feat(git): add tag management and cherry-pick workflow
This update extends the Git backend and UI to support listing, creating, deleting, and pushing tags. It also adds cherry-pick commands with proper in-progress detection and conflict handling, and prevents other operations while a cherry-pick is active. - Add GitTag model, tag listing, and tag CRUD/push commands - Implement cherry-pick start/continue/abort with status tracking - Update UI to display tags and gate actions during cherry-pick
This commit is contained in:
@@ -79,7 +79,13 @@
|
|||||||
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
"Bash(rustfmt --edition 2024 --check src/git.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
"Bash(rustfmt --edition 2024 --check src/badge.rs)",
|
||||||
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
|
"Bash(rustfmt --edition 2024 --check src/git.rs src/main.rs)",
|
||||||
"Bash(pkg-config --list-all)"
|
"Bash(pkg-config --list-all)",
|
||||||
|
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata src/git.rs)",
|
||||||
|
"Bash(rustc --edition 2021 --crate-type lib -o /dev/null --emit=metadata /mnt/d/Development/GitLite/src-tauri/src/git.rs)",
|
||||||
|
"Bash(rustc --edition 2021 --crate-type bin -o /dev/null --emit=metadata src/main.rs)",
|
||||||
|
"Bash(grep -B1 \"^error\\\\[E0432\\\\]\\\\|^error$\")",
|
||||||
|
"Bash(grep -v \"^--$\")",
|
||||||
|
"Bash(grep \"^error$\" -A2)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ pub struct GitStatus {
|
|||||||
pub files: Vec<GitFileStatus>,
|
pub files: Vec<GitFileStatus>,
|
||||||
pub clean: bool,
|
pub clean: bool,
|
||||||
pub rebase_in_progress: bool,
|
pub rebase_in_progress: bool,
|
||||||
|
pub cherry_pick_in_progress: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
@@ -57,6 +58,16 @@ pub struct GitBranch {
|
|||||||
pub remote: bool,
|
pub remote: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct GitTag {
|
||||||
|
pub name: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub short_hash: String,
|
||||||
|
pub message: Option<String>,
|
||||||
|
pub date: String,
|
||||||
|
pub annotated: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||||
pub struct GitStash {
|
pub struct GitStash {
|
||||||
pub selector: String,
|
pub selector: String,
|
||||||
@@ -245,6 +256,7 @@ pub fn open_repository_file(path: String, file: String) -> Result<(), String> {
|
|||||||
pub struct RepositoryBundle {
|
pub struct RepositoryBundle {
|
||||||
pub status: GitStatus,
|
pub status: GitStatus,
|
||||||
pub branches: Vec<GitBranch>,
|
pub branches: Vec<GitBranch>,
|
||||||
|
pub tags: Vec<GitTag>,
|
||||||
pub stashes: Vec<GitStash>,
|
pub stashes: Vec<GitStash>,
|
||||||
pub commits: Vec<GitCommit>,
|
pub commits: Vec<GitCommit>,
|
||||||
pub files: Vec<GitRepositoryFile>,
|
pub files: Vec<GitRepositoryFile>,
|
||||||
@@ -287,12 +299,14 @@ pub async fn open_repository_bundle(
|
|||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
let status = status_for_repo(&repo)?;
|
let status = status_for_repo(&repo)?;
|
||||||
let branches = branches_for_repo(&repo)?;
|
let branches = branches_for_repo(&repo)?;
|
||||||
|
let tags = tags_for_repo(&repo)?;
|
||||||
let stashes = stashes_for_repo(&repo)?;
|
let stashes = stashes_for_repo(&repo)?;
|
||||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||||
let files = repository_files_with_status(&repo, &status)?;
|
let files = repository_files_with_status(&repo, &status)?;
|
||||||
Ok(RepositoryBundle {
|
Ok(RepositoryBundle {
|
||||||
status,
|
status,
|
||||||
branches,
|
branches,
|
||||||
|
tags,
|
||||||
stashes,
|
stashes,
|
||||||
commits,
|
commits,
|
||||||
files,
|
files,
|
||||||
@@ -320,6 +334,63 @@ pub fn list_stashes(path: String) -> Result<Vec<GitStash>, String> {
|
|||||||
stashes_for_repo(&repo)
|
stashes_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn list_tags(path: String) -> Result<Vec<GitTag>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
tags_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tags_for_repo(repo: &Path) -> Result<Vec<GitTag>, String> {
|
||||||
|
let output = run_git(
|
||||||
|
repo,
|
||||||
|
[
|
||||||
|
"for-each-ref",
|
||||||
|
"--sort=-creatordate",
|
||||||
|
"refs/tags",
|
||||||
|
"--format=%(refname:short)%00%(objectname)%00%(objectname:short)%00%(*objectname)%00%(*objectname:short)%00%(contents:subject)%00%(creatordate:iso-strict)",
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
let text = String::from_utf8_lossy(&output);
|
||||||
|
let mut tags = Vec::new();
|
||||||
|
|
||||||
|
for line in text.lines() {
|
||||||
|
let mut parts = line.splitn(7, '\0');
|
||||||
|
let name = parts.next().unwrap_or_default().trim();
|
||||||
|
let object_hash = parts.next().unwrap_or_default().trim();
|
||||||
|
let object_short = parts.next().unwrap_or_default().trim();
|
||||||
|
let deref_hash = parts.next().unwrap_or_default().trim();
|
||||||
|
let deref_short = parts.next().unwrap_or_default().trim();
|
||||||
|
let subject = parts.next().unwrap_or_default().trim();
|
||||||
|
let date = parts.next().unwrap_or_default().trim();
|
||||||
|
|
||||||
|
if name.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Annotated tags are their own object with a `taggerdate`/subject and
|
||||||
|
// dereference (`*...`) to the commit they point at; lightweight tags
|
||||||
|
// point straight at the commit, so the dereferenced fields are empty.
|
||||||
|
let annotated = !deref_hash.is_empty();
|
||||||
|
let hash = if annotated { deref_hash } else { object_hash };
|
||||||
|
let short_hash = if annotated { deref_short } else { object_short };
|
||||||
|
|
||||||
|
tags.push(GitTag {
|
||||||
|
name: name.to_string(),
|
||||||
|
hash: hash.to_string(),
|
||||||
|
short_hash: short_hash.to_string(),
|
||||||
|
message: if annotated && !subject.is_empty() {
|
||||||
|
Some(subject.to_string())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
},
|
||||||
|
date: date.to_string(),
|
||||||
|
annotated,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(tags)
|
||||||
|
}
|
||||||
|
|
||||||
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
fn branches_for_repo(repo: &Path) -> Result<Vec<GitBranch>, String> {
|
||||||
let output = run_git(
|
let output = run_git(
|
||||||
repo,
|
repo,
|
||||||
@@ -570,6 +641,123 @@ pub fn delete_branch(
|
|||||||
status_for_repo(&repo)
|
status_for_repo(&repo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn create_tag(
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
target: Option<String>,
|
||||||
|
message: Option<String>,
|
||||||
|
) -> Result<Vec<GitTag>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_new_tag_name(&repo, &name)?;
|
||||||
|
let target = match target {
|
||||||
|
Some(target) if !target.trim().is_empty() => verify_commit(&repo, &target)?,
|
||||||
|
_ => verify_commit(&repo, "HEAD")?,
|
||||||
|
};
|
||||||
|
let message = message
|
||||||
|
.map(|message| message.trim().to_string())
|
||||||
|
.filter(|message| !message.is_empty());
|
||||||
|
|
||||||
|
match message {
|
||||||
|
Some(message) => {
|
||||||
|
run_git(
|
||||||
|
&repo,
|
||||||
|
["tag", "-a", name.as_str(), "-m", message.as_str(), target.as_str()],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
run_git(&repo, ["tag", name.as_str(), target.as_str()])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tags_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn delete_tag(path: String, name: String) -> Result<Vec<GitTag>, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_existing_tag_name(&repo, &name)?;
|
||||||
|
run_git(&repo, ["tag", "-d", name.as_str()])?;
|
||||||
|
tags_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn push_tag(
|
||||||
|
path: String,
|
||||||
|
name: String,
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let name = validate_existing_tag_name(&repo, &name)?;
|
||||||
|
let remote = initial_push_remote_name(&repo)?;
|
||||||
|
let tag_ref = format!("refs/tags/{name}");
|
||||||
|
let push_args = ["push", remote.as_str(), tag_ref.as_str()];
|
||||||
|
|
||||||
|
match (username.as_deref(), password.as_deref()) {
|
||||||
|
(Some(u), Some(p)) if !u.is_empty() || !p.is_empty() => {
|
||||||
|
run_git_authenticated(&repo, push_args, u, p)?;
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
run_git(&repo, push_args)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_new_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Tag name must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = validate_tag_ref_name(name)?;
|
||||||
|
if ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||||
|
return Err(format!("Tag '{normalized}' already exists."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_existing_tag_name(repo: &Path, name: &str) -> Result<String, String> {
|
||||||
|
let name = name.trim();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("Tag name must not be empty.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = validate_tag_ref_name(name)?;
|
||||||
|
if !ref_exists(repo, &format!("refs/tags/{normalized}"))? {
|
||||||
|
return Err(format!("Tag '{normalized}' was not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(normalized)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_tag_ref_name(name: &str) -> Result<String, String> {
|
||||||
|
let output = git_command()
|
||||||
|
.args(["check-ref-format", "--allow-onelevel", &format!("refs/tags/{name}")])
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
|
if !output.status.success() {
|
||||||
|
let details = command_output_details(&output);
|
||||||
|
return Err(format!("Invalid tag name: {details}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let normalized = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||||
|
let normalized = normalized
|
||||||
|
.strip_prefix("refs/tags/")
|
||||||
|
.map(str::to_string)
|
||||||
|
.unwrap_or(normalized);
|
||||||
|
|
||||||
|
Ok(if normalized.is_empty() {
|
||||||
|
name.to_string()
|
||||||
|
} else {
|
||||||
|
normalized
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
pub fn stage_files(path: String, files: Vec<String>) -> Result<GitStatus, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -1214,6 +1402,68 @@ fn rebase_status_or_error(
|
|||||||
Err(format!("{context}: {}", command_output_details(&output)))
|
Err(format!("{context}: {}", command_output_details(&output)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cherry_pick_commit(path: String, commit: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
let commit_hash = verify_commit(&repo, &commit)?;
|
||||||
|
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["cherry-pick", commit_hash.as_str()])
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cherry_pick_continue(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if !cherry_pick_in_progress(&repo) {
|
||||||
|
return Err("No cherry-pick is currently in progress.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = git_command()
|
||||||
|
.arg("-C")
|
||||||
|
.arg(&repo)
|
||||||
|
.args(["cherry-pick", "--continue"])
|
||||||
|
.env("GIT_EDITOR", "true")
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("Could not start Git. Is Git installed? {err}"))?;
|
||||||
|
|
||||||
|
cherry_pick_status_or_error(&repo, output, "Cherry-pick continue failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn cherry_pick_abort(path: String) -> Result<GitStatus, String> {
|
||||||
|
let repo = resolve_repo(&path)?;
|
||||||
|
if !cherry_pick_in_progress(&repo) {
|
||||||
|
return Err("No cherry-pick is currently in progress.".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
run_git(&repo, ["cherry-pick", "--abort"])?;
|
||||||
|
status_for_repo(&repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cherry_pick_status_or_error(
|
||||||
|
repo: &Path,
|
||||||
|
output: Output,
|
||||||
|
context: &str,
|
||||||
|
) -> Result<GitStatus, String> {
|
||||||
|
if output.status.success() {
|
||||||
|
return status_for_repo(repo);
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = status_for_repo(repo)?;
|
||||||
|
if has_unresolved_conflicts(&status) || status.cherry_pick_in_progress {
|
||||||
|
return Ok(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(format!("{context}: {}", command_output_details(&output)))
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
pub fn list_commits(path: String, limit: Option<u32>) -> Result<Vec<GitCommit>, String> {
|
||||||
let repo = resolve_repo(&path)?;
|
let repo = resolve_repo(&path)?;
|
||||||
@@ -2049,6 +2299,7 @@ fn status_for_repo(repo: &Path) -> Result<GitStatus, String> {
|
|||||||
clean: files.is_empty(),
|
clean: files.is_empty(),
|
||||||
files,
|
files,
|
||||||
rebase_in_progress: rebase_in_progress(repo),
|
rebase_in_progress: rebase_in_progress(repo),
|
||||||
|
cherry_pick_in_progress: cherry_pick_in_progress(repo),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2056,6 +2307,10 @@ fn rebase_in_progress(repo: &Path) -> bool {
|
|||||||
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
git_path_exists(repo, "rebase-merge") || git_path_exists(repo, "rebase-apply")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn cherry_pick_in_progress(repo: &Path) -> bool {
|
||||||
|
git_path_exists(repo, "CHERRY_PICK_HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
fn git_path_exists(repo: &Path, name: &str) -> bool {
|
||||||
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
let Ok(output) = run_git(repo, ["rev-parse", "--git-path", name]) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -2245,6 +2500,7 @@ fn clone_repository_core(
|
|||||||
let repo = resolve_repo(&target.to_string_lossy())?;
|
let repo = resolve_repo(&target.to_string_lossy())?;
|
||||||
let status = status_for_repo(&repo)?;
|
let status = status_for_repo(&repo)?;
|
||||||
let branches = branches_for_repo(&repo)?;
|
let branches = branches_for_repo(&repo)?;
|
||||||
|
let tags = tags_for_repo(&repo)?;
|
||||||
let stashes = stashes_for_repo(&repo)?;
|
let stashes = stashes_for_repo(&repo)?;
|
||||||
let commits = commits_for_repo(&repo, commit_limit)?;
|
let commits = commits_for_repo(&repo, commit_limit)?;
|
||||||
let files = repository_files_with_status(&repo, &status)?;
|
let files = repository_files_with_status(&repo, &status)?;
|
||||||
@@ -2252,6 +2508,7 @@ fn clone_repository_core(
|
|||||||
Ok(RepositoryBundle {
|
Ok(RepositoryBundle {
|
||||||
status,
|
status,
|
||||||
branches,
|
branches,
|
||||||
|
tags,
|
||||||
stashes,
|
stashes,
|
||||||
commits,
|
commits,
|
||||||
files,
|
files,
|
||||||
|
|||||||
+16
-8
@@ -6,16 +6,17 @@ mod git;
|
|||||||
use badge::set_sync_badge;
|
use badge::set_sync_badge;
|
||||||
use git::{
|
use git::{
|
||||||
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
SearchCancellationState, apply_file_patch, cancel_code_search, cancel_file_history,
|
||||||
checkout_branch, clone_repository, commit, commit_ai_generate, commit_ai_load,
|
checkout_branch, cherry_pick_abort, cherry_pick_commit, cherry_pick_continue,
|
||||||
commit_ai_local_models, commit_ai_status, compare_commits, compare_file_to_head,
|
clone_repository, commit, commit_ai_generate, commit_ai_load, commit_ai_local_models,
|
||||||
compare_file_to_parent, create_branch, cred_delete, cred_load, cred_save, delete_branch,
|
commit_ai_status, compare_commits, compare_file_to_head, compare_file_to_parent,
|
||||||
|
create_branch, create_tag, cred_delete, cred_load, cred_save, delete_branch, delete_tag,
|
||||||
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
|
diff_file_against_working_tree, fetch, get_file_patch, get_remote_url, get_status,
|
||||||
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
|
list_branches, list_commits, list_file_history, list_repository_files, list_stashes,
|
||||||
merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
|
list_tags, merge_branch, open_repo_in_explorer, open_repository, open_repository_bundle,
|
||||||
open_repository_file, pull, push, read_conflict, rebase_abort, rebase_branch, rebase_continue,
|
open_repository_file, pull, push, push_tag, read_conflict, rebase_abort, rebase_branch,
|
||||||
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit,
|
rebase_continue, rename_branch, resolve_conflict, resolve_conflict_side,
|
||||||
restore_files, restore_to_commit, search_code_introductions, stage_files, stash_apply,
|
restore_file_from_commit, restore_files, restore_to_commit, search_code_introductions,
|
||||||
stash_drop, stash_pop, stash_push, unstage_files,
|
stage_files, stash_apply, stash_drop, stash_pop, stash_push, unstage_files,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -36,6 +37,13 @@ fn main() {
|
|||||||
create_branch,
|
create_branch,
|
||||||
rename_branch,
|
rename_branch,
|
||||||
delete_branch,
|
delete_branch,
|
||||||
|
list_tags,
|
||||||
|
create_tag,
|
||||||
|
delete_tag,
|
||||||
|
push_tag,
|
||||||
|
cherry_pick_commit,
|
||||||
|
cherry_pick_continue,
|
||||||
|
cherry_pick_abort,
|
||||||
stage_files,
|
stage_files,
|
||||||
unstage_files,
|
unstage_files,
|
||||||
stash_push,
|
stash_push,
|
||||||
|
|||||||
+129
-4
@@ -2,7 +2,7 @@
|
|||||||
import { onDestroy, onMount, tick } from "svelte";
|
import { onDestroy, onMount, tick } from "svelte";
|
||||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
import { check, type DownloadEvent, type Update } from "@tauri-apps/plugin-updater";
|
||||||
import { AlertCircle, BookOpen, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
import { AlertCircle, BookOpen, Cherry, Download, FolderOpen, GitBranch, GitMerge, LoaderCircle, Plus, Search, X } from "@lucide/svelte";
|
||||||
|
|
||||||
import TitleBar from "./lib/TitleBar.svelte";
|
import TitleBar from "./lib/TitleBar.svelte";
|
||||||
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
import AiSettingsDialog from "./lib/components/AiSettingsDialog.svelte";
|
||||||
@@ -29,6 +29,9 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
checkoutBranch,
|
checkoutBranch,
|
||||||
|
cherryPickAbort,
|
||||||
|
cherryPickCommit,
|
||||||
|
cherryPickContinue,
|
||||||
cloneRepository,
|
cloneRepository,
|
||||||
commit,
|
commit,
|
||||||
commitAiGenerate,
|
commitAiGenerate,
|
||||||
@@ -40,13 +43,16 @@
|
|||||||
cancelFileHistory,
|
cancelFileHistory,
|
||||||
applyFilePatch,
|
applyFilePatch,
|
||||||
createBranch,
|
createBranch,
|
||||||
|
createTag,
|
||||||
deleteBranch,
|
deleteBranch,
|
||||||
|
deleteTag,
|
||||||
diffFileAgainstWorkingTree,
|
diffFileAgainstWorkingTree,
|
||||||
compareFileToParent,
|
compareFileToParent,
|
||||||
fetchRemote,
|
fetchRemote,
|
||||||
getStatus,
|
getStatus,
|
||||||
listBranches,
|
listBranches,
|
||||||
listStashes,
|
listStashes,
|
||||||
|
listTags,
|
||||||
listCommits,
|
listCommits,
|
||||||
listFileHistory,
|
listFileHistory,
|
||||||
listRepositoryFiles,
|
listRepositoryFiles,
|
||||||
@@ -56,6 +62,7 @@
|
|||||||
openRepositoryBundle,
|
openRepositoryBundle,
|
||||||
pull,
|
pull,
|
||||||
push,
|
push,
|
||||||
|
pushTag,
|
||||||
renameBranch,
|
renameBranch,
|
||||||
rebaseAbort,
|
rebaseAbort,
|
||||||
rebaseBranch,
|
rebaseBranch,
|
||||||
@@ -97,6 +104,7 @@
|
|||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
GitTag,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
PreparedResolution,
|
PreparedResolution,
|
||||||
@@ -160,6 +168,7 @@
|
|||||||
let pendingClone: CloneRequest | null = null;
|
let pendingClone: CloneRequest | null = null;
|
||||||
let status: GitStatus | null = null;
|
let status: GitStatus | null = null;
|
||||||
let branches: GitBranchInfo[] = [];
|
let branches: GitBranchInfo[] = [];
|
||||||
|
let tags: GitTag[] = [];
|
||||||
let stashes: GitStash[] = [];
|
let stashes: GitStash[] = [];
|
||||||
let commits: GitCommit[] = [];
|
let commits: GitCommit[] = [];
|
||||||
let repoFiles: GitRepositoryFile[] = [];
|
let repoFiles: GitRepositoryFile[] = [];
|
||||||
@@ -260,9 +269,12 @@
|
|||||||
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
|
$: conflictedFiles = changedFiles.filter((f) => f.staged === "conflicted" || f.unstaged === "conflicted");
|
||||||
$: hasConflicts = conflictedFiles.length > 0;
|
$: hasConflicts = conflictedFiles.length > 0;
|
||||||
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
$: rebaseInProgress = status?.rebase_in_progress ?? false;
|
||||||
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !isBusy;
|
$: cherryPickInProgress = status?.cherry_pick_in_progress ?? false;
|
||||||
|
$: canCommit = hasRepository && stagedCount > 0 && commitMessage.trim().length > 0 && !hasConflicts && !rebaseInProgress && !cherryPickInProgress && !isBusy;
|
||||||
$: commitBlockReason = rebaseInProgress
|
$: commitBlockReason = rebaseInProgress
|
||||||
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
|
? "A rebase is in progress. Resolve conflicts and use Rebase continue or abort the rebase."
|
||||||
|
: cherryPickInProgress
|
||||||
|
? "A cherry-pick is in progress. Resolve conflicts and use Cherry-pick continue or abort it."
|
||||||
: hasConflicts
|
: hasConflicts
|
||||||
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
|
? `${conflictedFiles.length} ${conflictedFiles.length === 1 ? "conflict must" : "conflicts must"} be resolved before committing.`
|
||||||
: "";
|
: "";
|
||||||
@@ -360,6 +372,7 @@
|
|||||||
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
const bundle = await openRepositoryBundle(activeRepoPath, 100);
|
||||||
const previousHeadHash = lastFileHistoryHeadHash;
|
const previousHeadHash = lastFileHistoryHeadHash;
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshTags(activeRepoPath, bundle.tags);
|
||||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
@@ -941,6 +954,10 @@
|
|||||||
branches = prefetched ?? (await listBranches(path));
|
branches = prefetched ?? (await listBranches(path));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshTags(path = activeRepoPath, prefetched?: GitTag[]) {
|
||||||
|
tags = prefetched ?? (await listTags(path));
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
async function refreshStashes(path = activeRepoPath, prefetched?: GitStash[]) {
|
||||||
stashes = prefetched ?? (await listStashes(path));
|
stashes = prefetched ?? (await listStashes(path));
|
||||||
}
|
}
|
||||||
@@ -1039,6 +1056,7 @@
|
|||||||
if (globalSearchBusy) void cancelGlobalSearch();
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
activeView = "repository";
|
activeView = "repository";
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshTags(activeRepoPath, bundle.tags);
|
||||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
@@ -1106,6 +1124,7 @@
|
|||||||
if (globalSearchBusy) void cancelGlobalSearch();
|
if (globalSearchBusy) void cancelGlobalSearch();
|
||||||
activeView = "repository";
|
activeView = "repository";
|
||||||
await refreshBranchList(activeRepoPath, bundle.branches);
|
await refreshBranchList(activeRepoPath, bundle.branches);
|
||||||
|
await refreshTags(activeRepoPath, bundle.tags);
|
||||||
await refreshStashes(activeRepoPath, bundle.stashes);
|
await refreshStashes(activeRepoPath, bundle.stashes);
|
||||||
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
await refreshCommitHistory(activeRepoPath, bundle.commits);
|
||||||
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
await refreshExplorerFiles(activeRepoPath, bundle.files);
|
||||||
@@ -1327,7 +1346,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function rebaseOnto(branch: GitBranchInfo) {
|
async function rebaseOnto(branch: GitBranchInfo) {
|
||||||
if (!activeRepoPath || branch.current || rebaseInProgress) return;
|
if (!activeRepoPath || branch.current || rebaseInProgress || cherryPickInProgress) return;
|
||||||
await runOperation(`Rebasing onto ${branch.name}`, async () => {
|
await runOperation(`Rebasing onto ${branch.name}`, async () => {
|
||||||
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
|
applyStatus(await rebaseBranch(activeRepoPath, branch.name));
|
||||||
await refreshBranchList(activeRepoPath);
|
await refreshBranchList(activeRepoPath);
|
||||||
@@ -1366,6 +1385,84 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function createNewTag(name: string, message: string) {
|
||||||
|
const trimmed = name.trim();
|
||||||
|
if (!activeRepoPath || !trimmed) return;
|
||||||
|
await runOperation(`Creating tag ${trimmed}`, async () => {
|
||||||
|
await refreshTags(activeRepoPath, await createTag(activeRepoPath, trimmed, undefined, message));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteLocalTag(tag: GitTag) {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
|
const confirmed = window.confirm(`Delete tag '${tag.name}'?\n\nThis only removes the local tag, not any copy already pushed to a remote.`);
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await runOperation(`Deleting tag ${tag.name}`, async () => {
|
||||||
|
await refreshTags(activeRepoPath, await deleteTag(activeRepoPath, tag.name));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushLocalTag(tag: GitTag) {
|
||||||
|
if (!activeRepoPath || isBusy) return;
|
||||||
|
const key = await currentCredKey();
|
||||||
|
const stored = await loadStoredCredential(key);
|
||||||
|
const credential = stored && !isCredentialExpired(stored) ? stored : null;
|
||||||
|
|
||||||
|
await runOperation(`Pushing tag ${tag.name}`, async () => {
|
||||||
|
try {
|
||||||
|
await pushTag(activeRepoPath, tag.name, credential?.username, credential?.password);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorToMessage(error);
|
||||||
|
throw new Error(
|
||||||
|
isAuthError(message)
|
||||||
|
? `Sign in via the Push button first, then retry pushing tag '${tag.name}'.`
|
||||||
|
: stripAuthPrefix(message),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cherryPickFromCommit(commit: GitCommit) {
|
||||||
|
if (!activeRepoPath || rebaseInProgress || cherryPickInProgress) return;
|
||||||
|
await runOperation(`Cherry-picking ${commit.short_hash}`, async () => {
|
||||||
|
applyStatus(await cherryPickCommit(activeRepoPath, commit.hash));
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function continueCherryPick() {
|
||||||
|
if (!activeRepoPath || !cherryPickInProgress || hasConflicts) return;
|
||||||
|
await runOperation("Continuing cherry-pick", async () => {
|
||||||
|
applyStatus(await cherryPickContinue(activeRepoPath));
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function abortCherryPick() {
|
||||||
|
if (!activeRepoPath || !cherryPickInProgress) return;
|
||||||
|
const confirmed = window.confirm("Abort the current cherry-pick and return to the previous state?");
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
await runOperation("Aborting cherry-pick", async () => {
|
||||||
|
applyStatus(await cherryPickAbort(activeRepoPath));
|
||||||
|
preparedResolutions = {};
|
||||||
|
resolveDialogOpen = false;
|
||||||
|
conflict = null;
|
||||||
|
conflictTarget = "";
|
||||||
|
await refreshBranchList(activeRepoPath);
|
||||||
|
await refreshCommitHistory(activeRepoPath);
|
||||||
|
await refreshExplorerFiles(activeRepoPath);
|
||||||
|
await refreshFileHistory(activeRepoPath);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Resolve the keychain key (host/org) for the active repo's remote.
|
// Resolve the keychain key (host/org) for the active repo's remote.
|
||||||
async function currentCredKey(): Promise<string | null> {
|
async function currentCredKey(): Promise<string | null> {
|
||||||
if (!activeRepoPath) return null;
|
if (!activeRepoPath) return null;
|
||||||
@@ -1783,6 +1880,10 @@
|
|||||||
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
|
errorMessage = "A rebase is in progress. Use Rebase continue or abort the rebase.";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (cherryPickInProgress) {
|
||||||
|
errorMessage = "A cherry-pick is in progress. Use Cherry-pick continue or abort it.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
await runOperation("Committing", async () => {
|
await runOperation("Committing", async () => {
|
||||||
applyStatus(await commit(activeRepoPath, message));
|
applyStatus(await commit(activeRepoPath, message));
|
||||||
commitMessage = "";
|
commitMessage = "";
|
||||||
@@ -2201,7 +2302,7 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if workspaceActive && hasConflicts && !rebaseInProgress}
|
{#if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||||
<section class="notice conflict" role="alert">
|
<section class="notice conflict" role="alert">
|
||||||
<GitMerge size={17} aria-hidden="true" />
|
<GitMerge size={17} aria-hidden="true" />
|
||||||
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
<span>{conflictedFiles.length} {conflictedFiles.length === 1 ? "file has" : "files have"} conflicts.</span>
|
||||||
@@ -2228,6 +2329,25 @@
|
|||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if workspaceActive && cherryPickInProgress}
|
||||||
|
<section class="notice rebase" role="status">
|
||||||
|
<Cherry size={17} aria-hidden="true" />
|
||||||
|
<span>
|
||||||
|
Cherry-pick in progress.
|
||||||
|
{#if hasConflicts}
|
||||||
|
Resolve conflicts, then continue.
|
||||||
|
{:else}
|
||||||
|
Continue when the index is ready, or abort to return to the previous state.
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{#if hasConflicts}
|
||||||
|
<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>
|
||||||
|
{/if}
|
||||||
|
<button type="button" onclick={continueCherryPick} disabled={isBusy || hasConflicts}>Continue</button>
|
||||||
|
<button type="button" onclick={abortCherryPick} disabled={isBusy}>Abort</button>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if activeView === "management"}
|
{#if activeView === "management"}
|
||||||
<section class="repo-management" aria-label="Repository Management">
|
<section class="repo-management" aria-label="Repository Management">
|
||||||
<div class="repo-management-head">
|
<div class="repo-management-head">
|
||||||
@@ -2354,6 +2474,7 @@
|
|||||||
{branches}
|
{branches}
|
||||||
{localBranches}
|
{localBranches}
|
||||||
{remoteBranches}
|
{remoteBranches}
|
||||||
|
{tags}
|
||||||
{hasRepository}
|
{hasRepository}
|
||||||
{isBusy}
|
{isBusy}
|
||||||
onCheckout={checkout}
|
onCheckout={checkout}
|
||||||
@@ -2362,6 +2483,9 @@
|
|||||||
onCreateBranch={createNewBranch}
|
onCreateBranch={createNewBranch}
|
||||||
onRenameBranch={renameLocalBranch}
|
onRenameBranch={renameLocalBranch}
|
||||||
onDeleteBranch={deleteLocalBranch}
|
onDeleteBranch={deleteLocalBranch}
|
||||||
|
onCreateTag={createNewTag}
|
||||||
|
onDeleteTag={deleteLocalTag}
|
||||||
|
onPushTag={pushLocalTag}
|
||||||
/>
|
/>
|
||||||
<StashPanel
|
<StashPanel
|
||||||
{stashes}
|
{stashes}
|
||||||
@@ -2490,6 +2614,7 @@
|
|||||||
onRestoreCommit={restoreCommit}
|
onRestoreCommit={restoreCommit}
|
||||||
onPreviewCommitFile={previewCommitFileFromHistory}
|
onPreviewCommitFile={previewCommitFileFromHistory}
|
||||||
onCreateBranchFromCommit={openNewBranchDialog}
|
onCreateBranchFromCommit={openNewBranchDialog}
|
||||||
|
onCherryPickCommit={cherryPickFromCommit}
|
||||||
onToggleCommitFiles={(hash) => {
|
onToggleCommitFiles={(hash) => {
|
||||||
const next = new Set(expandedCommitHashes);
|
const next = new Set(expandedCommitHashes);
|
||||||
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
if (next.has(hash)) next.delete(hash); else next.add(hash);
|
||||||
|
|||||||
@@ -1550,6 +1550,12 @@
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-group-head { display: flex; align-items: center; gap: 6px; }
|
||||||
|
.tag-group-head .branch-group-toggle { flex: 1; min-width: 0; }
|
||||||
|
.tag-group-head .branch-create-toggle { flex-shrink: 0; }
|
||||||
|
|
||||||
|
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
|
||||||
|
|
||||||
.branch-empty {
|
.branch-empty {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
color: var(--color-ink-faint);
|
color: var(--color-ink-faint);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Trash2, X } from "@lucide/svelte";
|
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
|
||||||
import type { GitBranch as GitBranchInfo } from "../types";
|
import type { GitBranch as GitBranchInfo, GitTag } from "../types";
|
||||||
|
|
||||||
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
|
||||||
|
|
||||||
@@ -45,6 +45,7 @@
|
|||||||
branches: GitBranchInfo[];
|
branches: GitBranchInfo[];
|
||||||
localBranches: GitBranchInfo[];
|
localBranches: GitBranchInfo[];
|
||||||
remoteBranches: GitBranchInfo[];
|
remoteBranches: GitBranchInfo[];
|
||||||
|
tags: GitTag[];
|
||||||
hasRepository: boolean;
|
hasRepository: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCheckout: (branch: GitBranchInfo) => void;
|
onCheckout: (branch: GitBranchInfo) => void;
|
||||||
@@ -53,12 +54,16 @@
|
|||||||
onCreateBranch: (branchName: string) => void | Promise<void>;
|
onCreateBranch: (branchName: string) => void | Promise<void>;
|
||||||
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onRenameBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
|
||||||
|
onCreateTag: (name: string, message: string) => void | Promise<void>;
|
||||||
|
onDeleteTag: (tag: GitTag) => void | Promise<void>;
|
||||||
|
onPushTag: (tag: GitTag) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
branches = [],
|
branches = [],
|
||||||
localBranches = [],
|
localBranches = [],
|
||||||
remoteBranches = [],
|
remoteBranches = [],
|
||||||
|
tags = [],
|
||||||
hasRepository = false,
|
hasRepository = false,
|
||||||
isBusy = false,
|
isBusy = false,
|
||||||
onCheckout = () => {},
|
onCheckout = () => {},
|
||||||
@@ -67,17 +72,28 @@
|
|||||||
onCreateBranch = () => {},
|
onCreateBranch = () => {},
|
||||||
onRenameBranch = () => {},
|
onRenameBranch = () => {},
|
||||||
onDeleteBranch = () => {},
|
onDeleteBranch = () => {},
|
||||||
|
onCreateTag = () => {},
|
||||||
|
onDeleteTag = () => {},
|
||||||
|
onPushTag = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let localOpen = $state(true);
|
let localOpen = $state(true);
|
||||||
let remoteOpen = $state(false);
|
let remoteOpen = $state(false);
|
||||||
|
let tagsOpen = $state(false);
|
||||||
let createOpen = $state(false);
|
let createOpen = $state(false);
|
||||||
let newBranchName = $state("");
|
let newBranchName = $state("");
|
||||||
let createInput = $state<HTMLInputElement | null>(null);
|
let createInput = $state<HTMLInputElement | null>(null);
|
||||||
|
let tagCreateOpen = $state(false);
|
||||||
|
let newTagName = $state("");
|
||||||
|
let newTagMessage = $state("");
|
||||||
|
let tagCreateInput = $state<HTMLInputElement | null>(null);
|
||||||
let panelElement = $state<HTMLElement | null>(null);
|
let panelElement = $state<HTMLElement | null>(null);
|
||||||
let contextBranch = $state<GitBranchInfo | null>(null);
|
let contextBranch = $state<GitBranchInfo | null>(null);
|
||||||
let contextMenuX = $state(0);
|
let contextMenuX = $state(0);
|
||||||
let contextMenuY = $state(0);
|
let contextMenuY = $state(0);
|
||||||
|
let contextTag = $state<GitTag | null>(null);
|
||||||
|
let tagContextMenuX = $state(0);
|
||||||
|
let tagContextMenuY = $state(0);
|
||||||
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
let collapsedBranchFolders = $state<Set<string>>(new Set());
|
||||||
|
|
||||||
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
|
||||||
@@ -270,12 +286,74 @@
|
|||||||
await onRebase(branch);
|
await onRebase(branch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openTagCreateForm() {
|
||||||
|
if (!hasRepository || isBusy) return;
|
||||||
|
tagCreateOpen = true;
|
||||||
|
queueMicrotask(() => tagCreateInput?.focus());
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagCreateForm() {
|
||||||
|
tagCreateOpen = false;
|
||||||
|
newTagName = "";
|
||||||
|
newTagMessage = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreateTag(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const name = newTagName.trim();
|
||||||
|
if (!name || !hasRepository || isBusy) return;
|
||||||
|
await onCreateTag(name, newTagMessage.trim());
|
||||||
|
newTagName = "";
|
||||||
|
newTagMessage = "";
|
||||||
|
tagCreateOpen = false;
|
||||||
|
tagsOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTagContextMenu(event: MouseEvent, tag: GitTag) {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (isBusy) return;
|
||||||
|
|
||||||
|
const rect = panelElement?.getBoundingClientRect();
|
||||||
|
const rawX = rect ? event.clientX - rect.left : event.offsetX;
|
||||||
|
const rawY = rect ? event.clientY - rect.top : event.offsetY;
|
||||||
|
const maxX = Math.max(8, (rect?.width ?? window.innerWidth) - 192);
|
||||||
|
const maxY = Math.max(8, (rect?.height ?? window.innerHeight) - 130);
|
||||||
|
|
||||||
|
contextTag = tag;
|
||||||
|
tagContextMenuX = Math.max(8, Math.min(rawX, maxX));
|
||||||
|
tagContextMenuY = Math.max(8, Math.min(rawY, maxY));
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeTagContextMenu() {
|
||||||
|
contextTag = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushContextTag() {
|
||||||
|
const tag = contextTag;
|
||||||
|
if (!tag || isBusy) return;
|
||||||
|
closeTagContextMenu();
|
||||||
|
await onPushTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteContextTag() {
|
||||||
|
const tag = contextTag;
|
||||||
|
if (!tag || isBusy) return;
|
||||||
|
closeTagContextMenu();
|
||||||
|
await onDeleteTag(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAllContextMenus() {
|
||||||
|
closeBranchContextMenu();
|
||||||
|
closeTagContextMenu();
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === "Escape") closeBranchContextMenu();
|
if (event.key === "Escape") closeAllContextMenus();
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:window on:click={closeBranchContextMenu} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeBranchContextMenu} />
|
<svelte:window on:click={closeAllContextMenus} on:keydown={handleWindowKeydown} on:contextmenu|capture={closeAllContextMenus} />
|
||||||
|
|
||||||
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
<section bind:this={panelElement} class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Branches">
|
||||||
<div class="section-head">
|
<div class="section-head">
|
||||||
@@ -300,7 +378,7 @@
|
|||||||
|
|
||||||
{#if !hasRepository}
|
{#if !hasRepository}
|
||||||
<p class="blank-state">Open a repository to list branches.</p>
|
<p class="blank-state">Open a repository to list branches.</p>
|
||||||
{:else if branches.length === 0}
|
{:else if branches.length === 0 && tags.length === 0}
|
||||||
<p class="blank-state">No branches returned.</p>
|
<p class="blank-state">No branches returned.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
<div class="branch-list overflow-auto p-2 flex flex-col gap-0">
|
||||||
@@ -458,6 +536,86 @@
|
|||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="branch-group">
|
||||||
|
<div class="tag-group-head">
|
||||||
|
<button
|
||||||
|
class="branch-group-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={() => { tagsOpen = !tagsOpen; }}
|
||||||
|
aria-expanded={tagsOpen}
|
||||||
|
>
|
||||||
|
{#if tagsOpen}
|
||||||
|
<ChevronDown size={14} aria-hidden="true" />
|
||||||
|
{:else}
|
||||||
|
<ChevronRight size={14} aria-hidden="true" />
|
||||||
|
{/if}
|
||||||
|
<span>Tags</span>
|
||||||
|
<span class="branch-group-count">{tags.length}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="branch-create-toggle"
|
||||||
|
type="button"
|
||||||
|
onclick={openTagCreateForm}
|
||||||
|
disabled={!hasRepository || isBusy}
|
||||||
|
title="Create new tag"
|
||||||
|
aria-label="Create new tag"
|
||||||
|
>
|
||||||
|
<Plus size={13} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if tagCreateOpen}
|
||||||
|
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
|
||||||
|
<TagIcon size={15} aria-hidden="true" />
|
||||||
|
<input
|
||||||
|
bind:this={tagCreateInput}
|
||||||
|
bind:value={newTagName}
|
||||||
|
disabled={isBusy}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="v1.0.0"
|
||||||
|
aria-label="New tag name"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
bind:value={newTagMessage}
|
||||||
|
disabled={isBusy}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="Message (optional)"
|
||||||
|
aria-label="Tag message"
|
||||||
|
/>
|
||||||
|
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
|
||||||
|
<Check size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
|
||||||
|
<X size={14} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if tagsOpen}
|
||||||
|
{#if tags.length === 0}
|
||||||
|
<div class="branch-empty">No tags.</div>
|
||||||
|
{:else}
|
||||||
|
{#each tags as tag (tag.name)}
|
||||||
|
<article
|
||||||
|
class="branch-row"
|
||||||
|
oncontextmenu={(event) => openTagContextMenu(event, tag)}
|
||||||
|
title={tag.message ?? tag.name}
|
||||||
|
>
|
||||||
|
<div class="branch-info">
|
||||||
|
<TagIcon size={16} aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<strong>{tag.name}</strong>
|
||||||
|
<span>{tag.short_hash}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -499,4 +657,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if contextTag}
|
||||||
|
<div
|
||||||
|
class="branch-context-menu"
|
||||||
|
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
|
||||||
|
role="menu"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-label={`Actions for ${contextTag.name}`}
|
||||||
|
>
|
||||||
|
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
|
||||||
|
<Upload size={14} aria-hidden="true" />
|
||||||
|
Push to remote
|
||||||
|
</button>
|
||||||
|
<div class="menu-separator" role="separator"></div>
|
||||||
|
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
|
||||||
|
<Trash2 size={14} aria-hidden="true" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
import { Cherry, ChevronDown, ChevronRight, EllipsisVertical, GitBranch, GitMerge, RotateCcw, X } from "@lucide/svelte";
|
||||||
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
|
||||||
|
|
||||||
interface GraphSegment {
|
interface GraphSegment {
|
||||||
@@ -40,6 +40,7 @@
|
|||||||
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
onPreviewCommitFile: (commit: GitCommit, file: GitCommitFile) => void;
|
||||||
onToggleCommitFiles: (hash: string) => void;
|
onToggleCommitFiles: (hash: string) => void;
|
||||||
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
onCreateBranchFromCommit: (commit: GitCommit) => void;
|
||||||
|
onCherryPickCommit: (commit: GitCommit) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -54,6 +55,7 @@
|
|||||||
onPreviewCommitFile = () => {},
|
onPreviewCommitFile = () => {},
|
||||||
onToggleCommitFiles = () => {},
|
onToggleCommitFiles = () => {},
|
||||||
onCreateBranchFromCommit = () => {},
|
onCreateBranchFromCommit = () => {},
|
||||||
|
onCherryPickCommit = () => {},
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
let hiddenGraphBranches = $state<Set<string>>(new Set());
|
||||||
@@ -380,6 +382,13 @@
|
|||||||
await onRestoreCommit(commit);
|
await onRestoreCommit(commit);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function cherryPickContextCommit() {
|
||||||
|
const commit = contextCommit;
|
||||||
|
if (!commit || isBusy) return;
|
||||||
|
closeCommitContextMenu();
|
||||||
|
await onCherryPickCommit(commit);
|
||||||
|
}
|
||||||
|
|
||||||
function handleWindowKeydown(event: KeyboardEvent) {
|
function handleWindowKeydown(event: KeyboardEvent) {
|
||||||
if (event.key !== "Escape") return;
|
if (event.key !== "Escape") return;
|
||||||
closeCommitContextMenu();
|
closeCommitContextMenu();
|
||||||
@@ -656,6 +665,16 @@
|
|||||||
<RotateCcw size={14} aria-hidden="true" />
|
<RotateCcw size={14} aria-hidden="true" />
|
||||||
Restore
|
Restore
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
onclick={cherryPickContextCommit}
|
||||||
|
disabled={isBusy}
|
||||||
|
title="Apply this commit's changes on top of the current branch"
|
||||||
|
>
|
||||||
|
<Cherry size={14} aria-hidden="true" />
|
||||||
|
Cherry-pick
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
GitSearchHit,
|
GitSearchHit,
|
||||||
GitStash,
|
GitStash,
|
||||||
GitStatus,
|
GitStatus,
|
||||||
|
GitTag,
|
||||||
LocalModelOption,
|
LocalModelOption,
|
||||||
PatchApplyAction,
|
PatchApplyAction,
|
||||||
RepositoryBundle,
|
RepositoryBundle,
|
||||||
@@ -94,6 +95,49 @@ export function deleteBranch(path: string, branch: string, force = false): Promi
|
|||||||
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
return invoke<GitStatus>("delete_branch", { path, branch, force });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function listTags(path: string): Promise<GitTag[]> {
|
||||||
|
return invoke<GitTag[]>("list_tags", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTag(
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
target?: string,
|
||||||
|
message?: string,
|
||||||
|
): Promise<GitTag[]> {
|
||||||
|
return invoke<GitTag[]>("create_tag", {
|
||||||
|
path,
|
||||||
|
name,
|
||||||
|
target: target?.trim() ? target.trim() : null,
|
||||||
|
message: message?.trim() ? message.trim() : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteTag(path: string, name: string): Promise<GitTag[]> {
|
||||||
|
return invoke<GitTag[]>("delete_tag", { path, name });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushTag(
|
||||||
|
path: string,
|
||||||
|
name: string,
|
||||||
|
username?: string,
|
||||||
|
password?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return invoke<void>("push_tag", { path, name, username: username ?? null, password: password ?? null });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cherryPickCommit(path: string, commit: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("cherry_pick_commit", { path, commit });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cherryPickContinue(path: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("cherry_pick_continue", { path });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function cherryPickAbort(path: string): Promise<GitStatus> {
|
||||||
|
return invoke<GitStatus>("cherry_pick_abort", { path });
|
||||||
|
}
|
||||||
|
|
||||||
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
export function stageFiles(path: string, files: string[]): Promise<GitStatus> {
|
||||||
return invoke<GitStatus>("stage_files", { path, files });
|
return invoke<GitStatus>("stage_files", { path, files });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ export interface GitStatus {
|
|||||||
files: GitFileStatus[];
|
files: GitFileStatus[];
|
||||||
clean: boolean;
|
clean: boolean;
|
||||||
rebase_in_progress: boolean;
|
rebase_in_progress: boolean;
|
||||||
|
cherry_pick_in_progress: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GitFileStatus {
|
export interface GitFileStatus {
|
||||||
@@ -59,6 +60,15 @@ export interface GitBranch {
|
|||||||
remote: boolean;
|
remote: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GitTag {
|
||||||
|
name: string;
|
||||||
|
hash: string;
|
||||||
|
short_hash: string;
|
||||||
|
message: string | null;
|
||||||
|
date: string;
|
||||||
|
annotated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GitStash {
|
export interface GitStash {
|
||||||
selector: string;
|
selector: string;
|
||||||
index: number;
|
index: number;
|
||||||
@@ -95,6 +105,7 @@ export interface GitRepositoryFile {
|
|||||||
export interface RepositoryBundle {
|
export interface RepositoryBundle {
|
||||||
status: GitStatus;
|
status: GitStatus;
|
||||||
branches: GitBranch[];
|
branches: GitBranch[];
|
||||||
|
tags: GitTag[];
|
||||||
stashes: GitStash[];
|
stashes: GitStash[];
|
||||||
commits: GitCommit[];
|
commits: GitCommit[];
|
||||||
files: GitRepositoryFile[];
|
files: GitRepositoryFile[];
|
||||||
|
|||||||
Reference in New Issue
Block a user