From 38b7f1a5362559783eb1a8f7fa28ea259698d868 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 26 Jul 2026 21:47:35 +0200 Subject: [PATCH 1/3] feat(git): Add comprehensive worktree management capabilities This update introduces full support for Git worktrees, allowing users to manage multiple isolated working copies within a single repository. This includes new functionality to list, add, remove, move, lock, and repair worktrees, significantly enhancing the repository's capability to handle parallel development streams. - Added `GitWorktree` structure definition across API contracts and Rust backend - Implemented full CRUD operations for worktrees in Tauri commands - Updated UI components (App.svelte, BranchPanel.svelte) to expose worktree management dialog --- docs/api-contract.md | 26 ++ src-tauri/src/git.rs | 374 +++++++++++++++++++ src-tauri/src/main.rs | 33 +- src/App.svelte | 185 ++++++++- src/app.css | 251 +++++++++++++ src/lib/components/BranchPanel.svelte | 27 +- src/lib/components/WorktreeDialog.svelte | 455 +++++++++++++++++++++++ src/lib/git.ts | 51 +++ src/lib/types.ts | 18 + 9 files changed, 1406 insertions(+), 14 deletions(-) create mode 100644 src/lib/components/WorktreeDialog.svelte diff --git a/docs/api-contract.md b/docs/api-contract.md index 00db310..c0b4a41 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -33,6 +33,24 @@ interface GitBranch { remote: boolean; } +interface GitWorktree { + path: string; + head: string | null; + short_head: string | null; + branch: string | null; + bare: boolean; + detached: boolean; + locked: boolean; + lock_reason: string | null; + prunable: boolean; + prune_reason: string | null; + missing: boolean; + is_main: boolean; + is_current: boolean; + clean: boolean; + changed_files: number; +} + interface GitCommit { hash: string; short_hash: string; @@ -73,6 +91,14 @@ The command list below includes the repository-management and synchronization AP - `set_branch_upstream(path: string, branch: string, upstream?: string): Promise` - `delete_remote_branch(path: string, remote: string, branch: string): Promise` - `checkout_branch(path: string, branch: string): Promise` +- `list_worktrees(path: string): Promise` +- `add_worktree(path: string, worktreePath: string, ...): Promise` +- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise` +- `move_worktree(path: string, worktreePath: string, destination: string): Promise` +- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise` +- `unlock_worktree(path: string, worktreePath: string): Promise` +- `prune_worktrees(path: string): Promise` +- `repair_worktree(path: string, worktreePath: string): Promise` - `stage_files(path: string, files: string[]): Promise` - `unstage_files(path: string, files: string[]): Promise` - `restore_files(path: string, files: string[], staged: boolean): Promise` diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 1475064..98722f0 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -68,6 +68,25 @@ pub struct GitBranch { pub remote: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct GitWorktree { + pub path: String, + pub head: Option, + pub short_head: Option, + pub branch: Option, + pub bare: bool, + pub detached: bool, + pub locked: bool, + pub lock_reason: Option, + pub prunable: bool, + pub prune_reason: Option, + pub missing: bool, + pub is_main: bool, + pub is_current: bool, + pub clean: bool, + pub changed_files: u32, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct GitTag { pub name: String, @@ -927,6 +946,291 @@ pub fn delete_branch( status_for_repo(&repo) } +fn worktree_path_matches(left: &Path, right: &Path) -> bool { + match (fs::canonicalize(left), fs::canonicalize(right)) { + (Ok(left), Ok(right)) => left == right, + _ => left == right, + } +} + +fn parse_worktree_porcelain(output: &[u8], current_repo: &Path) -> Vec { + #[derive(Default)] + struct Record { + path: Option, + head: Option, + branch: Option, + bare: bool, + detached: bool, + locked: bool, + lock_reason: Option, + prunable: bool, + prune_reason: Option, + } + + fn finish_record(rows: &mut Vec, record: &mut Record, current_repo: &Path) { + let Some(path) = record.path.take() else { + *record = Record::default(); + return; + }; + let path_buf = PathBuf::from(&path); + let missing = !path_buf.exists(); + let is_current = worktree_path_matches(&path_buf, current_repo); + let head = record.head.take(); + let short_head = head + .as_ref() + .map(|value| value.chars().take(8).collect::()); + rows.push(GitWorktree { + path, + head, + short_head, + branch: record.branch.take(), + bare: record.bare, + detached: record.detached, + locked: record.locked, + lock_reason: record.lock_reason.take(), + prunable: record.prunable, + prune_reason: record.prune_reason.take(), + missing, + is_main: rows.is_empty(), + is_current, + clean: true, + changed_files: 0, + }); + *record = Record::default(); + } + + let mut rows = Vec::new(); + let mut record = Record::default(); + for field in output.split(|byte| *byte == 0) { + if field.is_empty() { + finish_record(&mut rows, &mut record, current_repo); + continue; + } + let value = String::from_utf8_lossy(field); + let (key, detail) = value + .split_once(' ') + .map_or((value.as_ref(), None), |(key, detail)| (key, Some(detail))); + match key { + "worktree" => record.path = detail.map(str::to_string), + "HEAD" => record.head = detail.map(str::to_string), + "branch" => { + record.branch = detail.map(|branch| { + branch + .strip_prefix("refs/heads/") + .unwrap_or(branch) + .to_string() + }) + } + "bare" => record.bare = true, + "detached" => record.detached = true, + "locked" => { + record.locked = true; + record.lock_reason = detail.map(str::to_string).filter(|value| !value.is_empty()); + } + "prunable" => { + record.prunable = true; + record.prune_reason = detail.map(str::to_string).filter(|value| !value.is_empty()); + } + _ => {} + } + } + finish_record(&mut rows, &mut record, current_repo); + rows +} + +fn worktrees_for_repo(repo: &Path) -> Result, String> { + let output = run_git(repo, ["worktree", "list", "--porcelain", "-z"])?; + let mut worktrees = parse_worktree_porcelain(&output, repo); + for worktree in &mut worktrees { + if worktree.missing { + worktree.clean = false; + continue; + } + if worktree.bare { + continue; + } + match run_git_at( + Path::new(&worktree.path), + ["status", "--porcelain=v1", "-z", "--untracked-files=normal"], + "Could not inspect worktree status", + ) { + Ok(status) => { + worktree.changed_files = status + .split(|byte| *byte == 0) + .filter(|entry| entry.len() >= 3 && entry[2] == b' ') + .count() as u32; + worktree.clean = worktree.changed_files == 0; + } + Err(_) => { + worktree.clean = false; + } + } + } + Ok(worktrees) +} + +#[tauri::command] +pub fn list_worktrees(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn add_worktree( + path: String, + worktree_path: String, + branch: Option, + new_branch: Option, + start_point: Option, + detached: Option, + lock: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let destination = worktree_path.trim(); + if destination.is_empty() { + return Err("Choose a folder for the new worktree.".to_string()); + } + let branch = branch + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let new_branch = new_branch + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if branch.is_some() && new_branch.is_some() { + return Err("Choose either an existing branch or a new branch.".to_string()); + } + + let mut args = vec![OsString::from("worktree"), OsString::from("add")]; + if lock.unwrap_or(false) { + args.push(OsString::from("--lock")); + } + if detached.unwrap_or(false) { + args.push(OsString::from("--detach")); + } + + let target = if let Some(new_branch) = new_branch { + let new_branch = validate_new_branch_name(&repo, &new_branch)?; + args.push(OsString::from("-b")); + args.push(OsString::from(new_branch)); + start_point + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| verify_commit(&repo, value)) + .transpose()? + } else if let Some(branch) = branch { + Some(validate_existing_local_branch_name(&repo, &branch)?) + } else { + start_point + .as_deref() + .filter(|value| !value.trim().is_empty()) + .map(|value| verify_commit(&repo, value)) + .transpose()? + }; + + args.push(OsString::from(destination)); + if let Some(target) = target { + args.push(OsString::from(target)); + } + run_git(&repo, args)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn remove_worktree( + path: String, + worktree_path: String, + force: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let worktrees = worktrees_for_repo(&repo)?; + let target = worktrees + .iter() + .find(|worktree| { + worktree_path_matches(Path::new(&worktree.path), Path::new(&worktree_path)) + }) + .ok_or_else(|| "The selected worktree is no longer registered.".to_string())?; + if target.is_main { + return Err("The main worktree cannot be removed.".to_string()); + } + if target.is_current { + return Err("The currently open worktree cannot be removed.".to_string()); + } + if target.locked { + return Err("Unlock this worktree before removing it.".to_string()); + } + let mut args = vec![OsString::from("worktree"), OsString::from("remove")]; + if force.unwrap_or(false) { + args.push(OsString::from("--force")); + } + args.push(OsString::from(worktree_path)); + run_git(&repo, args)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn move_worktree( + path: String, + worktree_path: String, + destination: String, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let destination = destination.trim(); + if destination.is_empty() { + return Err("Choose a new location for the worktree.".to_string()); + } + run_git( + &repo, + [ + OsString::from("worktree"), + OsString::from("move"), + OsString::from(worktree_path), + OsString::from(destination), + ], + )?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn lock_worktree( + path: String, + worktree_path: String, + reason: Option, +) -> Result, String> { + let repo = resolve_repo(&path)?; + let mut args = vec![OsString::from("worktree"), OsString::from("lock")]; + if let Some(reason) = reason + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + { + args.push(OsString::from("--reason")); + args.push(OsString::from(reason)); + } + args.push(OsString::from(worktree_path)); + run_git(&repo, args)?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn unlock_worktree(path: String, worktree_path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "unlock", "--", worktree_path.as_str()])?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn prune_worktrees(path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "prune"])?; + worktrees_for_repo(&repo) +} + +#[tauri::command] +pub fn repair_worktree(path: String, worktree_path: String) -> Result, String> { + let repo = resolve_repo(&path)?; + run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?; + worktrees_for_repo(&repo) +} + #[tauri::command] pub fn create_tag( path: String, @@ -6687,6 +6991,76 @@ mod tests { assert!(result.lines[0].is_uncommitted); } + #[test] + fn parse_worktree_porcelain_preserves_flags_and_reasons() { + let current = Path::new("/repos/main"); + let output = b"worktree /repos/main\0HEAD 1234567890abcdef\0branch refs/heads/main\0\0worktree /repos/feature\0HEAD abcdef1234567890\0detached\0locked external drive\0prunable gitdir file points to non-existent location\0\0"; + + let rows = parse_worktree_porcelain(output, current); + + assert_eq!(rows.len(), 2); + assert!(rows[0].is_main); + assert_eq!(rows[0].branch.as_deref(), Some("main")); + assert_eq!(rows[0].short_head.as_deref(), Some("12345678")); + assert!(rows[1].detached); + assert!(rows[1].locked); + assert_eq!(rows[1].lock_reason.as_deref(), Some("external drive")); + assert!(rows[1].prunable); + } + + #[test] + fn worktree_add_list_and_remove_round_trip() { + let repo = init_temp_repo("worktree_round_trip"); + let destination = temp_dir("worktree_round_trip_destination"); + commit_initial_file(&repo.path); + run_git_test(&repo.path, ["branch", "feature"]); + + let rows = add_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + Some("feature".to_string()), + None, + None, + Some(false), + Some(false), + ) + .expect("worktree should be created"); + + let linked = rows + .iter() + .find(|row| row.branch.as_deref() == Some("feature")) + .expect("linked worktree should be listed"); + assert!(!linked.is_main); + assert!(linked.clean); + + let rows = lock_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + Some("test lock".to_string()), + ) + .expect("worktree should lock"); + let linked = rows + .iter() + .find(|row| row.branch.as_deref() == Some("feature")) + .expect("linked worktree should remain listed"); + assert!(linked.locked); + assert_eq!(linked.lock_reason.as_deref(), Some("test lock")); + + unlock_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + ) + .expect("worktree should unlock"); + + let rows = remove_worktree( + repo.path.to_string_lossy().to_string(), + destination.path.to_string_lossy().to_string(), + Some(false), + ) + .expect("worktree should be removed"); + assert_eq!(rows.len(), 1); + } + #[test] fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() { let raw = r#"```json diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index a41858c..8a5764e 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -6,23 +6,24 @@ mod telemetry; use badge::set_sync_badge; use git::{ - SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search, - cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, - cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, - commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, + SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch, + cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort, + cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate, + commit_ai_load, commit_ai_local_models, commit_ai_review, 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_remote_branch, delete_tag, diff_file_against_working_tree, fetch, get_file_blame, get_file_patch, get_remote_url, get_status, init_repository, last_commit_message, list_branches, list_commits, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, - list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue, - open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, - push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, - rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, - restore_files, restore_reflog_entry, restore_to_commit, revert_commit, - run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files, - start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, - unstage_files, update_remote, + list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort, + merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository, + open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag, + read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree, + rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side, + restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit, + revert_commit, run_sequence_editor_if_requested, search_code_introductions, + set_branch_upstream, stage_files, start_interactive_rebase, stash_apply, stash_drop, stash_pop, + stash_push, undo_last_commit, unlock_worktree, unstage_files, update_remote, }; use tauri::Manager; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; @@ -135,6 +136,14 @@ async fn main() { create_branch, rename_branch, delete_branch, + list_worktrees, + add_worktree, + remove_worktree, + move_worktree, + lock_worktree, + unlock_worktree, + prune_worktrees, + repair_worktree, list_tags, create_tag, delete_tag, diff --git a/src/App.svelte b/src/App.svelte index d87a24e..6174b02 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -37,10 +37,12 @@ import StatusPanel from "./lib/components/StatusPanel.svelte"; import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte"; + import WorktreeDialog from "./lib/components/WorktreeDialog.svelte"; import { amendCommit, addRemote, + addWorktree, checkoutBranch, cherryPickAbort, cherryPickCommit, @@ -72,6 +74,7 @@ listRemotes, listStashes, listTags, + listWorktrees, listCommits, listFileHistory, listInteractiveRebaseCommits, @@ -83,10 +86,15 @@ openRepoInExplorer, openRepositoryFile, openRepositoryBundle, + lockWorktree, + moveWorktree, + pruneWorktrees, pull, push, pushTag, removeRemote, + removeWorktree, + repairWorktree, revertCommit, setBranchUpstream, updateRemote, @@ -115,6 +123,7 @@ stashPop, stashPush, undoLastCommit, + unlockWorktree, unstageFiles, } from "./lib/git"; @@ -142,6 +151,7 @@ GitStash, GitStatus, GitTag, + GitWorktree, LocalModelOption, PatchApplyAction, PreparedResolution, @@ -304,6 +314,11 @@ let renameBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null; let deleteBranchForce = false; + let worktreeDialogOpen = false; + let worktreeInitialBranch = ""; + let worktrees: GitWorktree[] = []; + let worktreesLoading = false; + let worktreeError = ""; let compareSelectOpen = false; let compareDialogOpen = false; let interactiveRebaseOpen = false; @@ -719,7 +734,7 @@ } async function autoRefreshTick() { - if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || newBranchCommit || globalSearchOpen || helpOpen) return; + if (!autoRefreshEnabled || activeView !== "repository" || !activeRepoPath || isBusy || autoRefreshInFlight || resolveDialogOpen || compareDialogOpen || compareSelectOpen || interactiveRebaseOpen || reflogOpen || worktreeDialogOpen || newBranchCommit || globalSearchOpen || helpOpen) return; const path = activeRepoPath; autoRefreshInFlight = true; try { @@ -1760,6 +1775,10 @@ globalSearchResults = []; deleteBranchTarget = null; deleteBranchForce = false; + worktreeDialogOpen = false; + worktreeInitialBranch = ""; + worktrees = []; + worktreeError = ""; globalSearchOpen = false; globalSearchError = ""; resolveDialogOpen = false; @@ -2369,6 +2388,146 @@ deleteBranchForce = false; } + async function openWorktreeDialog(branch = "") { + if (!activeRepoPath || isBusy) return; + worktreeInitialBranch = branch; + worktreeDialogOpen = true; + worktreeError = ""; + worktreesLoading = true; + try { + worktrees = await listWorktrees(activeRepoPath); + trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) }); + } catch (error) { + worktreeError = errorToMessage(error); + } finally { + worktreesLoading = false; + } + } + + function openBranchInWorktree(branch: GitBranchInfo) { + if (branch.remote) return; + void openWorktreeDialog(branch.name); + } + + async function refreshWorktrees() { + if (!activeRepoPath || worktreesLoading) return; + worktreesLoading = true; + worktreeError = ""; + try { + worktrees = await listWorktrees(activeRepoPath); + } catch (error) { + worktreeError = errorToMessage(error); + } finally { + worktreesLoading = false; + } + } + + async function runWorktreeOperation( + label: string, + task: () => Promise, + eventName: string, + ): Promise { + if (!activeRepoPath || isBusy) return false; + operation = label; + worktreeError = ""; + try { + worktrees = await task(); + await refreshBranchList(activeRepoPath); + trackEvent(eventName, { linked_worktrees: Math.max(0, worktrees.length - 1) }); + return true; + } catch (error) { + worktreeError = errorToMessage(error); + return false; + } finally { + operation = ""; + } + } + + async function createWorktree(request: { + worktreePath: string; + branch?: string; + newBranch?: string; + startPoint?: string; + detached?: boolean; + lock?: boolean; + }): Promise { + return runWorktreeOperation( + "Creating worktree", + () => addWorktree(activeRepoPath, request.worktreePath, request), + "worktree_created", + ); + } + + async function openWorktreeTab(worktree: GitWorktree) { + if (worktree.missing || worktree.bare || isBusy) return; + worktreeDialogOpen = false; + worktreeInitialBranch = ""; + await openRepo(worktree.path); + } + + async function removeSelectedWorktree(worktree: GitWorktree, force: boolean): Promise { + if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) { + worktreeError = "Close this worktree's repository tab before removing it."; + return false; + } + return runWorktreeOperation( + `Removing ${worktree.branch || "worktree"}`, + () => removeWorktree(activeRepoPath, worktree.path, force), + "worktree_removed", + ); + } + + async function moveSelectedWorktree(worktree: GitWorktree, destination: string) { + if (repoTabs.some((tab) => sameRepoPath(tab.path, worktree.path))) { + worktreeError = "Close this worktree's repository tab before moving it."; + return; + } + await runWorktreeOperation( + `Moving ${worktree.branch || "worktree"}`, + () => moveWorktree(activeRepoPath, worktree.path, destination), + "worktree_moved", + ); + } + + function lockSelectedWorktree(worktree: GitWorktree, reason: string): Promise { + return runWorktreeOperation( + `Locking ${worktree.branch || "worktree"}`, + () => lockWorktree(activeRepoPath, worktree.path, reason), + "worktree_locked", + ); + } + + async function unlockSelectedWorktree(worktree: GitWorktree) { + await runWorktreeOperation( + `Unlocking ${worktree.branch || "worktree"}`, + () => unlockWorktree(activeRepoPath, worktree.path), + "worktree_unlocked", + ); + } + + async function pruneStaleWorktrees() { + await runWorktreeOperation( + "Pruning stale worktrees", + () => pruneWorktrees(activeRepoPath), + "worktrees_pruned", + ); + } + + async function repairSelectedWorktree(worktree: GitWorktree, location: string) { + await runWorktreeOperation( + `Repairing ${worktree.branch || "worktree"}`, + () => repairWorktree(activeRepoPath, location), + "worktree_repaired", + ); + } + + function closeWorktreeDialog() { + if (isBusy) return; + worktreeDialogOpen = false; + worktreeInitialBranch = ""; + worktreeError = ""; + } + function openNewBranchDialog(commit: GitCommit) { if (!activeRepoPath || isBusy) return; newBranchCommit = commit; @@ -3730,6 +3889,7 @@ else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); + else if (event.key === "Escape" && worktreeDialogOpen) closeWorktreeDialog(); else if (event.key === "Escape" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false; else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; @@ -4115,6 +4275,8 @@ onCreateTag={createNewTag} onDeleteTag={deleteLocalTag} onPushTag={pushLocalTag} + onManageWorktrees={() => { void openWorktreeDialog(); }} + onCreateWorktree={openBranchInWorktree} collapsed={branchPanelCollapsed} onToggleCollapsed={toggleBranchPanelCollapsed} /> @@ -4480,6 +4642,27 @@ /> {/if} +{#if worktreeDialogOpen} + +{/if} + {#if newBranchCommit} div { + display: flex; + align-items: baseline; + gap: 7px; + min-width: 0; + padding: 11px 14px; + background: var(--app-dialog-bg); + } + .worktree-summary > div strong { color: var(--color-ink); font-family: var(--font-mono); font-size: 14px; } + .worktree-summary > div span { overflow: hidden; color: var(--color-ink-faint); font-size: 10.5px; font-weight: 700; text-overflow: ellipsis; white-space: nowrap; } + .worktree-summary > div.attention strong { color: #ffc07a; } + .worktree-summary > .btn-primary { align-self: center; margin: 0 14px; white-space: nowrap; } + .worktree-error { + display: flex; + align-items: center; + gap: 8px; + padding: 9px 16px; + border-bottom: 1px solid rgba(255, 90, 103, 0.2); + color: #ffb8bf; + background: rgba(255, 90, 103, 0.08); + font-size: 11.5px; + font-weight: 650; + } + .worktree-content { + min-height: 0; + overflow: auto; + padding: 14px; + background: + radial-gradient(circle at 92% 0%, rgba(77, 182, 214, 0.055), transparent 28%), + var(--app-dialog-bg); + } + .worktree-create-card { + display: grid; + gap: 14px; + margin-bottom: 14px; + padding: 14px; + border: 1px solid rgba(77, 182, 214, 0.26); + border-radius: 10px; + background: var(--color-surface-raised); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.035); + } + .worktree-create-card > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; } + .worktree-create-card h3 { margin: 3px 0 0; color: var(--color-ink); font-size: 13px; font-weight: 700; } + .worktree-mode-tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 3px; + padding: 3px; + border: 1px solid var(--color-border-subtle); + border-radius: 8px; + background: rgba(0, 0, 0, 0.14); + } + .worktree-mode-tabs button { + display: flex; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 32px; + border-color: transparent; + color: var(--color-ink-faint); + background: transparent; + font-size: 10.5px; + font-weight: 750; + } + .worktree-mode-tabs button.active { + border-color: rgba(77, 182, 214, 0.27); + color: #b8e7f6; + background: rgba(77, 182, 214, 0.11); + box-shadow: 0 3px 12px rgba(0,0,0,0.14); + } + .worktree-create-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; } + .worktree-create-fields label, + .worktree-lock-body label { display: grid; gap: 5px; color: var(--color-ink-dim); font-size: 10.5px; font-weight: 750; } + .worktree-create-fields input, + .worktree-create-fields select, + .worktree-lock-body input { width: 100%; min-width: 0; } + .worktree-create-fields .worktree-path-field { grid-column: 1 / -1; } + .worktree-path-field > div { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 7px; } + .worktree-create-card > footer { display: flex; align-items: center; justify-content: space-between; gap: 14px; } + .worktree-check { display: flex; align-items: flex-start; gap: 8px; color: var(--color-ink-muted); font-size: 11px; cursor: pointer; } + .worktree-check input { width: auto; margin-top: 2px; } + .worktree-check span { display: grid; gap: 1px; } + .worktree-check strong { color: var(--color-ink); font-size: 11px; } + .worktree-check small { color: var(--color-ink-faint); font-size: 10px; font-weight: 500; } + .worktree-check.danger strong, + .worktree-check.danger small { color: #ffb8bf; } + .worktree-list { display: grid; gap: 8px; } + .worktree-card { + position: relative; + display: grid; + grid-template-columns: 28px minmax(0, 1fr); + min-width: 0; + border: 1px solid var(--color-border-subtle); + border-radius: 9px; + background: var(--color-surface-raised); + overflow: hidden; + } + .worktree-card.current { border-color: rgba(77, 182, 214, 0.34); box-shadow: inset 0 0 0 1px rgba(77, 182, 214, 0.06); } + .worktree-card.stale { border-color: rgba(255, 151, 61, 0.28); } + .worktree-rail { + position: relative; + display: grid; + place-items: start center; + padding-top: 18px; + background: rgba(0,0,0,0.1); + } + .worktree-rail::before { + content: ""; + position: absolute; + top: 0; + bottom: 0; + left: 50%; + width: 1px; + background: rgba(77, 182, 214, 0.22); + } + .worktree-rail span { + position: relative; + z-index: 1; + width: 9px; + height: 9px; + border: 2px solid #76cde7; + border-radius: 50%; + background: var(--color-surface-solid); + box-shadow: 0 0 0 3px rgba(77, 182, 214, 0.08); + } + .worktree-rail i { + position: absolute; + top: 31px; + left: 50%; + width: 7px; + height: 14px; + border-bottom: 1px solid rgba(77, 182, 214, 0.26); + border-left: 1px solid rgba(77, 182, 214, 0.26); + } + .worktree-card-main { display: grid; gap: 9px; min-width: 0; padding: 12px; } + .worktree-card-main > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; } + .worktree-name { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--color-accent); } + .worktree-name > div { display: grid; gap: 1px; min-width: 0; } + .worktree-name strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } + .worktree-name span { color: var(--color-ink-faint); font-size: 10px; } + .worktree-badges { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 4px; } + .worktree-badges span { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 2px 6px; + border: 1px solid var(--color-border-subtle); + border-radius: 999px; + color: var(--color-ink-faint); + background: rgba(255,255,255,0.025); + font-size: 8.5px; + font-weight: 800; + letter-spacing: 0.04em; + text-transform: uppercase; + } + .worktree-badges span.active { border-color: rgba(78, 202, 118, 0.25); color: #77d99a; background: rgba(78, 202, 118, 0.08); } + .worktree-badges span.locked { border-color: rgba(111, 140, 255, 0.26); color: #aebcff; background: rgba(111, 140, 255, 0.08); } + .worktree-badges span.danger { border-color: rgba(255, 151, 61, 0.3); color: #ffc07a; background: rgba(255, 151, 61, 0.08); } + .worktree-path { display: flex; align-items: center; gap: 5px; min-width: 0; color: var(--color-ink-faint); } + .worktree-path code { overflow: hidden; font-family: var(--font-mono); font-size: 10.5px; text-overflow: ellipsis; white-space: nowrap; } + .worktree-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 6px 12px; color: var(--color-ink-faint); font-size: 9.5px; } + .worktree-meta span { display: inline-flex; align-items: center; gap: 4px; } + .worktree-meta span.dirty, + .worktree-meta span.danger { color: #ffc07a; } + .worktree-card-main > footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding-top: 2px; } + .worktree-card-actions { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 5px; } + .worktree-card-actions .danger { border-color: rgba(255, 90, 103, 0.2); color: #ff9aa4; background: rgba(255, 90, 103, 0.06); } + .worktree-dialog-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 14px; + border-top: 1px solid var(--color-border-subtle); + background: var(--app-dialog-chrome); + } + .worktree-dialog-footer > div { display: flex; align-items: center; gap: 6px; color: var(--color-ink-faint); font-size: 10px; } + .worktree-loading, + .worktree-empty { + display: grid; + place-items: center; + align-content: center; + gap: 7px; + min-height: 240px; + color: var(--color-ink-faint); + text-align: center; + font-size: 11px; + } + .worktree-empty strong { color: var(--color-ink); font-size: 13px; } + .worktree-confirm-body { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 13px; + padding: 17px 16px; + } + .worktree-confirm-body > div { display: grid; gap: 10px; min-width: 0; } + .worktree-confirm-body p { margin: 0; color: var(--color-ink-muted); font-size: 12px; line-height: 1.45; } + .worktree-confirm-body code { overflow: auto; padding: 8px; border: 1px solid var(--color-border-subtle); border-radius: 6px; color: var(--color-ink); background: rgba(0,0,0,0.16); font-size: 10.5px; } + .worktree-lock-body { padding: 18px 16px; } + .worktree-lock-body label > span { display: flex; align-items: baseline; justify-content: space-between; } + .worktree-lock-body small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; } + @media (max-width: 700px) { + .worktree-summary { grid-template-columns: repeat(3, 1fr); } + .worktree-summary > div { display: grid; gap: 2px; } + .worktree-summary > .btn-primary { grid-column: 1 / -1; margin: 9px 14px; } + .worktree-create-fields { grid-template-columns: 1fr; } + .worktree-create-fields .worktree-path-field { grid-column: auto; } + .worktree-create-card > footer, + .worktree-card-main > footer { align-items: stretch; flex-direction: column; } + .worktree-card-actions { justify-content: flex-start; } + .worktree-dialog-footer > div { display: none; } + } .discard-target-list { display: grid; gap: 4px; diff --git a/src/lib/components/BranchPanel.svelte b/src/lib/components/BranchPanel.svelte index 5731ab4..63655fa 100644 --- a/src/lib/components/BranchPanel.svelte +++ b/src/lib/components/BranchPanel.svelte @@ -1,5 +1,5 @@ + + + +{#if pendingRemoval} + +{/if} + +{#if pendingLock} + +{/if} diff --git a/src/lib/git.ts b/src/lib/git.ts index 7190702..530ce30 100644 --- a/src/lib/git.ts +++ b/src/lib/git.ts @@ -21,6 +21,7 @@ import type { GitStash, GitStatus, GitTag, + GitWorktree, LocalModelOption, PatchApplyAction, RepositoryBundle, @@ -116,6 +117,56 @@ export function deleteBranch(path: string, branch: string, force = false): Promi return invoke("delete_branch", { path, branch, force }); } +export function listWorktrees(path: string): Promise { + return invoke("list_worktrees", { path }); +} + +export function addWorktree( + path: string, + worktreePath: string, + options: { + branch?: string; + newBranch?: string; + startPoint?: string; + detached?: boolean; + lock?: boolean; + } = {}, +): Promise { + return invoke("add_worktree", { + path, + worktreePath, + branch: options.branch ?? null, + newBranch: options.newBranch ?? null, + startPoint: options.startPoint ?? null, + detached: options.detached ?? false, + lock: options.lock ?? false, + }); +} + +export function removeWorktree(path: string, worktreePath: string, force = false): Promise { + return invoke("remove_worktree", { path, worktreePath, force }); +} + +export function moveWorktree(path: string, worktreePath: string, destination: string): Promise { + return invoke("move_worktree", { path, worktreePath, destination }); +} + +export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise { + return invoke("lock_worktree", { path, worktreePath, reason: reason?.trim() || null }); +} + +export function unlockWorktree(path: string, worktreePath: string): Promise { + return invoke("unlock_worktree", { path, worktreePath }); +} + +export function pruneWorktrees(path: string): Promise { + return invoke("prune_worktrees", { path }); +} + +export function repairWorktree(path: string, worktreePath: string): Promise { + return invoke("repair_worktree", { path, worktreePath }); +} + export function listTags(path: string): Promise { return invoke("list_tags", { path }); } diff --git a/src/lib/types.ts b/src/lib/types.ts index 957ec31..efcd711 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -90,6 +90,24 @@ export interface GitBranch { remote: boolean; } +export interface GitWorktree { + path: string; + head: string | null; + short_head: string | null; + branch: string | null; + bare: boolean; + detached: boolean; + locked: boolean; + lock_reason: string | null; + prunable: boolean; + prune_reason: string | null; + missing: boolean; + is_main: boolean; + is_current: boolean; + clean: boolean; + changed_files: number; +} + export interface GitTag { name: string; hash: string; -- 2.54.0 From 24201c1302accbc16013c1c98509f5a2a44ae287 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 26 Jul 2026 22:10:33 +0200 Subject: [PATCH 2/3] feat(diff): enable line-level patch selection and actions Introduces granular control over diff operations by enabling users to select individual lines within a hunk. This refactors the core logic across multiple components, allowing for precise staging, unstaging, or discarding of specific changes rather than operating on entire hunks. The UI now features dedicated controls and visual feedback for line-level selection. - Refactored patch parsing to track selected line IDs and calculate range information. - Updated the diff view CSS and component structure to display interactive line selection bars. - Extended action dialogs to support confirming operations on selected lines. --- src-tauri/src/git.rs | 30 ++ src/App.svelte | 28 +- src/app.css | 78 ++++- .../components/DiscardConfirmDialog.svelte | 6 +- src/lib/components/LinePatchDialog.svelte | 278 ++++++++++++++---- 5 files changed, 350 insertions(+), 70 deletions(-) diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index 98722f0..7ad41ce 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -6713,6 +6713,36 @@ mod tests { "one\nTWO\nthree\nfour\n" ); + let staged_patch = get_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + true, + ) + .expect("staged patch should load"); + let status = apply_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + staged_patch, + "unstage".to_string(), + ) + .expect("selected staged lines should unstage"); + + assert_eq!(status.files[0].staged, None); + assert_eq!(status.files[0].unstaged, Some(FileStatusKind::Modified)); + assert_eq!( + git_output_test(&repo.path, ["show", ":old.txt"]), + "one\ntwo\nthree" + ); + + let selected_patch = "diff --git a/old.txt b/old.txt\n--- a/old.txt\n+++ b/old.txt\n@@ -1,3 +1,3 @@\n one\n-two\n+TWO\n three\n"; + apply_file_patch( + repo.path.to_string_lossy().to_string(), + "old.txt".to_string(), + selected_patch.to_string(), + "stage".to_string(), + ) + .expect("selected line should stage again"); + let unstaged_patch = get_file_patch( repo.path.to_string_lossy().to_string(), "old.txt".to_string(), diff --git a/src/App.svelte b/src/App.svelte index 6174b02..6526f1c 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -177,7 +177,7 @@ type PendingDiscard = | { kind: "file"; files: GitFileStatus[]; staged: boolean } | { kind: "all-changes"; files: GitFileStatus[] } - | { kind: "hunk"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string }; + | { kind: "patch"; file: GitFileStatus; staged: boolean; action: PatchApplyAction; patch: string; scope: "hunk" | "lines" }; interface RepoTab { path: string; @@ -3303,14 +3303,15 @@ blameError = ""; } - function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus): string { + function patchOperationLabel(action: PatchApplyAction, file: GitFileStatus, scope: "hunk" | "lines"): string { + const target = scope === "lines" ? "selected lines" : "hunk"; switch (action) { case "stage": - return `Staging hunk in ${file.path}`; + return `Staging ${target} in ${file.path}`; case "unstage": - return `Unstaging hunk in ${file.path}`; + return `Unstaging ${target} in ${file.path}`; default: - return `Discarding hunk in ${file.path}`; + return `Discarding ${target} in ${file.path}`; } } @@ -3323,9 +3324,10 @@ patch: string, file: GitFileStatus, staged: boolean, + scope: "hunk" | "lines", ) { if (!activeRepoPath || isBusy) return; - operation = patchOperationLabel(action, file); + operation = patchOperationLabel(action, file, scope); errorMessage = ""; linePatchError = ""; @@ -3353,21 +3355,21 @@ } } - async function applyLinePatch(action: PatchApplyAction, patch: string) { + async function applyLinePatch(action: PatchApplyAction, patch: string, scope: "hunk" | "lines") { if (!activeRepoPath || !linePatchFile || isBusy) return; const file = linePatchFile; const staged = linePatchStaged; if (isDiscardPatchAction(action)) { - pendingDiscard = { kind: "hunk", file, staged, action, patch }; + pendingDiscard = { kind: "patch", file, staged, action, patch, scope }; trackEvent("discard_confirm_opened", { - kind: "hunk", + kind: scope, staged: staged ? 1 : 0, }); return; } - await runLinePatchAction(action, patch, file, staged); + await runLinePatchAction(action, patch, file, staged, scope); } async function confirmDiscard() { @@ -3379,7 +3381,7 @@ } else if (discard.kind === "all-changes") { await runDiscardAllChanges(discard.files); } else { - await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged); + await runLinePatchAction(discard.action, discard.patch, discard.file, discard.staged, discard.scope); } pendingDiscard = null; @@ -4613,9 +4615,9 @@ {#if pendingDiscard} div:first-child { + display: flex; + align-items: center; + gap: 7px; + min-width: 0; + color: var(--color-accent); + } + .line-patch-selection-bar strong { color: var(--color-ink); font-size: 11.5px; } + .line-patch-selection-bar span { color: var(--color-ink-faint); font-size: 10px; } + .line-patch-selected-actions { display: flex; align-items: center; justify-content: flex-end; gap: 6px; } + .line-patch-scroll { min-height: 0; overflow: auto; @@ -4197,6 +4223,33 @@ border-bottom: 1px solid var(--color-border-subtle); background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent); } + .line-patch-select-hunk, + .line-patch-line-select { + display: grid; + place-items: center; + width: 16px; + min-width: 16px; + height: 16px; + min-height: 16px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 4px; + color: #ffffff; + background: var(--code-surface); + box-shadow: none; + } + .line-patch-select-hunk:hover:not(:disabled), + .line-patch-line-select:hover:not(:disabled) { + border-color: rgba(77, 182, 214, 0.65); + background: rgba(77, 182, 214, 0.1); + } + .line-patch-select-hunk.all, + .line-patch-select-hunk.some, + .line-patch-line-select[aria-pressed="true"] { + border-color: rgba(77, 182, 214, 0.78); + background: #238eb4; + } + .line-patch-select-hunk.some { background: rgba(35, 142, 180, 0.55); } .line-patch-hunk-head code { color: var(--color-accent); font-family: var(--font-mono); @@ -4242,12 +4295,17 @@ .line-patch-row { display: grid; - grid-template-columns: 22px minmax(max-content, 1fr); + grid-template-columns: 18px 38px 38px 22px minmax(max-content, 1fr); align-items: start; min-height: 22px; - padding: 1px 10px 1px 28px; + padding: 1px 10px 1px 8px; color: var(--color-ink-muted); } + .line-patch-row.selectable { cursor: default; } + .line-patch-row.selected { + box-shadow: inset 3px 0 0 rgba(77, 182, 214, 0.86); + filter: saturate(1.12) brightness(1.06); + } .line-patch-row.add { background: var(--code-add-bg); color: var(--code-add-text); @@ -4264,12 +4322,26 @@ text-align: center; user-select: none; } + .line-patch-line-select { align-self: center; } + .line-patch-line-select-placeholder { width: 16px; } + .line-patch-line-number { + padding-right: 7px; + color: var(--color-ink-faint); + font-size: 10px; + line-height: 20px; + text-align: right; + user-select: none; + } .line-patch-row.add .line-patch-prefix { color: var(--code-add-strong); } .line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); } .line-patch-row code { white-space: pre; font-family: var(--font-mono); } + @media (max-width: 760px) { + .line-patch-selection-bar { align-items: stretch; flex-direction: column; } + .line-patch-selected-actions { justify-content: flex-start; flex-wrap: wrap; } + } .blame-body { min-height: 0; diff --git a/src/lib/components/DiscardConfirmDialog.svelte b/src/lib/components/DiscardConfirmDialog.svelte index 8516d21..aecab36 100644 --- a/src/lib/components/DiscardConfirmDialog.svelte +++ b/src/lib/components/DiscardConfirmDialog.svelte @@ -5,7 +5,7 @@ interface Props { files: GitFileStatus[]; staged: boolean | null; - scope: "file" | "hunk"; + scope: "file" | "hunk" | "lines"; isBusy: boolean; onConfirm: () => void | Promise; onClose: () => void; @@ -26,9 +26,9 @@ let count = $derived(files.length); let title = $derived( - scope === "hunk" ? "Discard hunk?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?" + scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?" ); - let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : count > 1 ? `${count} files` : "file"); + let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file"); let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes"); diff --git a/src/lib/components/LinePatchDialog.svelte b/src/lib/components/LinePatchDialog.svelte index 7f81838..c533f8c 100644 --- a/src/lib/components/LinePatchDialog.svelte +++ b/src/lib/components/LinePatchDialog.svelte @@ -1,5 +1,5 @@