Compare commits

...
4 Commits
Author SHA1 Message Date
Christoph 1721341e62 Merge pull request 'Worktrees' (#25) from worktrees into main
Reviewed-on: #25
2026-07-26 20:20:24 +00:00
Christoph Brandau eb99165931 feat(ui): add worktree management toggle to branch panel
Introduces a dedicated section and interactive button for managing repository worktrees within the branch list view. This improves visibility into the overall state of local repositories by grouping related controls.

- Adds visual styling and structure for the new worktree group toggle in CSS.
- Replaces the old "Manage worktrees" button with a more descriptive, styled component in BranchPanel.svelte.
2026-07-26 22:16:35 +02:00
Christoph Brandau 24201c1302 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.
2026-07-26 22:10:33 +02:00
Christoph Brandau 38b7f1a536 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
2026-07-26 21:47:35 +02:00
11 changed files with 1775 additions and 86 deletions
+26
View File
@@ -33,6 +33,24 @@ interface GitBranch {
remote: boolean; 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 { interface GitCommit {
hash: string; hash: string;
short_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<GitStatus>` - `set_branch_upstream(path: string, branch: string, upstream?: string): Promise<GitStatus>`
- `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>` - `delete_remote_branch(path: string, remote: string, branch: string): Promise<GitStatus>`
- `checkout_branch(path: string, branch: string): Promise<GitStatus>` - `checkout_branch(path: string, branch: string): Promise<GitStatus>`
- `list_worktrees(path: string): Promise<GitWorktree[]>`
- `add_worktree(path: string, worktreePath: string, ...): Promise<GitWorktree[]>`
- `remove_worktree(path: string, worktreePath: string, force?: boolean): Promise<GitWorktree[]>`
- `move_worktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]>`
- `lock_worktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]>`
- `unlock_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `prune_worktrees(path: string): Promise<GitWorktree[]>`
- `repair_worktree(path: string, worktreePath: string): Promise<GitWorktree[]>`
- `stage_files(path: string, files: string[]): Promise<GitStatus>` - `stage_files(path: string, files: string[]): Promise<GitStatus>`
- `unstage_files(path: string, files: string[]): Promise<GitStatus>` - `unstage_files(path: string, files: string[]): Promise<GitStatus>`
- `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>` - `restore_files(path: string, files: string[], staged: boolean): Promise<GitStatus>`
+404
View File
@@ -68,6 +68,25 @@ pub struct GitBranch {
pub remote: bool, pub remote: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitWorktree {
pub path: String,
pub head: Option<String>,
pub short_head: Option<String>,
pub branch: Option<String>,
pub bare: bool,
pub detached: bool,
pub locked: bool,
pub lock_reason: Option<String>,
pub prunable: bool,
pub prune_reason: Option<String>,
pub missing: bool,
pub is_main: bool,
pub is_current: bool,
pub clean: bool,
pub changed_files: u32,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct GitTag { pub struct GitTag {
pub name: String, pub name: String,
@@ -927,6 +946,291 @@ pub fn delete_branch(
status_for_repo(&repo) 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<GitWorktree> {
#[derive(Default)]
struct Record {
path: Option<String>,
head: Option<String>,
branch: Option<String>,
bare: bool,
detached: bool,
locked: bool,
lock_reason: Option<String>,
prunable: bool,
prune_reason: Option<String>,
}
fn finish_record(rows: &mut Vec<GitWorktree>, 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::<String>());
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<Vec<GitWorktree>, 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<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
worktrees_for_repo(&repo)
}
#[tauri::command]
pub fn add_worktree(
path: String,
worktree_path: String,
branch: Option<String>,
new_branch: Option<String>,
start_point: Option<String>,
detached: Option<bool>,
lock: Option<bool>,
) -> Result<Vec<GitWorktree>, 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<bool>,
) -> Result<Vec<GitWorktree>, 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<Vec<GitWorktree>, 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<String>,
) -> Result<Vec<GitWorktree>, 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<Vec<GitWorktree>, 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<Vec<GitWorktree>, 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<Vec<GitWorktree>, String> {
let repo = resolve_repo(&path)?;
run_git(&repo, ["worktree", "repair", "--", worktree_path.as_str()])?;
worktrees_for_repo(&repo)
}
#[tauri::command] #[tauri::command]
pub fn create_tag( pub fn create_tag(
path: String, path: String,
@@ -6409,6 +6713,36 @@ mod tests {
"one\nTWO\nthree\nfour\n" "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( let unstaged_patch = get_file_patch(
repo.path.to_string_lossy().to_string(), repo.path.to_string_lossy().to_string(),
"old.txt".to_string(), "old.txt".to_string(),
@@ -6687,6 +7021,76 @@ mod tests {
assert!(result.lines[0].is_uncommitted); 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] #[test]
fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() { fn parse_ai_review_accepts_fenced_json_and_normalizes_findings() {
let raw = r#"```json let raw = r#"```json
+21 -12
View File
@@ -6,23 +6,24 @@ mod telemetry;
use badge::set_sync_badge; use badge::set_sync_badge;
use git::{ use git::{
SearchCancellationState, add_remote, amend_commit, apply_file_patch, cancel_code_search, SearchCancellationState, add_remote, add_worktree, amend_commit, apply_file_patch,
cancel_file_history, checkout_branch, cherry_pick_abort, cherry_pick_commit, cancel_code_search, cancel_file_history, checkout_branch, cherry_pick_abort,
cherry_pick_continue, clone_repository, commit, commit_ai_generate, commit_ai_load, cherry_pick_commit, cherry_pick_continue, clone_repository, commit, commit_ai_generate,
commit_ai_local_models, commit_ai_review, commit_ai_status, compare_commits, 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, 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, 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, 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, get_status, init_repository, last_commit_message, list_branches, list_commits,
list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes, list_file_history, list_interactive_rebase_commits, list_reflog, list_remotes,
list_repository_files, list_stashes, list_tags, merge_abort, merge_branch, merge_continue, list_repository_files, list_stashes, list_tags, list_worktrees, lock_worktree, merge_abort,
open_repo_in_explorer, open_repository, open_repository_bundle, open_repository_file, pull, merge_branch, merge_continue, move_worktree, open_repo_in_explorer, open_repository,
push, push_tag, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, open_repository_bundle, open_repository_file, prune_worktrees, pull, push, push_tag,
rename_branch, resolve_conflict, resolve_conflict_side, restore_file_from_commit, read_conflict, rebase_abort, rebase_branch, rebase_continue, remove_remote, remove_worktree,
restore_files, restore_reflog_entry, restore_to_commit, revert_commit, rename_branch, repair_worktree, resolve_conflict, resolve_conflict_side,
run_sequence_editor_if_requested, search_code_introductions, set_branch_upstream, stage_files, restore_file_from_commit, restore_files, restore_reflog_entry, restore_to_commit,
start_interactive_rebase, stash_apply, stash_drop, stash_pop, stash_push, undo_last_commit, revert_commit, run_sequence_editor_if_requested, search_code_introductions,
unstage_files, update_remote, 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 tauri::Manager;
use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled}; use telemetry::{emit_frontend_log, emit_frontend_span, set_telemetry_enabled};
@@ -135,6 +136,14 @@ async fn main() {
create_branch, create_branch,
rename_branch, rename_branch,
delete_branch, delete_branch,
list_worktrees,
add_worktree,
remove_worktree,
move_worktree,
lock_worktree,
unlock_worktree,
prune_worktrees,
repair_worktree,
list_tags, list_tags,
create_tag, create_tag,
delete_tag, delete_tag,
+199 -14
View File
@@ -37,10 +37,12 @@
import StatusPanel from "./lib/components/StatusPanel.svelte"; import StatusPanel from "./lib/components/StatusPanel.svelte";
import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte"; import SyncSettingsDialog from "./lib/components/SyncSettingsDialog.svelte";
import UpdateToast from "./lib/components/UpdateToast.svelte"; import UpdateToast from "./lib/components/UpdateToast.svelte";
import WorktreeDialog from "./lib/components/WorktreeDialog.svelte";
import { import {
amendCommit, amendCommit,
addRemote, addRemote,
addWorktree,
checkoutBranch, checkoutBranch,
cherryPickAbort, cherryPickAbort,
cherryPickCommit, cherryPickCommit,
@@ -72,6 +74,7 @@
listRemotes, listRemotes,
listStashes, listStashes,
listTags, listTags,
listWorktrees,
listCommits, listCommits,
listFileHistory, listFileHistory,
listInteractiveRebaseCommits, listInteractiveRebaseCommits,
@@ -83,10 +86,15 @@
openRepoInExplorer, openRepoInExplorer,
openRepositoryFile, openRepositoryFile,
openRepositoryBundle, openRepositoryBundle,
lockWorktree,
moveWorktree,
pruneWorktrees,
pull, pull,
push, push,
pushTag, pushTag,
removeRemote, removeRemote,
removeWorktree,
repairWorktree,
revertCommit, revertCommit,
setBranchUpstream, setBranchUpstream,
updateRemote, updateRemote,
@@ -115,6 +123,7 @@
stashPop, stashPop,
stashPush, stashPush,
undoLastCommit, undoLastCommit,
unlockWorktree,
unstageFiles, unstageFiles,
} from "./lib/git"; } from "./lib/git";
@@ -142,6 +151,7 @@
GitStash, GitStash,
GitStatus, GitStatus,
GitTag, GitTag,
GitWorktree,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
PreparedResolution, PreparedResolution,
@@ -167,7 +177,7 @@
type PendingDiscard = type PendingDiscard =
| { kind: "file"; files: GitFileStatus[]; staged: boolean } | { kind: "file"; files: GitFileStatus[]; staged: boolean }
| { kind: "all-changes"; files: GitFileStatus[] } | { 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 { interface RepoTab {
path: string; path: string;
@@ -304,6 +314,11 @@
let renameBranchTarget: GitBranchInfo | null = null; let renameBranchTarget: GitBranchInfo | null = null;
let deleteBranchTarget: GitBranchInfo | null = null; let deleteBranchTarget: GitBranchInfo | null = null;
let deleteBranchForce = false; let deleteBranchForce = false;
let worktreeDialogOpen = false;
let worktreeInitialBranch = "";
let worktrees: GitWorktree[] = [];
let worktreesLoading = false;
let worktreeError = "";
let compareSelectOpen = false; let compareSelectOpen = false;
let compareDialogOpen = false; let compareDialogOpen = false;
let interactiveRebaseOpen = false; let interactiveRebaseOpen = false;
@@ -719,7 +734,7 @@
} }
async function autoRefreshTick() { 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; const path = activeRepoPath;
autoRefreshInFlight = true; autoRefreshInFlight = true;
try { try {
@@ -1760,6 +1775,10 @@
globalSearchResults = []; globalSearchResults = [];
deleteBranchTarget = null; deleteBranchTarget = null;
deleteBranchForce = false; deleteBranchForce = false;
worktreeDialogOpen = false;
worktreeInitialBranch = "";
worktrees = [];
worktreeError = "";
globalSearchOpen = false; globalSearchOpen = false;
globalSearchError = ""; globalSearchError = "";
resolveDialogOpen = false; resolveDialogOpen = false;
@@ -2369,6 +2388,146 @@
deleteBranchForce = false; 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<GitWorktree[]>,
eventName: string,
): Promise<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
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) { function openNewBranchDialog(commit: GitCommit) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
newBranchCommit = commit; newBranchCommit = commit;
@@ -3144,14 +3303,15 @@
blameError = ""; 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) { switch (action) {
case "stage": case "stage":
return `Staging hunk in ${file.path}`; return `Staging ${target} in ${file.path}`;
case "unstage": case "unstage":
return `Unstaging hunk in ${file.path}`; return `Unstaging ${target} in ${file.path}`;
default: default:
return `Discarding hunk in ${file.path}`; return `Discarding ${target} in ${file.path}`;
} }
} }
@@ -3164,9 +3324,10 @@
patch: string, patch: string,
file: GitFileStatus, file: GitFileStatus,
staged: boolean, staged: boolean,
scope: "hunk" | "lines",
) { ) {
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
operation = patchOperationLabel(action, file); operation = patchOperationLabel(action, file, scope);
errorMessage = ""; errorMessage = "";
linePatchError = ""; linePatchError = "";
@@ -3194,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; if (!activeRepoPath || !linePatchFile || isBusy) return;
const file = linePatchFile; const file = linePatchFile;
const staged = linePatchStaged; const staged = linePatchStaged;
if (isDiscardPatchAction(action)) { if (isDiscardPatchAction(action)) {
pendingDiscard = { kind: "hunk", file, staged, action, patch }; pendingDiscard = { kind: "patch", file, staged, action, patch, scope };
trackEvent("discard_confirm_opened", { trackEvent("discard_confirm_opened", {
kind: "hunk", kind: scope,
staged: staged ? 1 : 0, staged: staged ? 1 : 0,
}); });
return; return;
} }
await runLinePatchAction(action, patch, file, staged); await runLinePatchAction(action, patch, file, staged, scope);
} }
async function confirmDiscard() { async function confirmDiscard() {
@@ -3220,7 +3381,7 @@
} else if (discard.kind === "all-changes") { } else if (discard.kind === "all-changes") {
await runDiscardAllChanges(discard.files); await runDiscardAllChanges(discard.files);
} else { } 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; pendingDiscard = null;
@@ -3730,6 +3891,7 @@
else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null; else if (event.key === "Escape" && newBranchCommit) newBranchCommit = null;
else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null; else if (event.key === "Escape" && renameBranchTarget) renameBranchTarget = null;
else if (event.key === "Escape" && deleteBranchTarget) closeDeleteBranchDialog(); 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" && interactiveRebaseOpen && !isBusy) interactiveRebaseOpen = false;
else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false; else if (event.key === "Escape" && reflogOpen && !isBusy) reflogOpen = false;
else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false; else if (event.key === "Escape" && compareSelectOpen) compareSelectOpen = false;
@@ -4115,6 +4277,8 @@
onCreateTag={createNewTag} onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag} onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag} onPushTag={pushLocalTag}
onManageWorktrees={() => { void openWorktreeDialog(); }}
onCreateWorktree={openBranchInWorktree}
collapsed={branchPanelCollapsed} collapsed={branchPanelCollapsed}
onToggleCollapsed={toggleBranchPanelCollapsed} onToggleCollapsed={toggleBranchPanelCollapsed}
/> />
@@ -4451,9 +4615,9 @@
{#if pendingDiscard} {#if pendingDiscard}
<DiscardConfirmDialog <DiscardConfirmDialog
files={pendingDiscard.kind === "hunk" ? [pendingDiscard.file] : pendingDiscard.files} files={pendingDiscard.kind === "patch" ? [pendingDiscard.file] : pendingDiscard.files}
staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged} staged={pendingDiscard.kind === "all-changes" ? null : pendingDiscard.staged}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"} scope={pendingDiscard.kind === "patch" ? pendingDiscard.scope : "file"}
{isBusy} {isBusy}
onConfirm={confirmDiscard} onConfirm={confirmDiscard}
onClose={closeDiscardConfirm} onClose={closeDiscardConfirm}
@@ -4480,6 +4644,27 @@
/> />
{/if} {/if}
{#if worktreeDialogOpen}
<WorktreeDialog
{worktrees}
{branches}
initialBranch={worktreeInitialBranch}
isLoading={worktreesLoading}
{isBusy}
error={worktreeError}
onRefresh={refreshWorktrees}
onOpen={openWorktreeTab}
onAdd={createWorktree}
onRemove={removeSelectedWorktree}
onMove={moveSelectedWorktree}
onLock={lockSelectedWorktree}
onUnlock={unlockSelectedWorktree}
onPrune={pruneStaleWorktrees}
onRepair={repairSelectedWorktree}
onClose={closeWorktreeDialog}
/>
{/if}
<!-- Create a branch from a specific commit in the history --> <!-- Create a branch from a specific commit in the history -->
{#if newBranchCommit} {#if newBranchCommit}
<NewBranchDialog <NewBranchDialog
+340 -3
View File
@@ -2064,6 +2064,20 @@
.tag-group-head { display: flex; align-items: center; gap: 6px; } .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-group-toggle { flex: 1; min-width: 0; }
.tag-group-head .branch-create-toggle { flex-shrink: 0; } .tag-group-head .branch-create-toggle { flex-shrink: 0; }
.worktree-group-toggle {
border-color: rgba(77, 182, 214, 0.12);
background: linear-gradient(90deg, rgba(77, 182, 214, 0.055), transparent 72%);
}
.worktree-group-toggle:hover:not(:disabled) {
border-color: rgba(77, 182, 214, 0.25);
background: linear-gradient(90deg, rgba(77, 182, 214, 0.1), rgba(77, 182, 214, 0.025));
color: var(--color-ink);
}
.worktree-group-toggle svg { color: #76cde7; }
.worktree-group-toggle .branch-group-count {
color: #9edced;
background: rgba(77, 182, 214, 0.11);
}
.tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; } .tag-create-form { grid-template-columns: auto minmax(0, 1fr) minmax(0, 1fr) auto auto; }
@@ -3261,6 +3275,19 @@
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04); box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
overflow: auto; overflow: auto;
} }
.worktree-dialog {
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
width: min(920px, calc(100vw - 32px));
height: min(820px, calc(100vh - 32px));
border-color: rgba(77, 182, 214, 0.24);
}
.worktree-confirm-dialog {
grid-template-rows: auto auto auto;
width: min(520px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
}
.worktree-nested-backdrop { z-index: 70; }
.ai-settings-dialog { .ai-settings-dialog {
display: block; display: block;
width: min(560px, calc(100vw - 32px)); width: min(560px, calc(100vw - 32px));
@@ -3884,6 +3911,244 @@
font-weight: 600; font-weight: 600;
} }
.branch-delete-confirm { min-width: 116px; } .branch-delete-confirm { min-width: 116px; }
.worktree-dialog-header {
background:
linear-gradient(90deg, rgba(77, 182, 214, 0.09), transparent 40%),
var(--app-dialog-chrome);
}
.worktree-dialog-heading { display: flex; align-items: center; gap: 11px; }
.worktree-dialog-mark {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid rgba(77, 182, 214, 0.3);
border-radius: 9px;
color: #8ed8ee;
background: rgba(77, 182, 214, 0.1);
box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
}
.worktree-summary {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)) auto;
align-items: stretch;
gap: 1px;
padding: 1px 0;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--color-border-subtle);
}
.worktree-summary > 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 { .discard-target-list {
display: grid; display: grid;
gap: 4px; gap: 4px;
@@ -3916,11 +4181,37 @@
.line-patch-body { .line-patch-body {
display: grid; display: grid;
grid-template-rows: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr);
min-height: 0; min-height: 0;
overflow: hidden; overflow: hidden;
} }
.line-patch-selection-bar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 46px;
padding: 7px 12px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.line-patch-selection-bar.active {
background:
linear-gradient(90deg, rgba(77, 182, 214, 0.1), transparent 44%),
var(--app-dialog-chrome);
}
.line-patch-selection-bar > 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 { .line-patch-scroll {
min-height: 0; min-height: 0;
overflow: auto; overflow: auto;
@@ -3946,6 +4237,33 @@
border-bottom: 1px solid var(--color-border-subtle); border-bottom: 1px solid var(--color-border-subtle);
background: color-mix(in srgb, var(--code-surface-raised) 96%, transparent); 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 { .line-patch-hunk-head code {
color: var(--color-accent); color: var(--color-accent);
font-family: var(--font-mono); font-family: var(--font-mono);
@@ -3991,12 +4309,17 @@
.line-patch-row { .line-patch-row {
display: grid; display: grid;
grid-template-columns: 22px minmax(max-content, 1fr); grid-template-columns: 18px 38px 38px 22px minmax(max-content, 1fr);
align-items: start; align-items: start;
min-height: 22px; min-height: 22px;
padding: 1px 10px 1px 28px; padding: 1px 10px 1px 8px;
color: var(--color-ink-muted); 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 { .line-patch-row.add {
background: var(--code-add-bg); background: var(--code-add-bg);
color: var(--code-add-text); color: var(--code-add-text);
@@ -4013,12 +4336,26 @@
text-align: center; text-align: center;
user-select: none; 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.add .line-patch-prefix { color: var(--code-add-strong); }
.line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); } .line-patch-row.delete .line-patch-prefix { color: var(--code-delete-strong); }
.line-patch-row code { .line-patch-row code {
white-space: pre; white-space: pre;
font-family: var(--font-mono); 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 { .blame-body {
min-height: 0; min-height: 0;
+31 -3
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types"; import type { GitBranch as GitBranchInfo, GitTag } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -58,6 +58,8 @@
onCreateTag: (name: string, message: string) => void | Promise<void>; onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>; onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>; onPushTag: (tag: GitTag) => void | Promise<void>;
onManageWorktrees: () => void;
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapsed?: () => void; onToggleCollapsed?: () => void;
} }
@@ -79,6 +81,8 @@
onCreateTag = () => {}, onCreateTag = () => {},
onDeleteTag = () => {}, onDeleteTag = () => {},
onPushTag = () => {}, onPushTag = () => {},
onManageWorktrees = () => {},
onCreateWorktree = () => {},
collapsed = false, collapsed = false,
onToggleCollapsed = () => {}, onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
@@ -271,6 +275,13 @@
if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch); if (branch.remote) await onDeleteRemoteBranch(branch); else await onDeleteBranch(branch);
} }
async function createContextWorktree() {
const branch = contextBranch;
closeBranchContextMenu();
if (!branch) return;
await onCreateWorktree(branch);
}
async function checkoutContextBranch() { async function checkoutContextBranch() {
const branch = contextBranch; const branch = contextBranch;
if (!branch || branch.current || isBusy) return; if (!branch || branch.current || isBusy) return;
@@ -400,8 +411,6 @@
<!-- collapsed --> <!-- collapsed -->
{:else if !hasRepository} {:else 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 && tags.length === 0}
<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">
{#if createOpen} {#if createOpen}
@@ -638,6 +647,21 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<div class="branch-group">
<button
class="branch-group-toggle worktree-group-toggle"
type="button"
onclick={onManageWorktrees}
disabled={isBusy}
aria-haspopup="dialog"
title="Manage repository worktrees"
>
<HardDrive size={14} aria-hidden="true" />
<span>Worktrees</span>
<span class="branch-group-count">Manage</span>
</button>
</div>
</div> </div>
{/if} {/if}
@@ -661,6 +685,10 @@
<GitBranch size={14} aria-hidden="true" /> <GitBranch size={14} aria-hidden="true" />
Rebase current onto this Rebase current onto this
</button> </button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
Open in new worktree
</button>
<div class="menu-separator" role="separator"></div> <div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}> <button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy || contextBranch.remote}>
<Pencil size={14} aria-hidden="true" /> <Pencil size={14} aria-hidden="true" />
@@ -5,7 +5,7 @@
interface Props { interface Props {
files: GitFileStatus[]; files: GitFileStatus[];
staged: boolean | null; staged: boolean | null;
scope: "file" | "hunk"; scope: "file" | "hunk" | "lines";
isBusy: boolean; isBusy: boolean;
onConfirm: () => void | Promise<void>; onConfirm: () => void | Promise<void>;
onClose: () => void; onClose: () => void;
@@ -26,9 +26,9 @@
let count = $derived(files.length); let count = $derived(files.length);
let title = $derived( 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"); let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script> </script>
+227 -51
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { LoaderCircle, X } from "@lucide/svelte"; import { Check, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
import type { GitFileStatus, PatchApplyAction } from "../types"; import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta"; type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -8,12 +8,17 @@
id: string; id: string;
text: string; text: string;
kind: PatchLineKind; kind: PatchLineKind;
oldLine: number | null;
newLine: number | null;
} }
interface PatchHunk { interface PatchHunk {
id: string; id: string;
header: string; header: string;
lines: PatchLine[]; lines: PatchLine[];
oldStart: number;
newStart: number;
suffix: string;
} }
interface ParsedPatch { interface ParsedPatch {
@@ -31,7 +36,7 @@
error: string; error: string;
onClose: () => void; onClose: () => void;
onRefresh: () => void | Promise<void>; onRefresh: () => void | Promise<void>;
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>; onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
} }
let { let {
@@ -48,12 +53,20 @@
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false }); let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchScroll = $state<HTMLDivElement | null>(null); let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set());
let lastSelectedLineId = $state("");
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes"); let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path); let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let selectableLines = $derived(
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
);
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
$effect(() => { $effect(() => {
parsed = parsePatch(patch); parsed = parsePatch(patch);
selectedLineIds = new Set();
lastSelectedLineId = "";
}); });
function parsePatch(input: string): ParsedPatch { function parsePatch(input: string): ParsedPatch {
@@ -64,10 +77,22 @@
const headerLines: string[] = []; const headerLines: string[] = [];
const hunks: PatchHunk[] = []; const hunks: PatchHunk[] = [];
let current: PatchHunk | null = null; let current: PatchHunk | null = null;
let oldCursor = 0;
let newCursor = 0;
for (const line of lines) { for (const line of lines) {
if (line.startsWith("@@ ")) { if (line.startsWith("@@ ")) {
current = { id: `hunk-${hunks.length}`, header: line, lines: [] }; const range = parseHunkHeader(line);
current = {
id: `hunk-${hunks.length}`,
header: line,
lines: [],
oldStart: range.oldStart,
newStart: range.newStart,
suffix: range.suffix,
};
oldCursor = range.oldStart;
newCursor = range.newStart;
hunks.push(current); hunks.push(current);
continue; continue;
} }
@@ -78,11 +103,17 @@
} }
const kind = patchLineKind(line); const kind = patchLineKind(line);
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
const newLine = kind === "context" || kind === "add" ? newCursor : null;
current.lines.push({ current.lines.push({
id: `${current.id}-line-${current.lines.length}`, id: `${current.id}-line-${current.lines.length}`,
text: line, text: line,
kind, kind,
oldLine,
newLine,
}); });
if (oldLine !== null) oldCursor += 1;
if (newLine !== null) newCursor += 1;
} }
return { return {
@@ -92,6 +123,15 @@
}; };
} }
function parseHunkHeader(header: string): { oldStart: number; newStart: number; suffix: string } {
const match = header.match(/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@(.*)$/);
return {
oldStart: Number(match?.[1] ?? 0),
newStart: Number(match?.[2] ?? 0),
suffix: match?.[3] ?? "",
};
}
function patchLineKind(line: string): PatchLineKind { function patchLineKind(line: string): PatchLineKind {
if (line.startsWith("+") && !line.startsWith("+++")) return "add"; if (line.startsWith("+") && !line.startsWith("+++")) return "add";
if (line.startsWith("-") && !line.startsWith("---")) return "delete"; if (line.startsWith("-") && !line.startsWith("---")) return "delete";
@@ -117,7 +157,91 @@
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) { async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
if (isBusy || isLoading) return; if (isBusy || isLoading) return;
await onApply(action, buildHunkPatch(hunk)); await onApply(action, buildHunkPatch(hunk), "hunk");
}
function toggleLine(event: MouseEvent, line: PatchLine) {
if (isBusy || isLoading || (line.kind !== "add" && line.kind !== "delete")) return;
const next = new Set(selectedLineIds);
const selecting = !next.has(line.id);
if (event.shiftKey && lastSelectedLineId) {
const start = selectableLines.findIndex((candidate) => candidate.id === lastSelectedLineId);
const end = selectableLines.findIndex((candidate) => candidate.id === line.id);
if (start >= 0 && end >= 0) {
for (const candidate of selectableLines.slice(Math.min(start, end), Math.max(start, end) + 1)) {
if (selecting) next.add(candidate.id);
else next.delete(candidate.id);
}
}
} else if (selecting) {
next.add(line.id);
} else {
next.delete(line.id);
}
selectedLineIds = next;
lastSelectedLineId = line.id;
}
function toggleHunkSelection(hunk: PatchHunk) {
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
const next = new Set(selectedLineIds);
for (const line of changed) {
if (allSelected) next.delete(line.id);
else next.add(line.id);
}
selectedLineIds = next;
lastSelectedLineId = changed[changed.length - 1]?.id ?? "";
}
function hunkSelectionState(hunk: PatchHunk): "none" | "some" | "all" {
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
const count = changed.filter((line) => selectedLineIds.has(line.id)).length;
return count === 0 ? "none" : count === changed.length ? "all" : "some";
}
function rangePart(start: number, count: number): string {
return count === 1 ? `${start}` : `${start},${count}`;
}
function buildSelectedHunk(hunk: PatchHunk): string | null {
if (!hunk.lines.some((line) => selectedLineIds.has(line.id))) return null;
const output: string[] = [];
let previousIncluded = false;
for (const line of hunk.lines) {
if (line.kind === "context") {
output.push(line.text);
previousIncluded = true;
} else if (line.kind === "delete") {
output.push(selectedLineIds.has(line.id) ? line.text : ` ${line.text.slice(1)}`);
previousIncluded = true;
} else if (line.kind === "add") {
if (selectedLineIds.has(line.id)) {
output.push(line.text);
previousIncluded = true;
} else {
previousIncluded = false;
}
} else if (previousIncluded) {
output.push(line.text);
}
}
const oldCount = output.filter((line) => line.startsWith(" ") || line.startsWith("-")).length;
const newCount = output.filter((line) => line.startsWith(" ") || line.startsWith("+")).length;
const header = `@@ -${rangePart(hunk.oldStart, oldCount)} +${rangePart(hunk.newStart, newCount)} @@${hunk.suffix}`;
return [header, ...output].join("\n");
}
function buildSelectedPatch(): string {
const hunks = parsed.hunks.map(buildSelectedHunk).filter((hunk): hunk is string => Boolean(hunk));
return `${[...parsed.headerLines, ...hunks].join("\n")}\n`;
}
async function applySelected(action: PatchApplyAction) {
if (isBusy || isLoading || selectedCount === 0) return;
await onApply(action, buildSelectedPatch(), "lines");
} }
function hunkPosition(index: number): number { function hunkPosition(index: number): number {
@@ -166,54 +290,106 @@
{:else if parsed.binary || parsed.hunks.length === 0} {:else if parsed.binary || parsed.hunks.length === 0}
<div class="blank-state">This change cannot be split into text lines.</div> <div class="blank-state">This change cannot be split into text lines.</div>
{:else} {:else}
<div class="line-patch-workspace"> <div class:active={selectedCount > 0} class="line-patch-selection-bar">
<div class="line-patch-scroll" bind:this={patchScroll}> <div>
{#each parsed.hunks as hunk (hunk.id)} <MousePointer2 size={14} aria-hidden="true" />
<section class="line-patch-hunk" data-hunk-id={hunk.id}> {#if selectedCount > 0}
<div class="line-patch-hunk-head"> <strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
<code>{hunk.header}</code> <span>Shift-click to select a range.</span>
<div class="line-patch-hunk-actions"> {:else}
{#if staged} <strong>Select changed lines</strong>
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}> <span>Choose individual additions or deletions below.</span>
Discard Hunk {/if}
</button> </div>
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}> {#if selectedCount > 0}
Unstage Hunk <div class="line-patch-selected-actions">
</button> <button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
{:else} Discard selected
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}> </button>
Discard Hunk <button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
</button> {staged ? "Unstage selected" : "Stage selected"}
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}> </button>
Stage Hunk <button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
</button> </div>
{/if} {/if}
</div>
</div>
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class={`line-patch-row ${line.kind}`}>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
</div>
{/each}
</div>
</section>
{/each}
</div> </div>
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
{#each parsed.hunks as hunk, index (hunk.id)} <div class="line-patch-workspace">
<button <div class="line-patch-scroll" bind:this={patchScroll}>
class="diff-overview-marker {hunkKind(hunk)}" {#each parsed.hunks as hunk (hunk.id)}
type="button" <section class="line-patch-hunk" data-hunk-id={hunk.id}>
style={`--marker-position: ${hunkPosition(index)}%`} <div class="line-patch-hunk-head">
onclick={() => scrollToHunk(hunk.id)} <button
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`} class:all={hunkSelectionState(hunk) === "all"}
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`} class:some={hunkSelectionState(hunk) === "some"}
></button> class="line-patch-select-hunk"
{/each} type="button"
</nav> onclick={() => toggleHunkSelection(hunk)}
aria-label={`Select changed lines in ${hunk.header}`}
aria-pressed={hunkSelectionState(hunk) === "all"}
title="Select all changed lines in this hunk"
>
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
</button>
<code>{hunk.header}</code>
<div class="line-patch-hunk-actions">
{#if staged}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
Unstage Hunk
</button>
{:else}
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
Discard Hunk
</button>
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
Stage Hunk
</button>
{/if}
</div>
</div>
<div class="line-patch-lines">
{#each hunk.lines as line (line.id)}
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
{#if line.kind === "add" || line.kind === "delete"}
<button
class="line-patch-line-select"
type="button"
onclick={(event) => toggleLine(event, line)}
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
aria-pressed={selectedLineIds.has(line.id)}
title="Select line (Shift-click for range)"
>
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
</button>
{:else}
<span class="line-patch-line-select-placeholder"></span>
{/if}
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
<span class="line-patch-prefix">{linePrefix(line)}</span>
<code>{lineBody(line)}</code>
</div>
{/each}
</div>
</section>
{/each}
</div>
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
{#each parsed.hunks as hunk, index (hunk.id)}
<button
class="diff-overview-marker {hunkKind(hunk)}"
type="button"
style={`--marker-position: ${hunkPosition(index)}%`}
onclick={() => scrollToHunk(hunk.id)}
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
></button>
{/each}
</nav>
</div> </div>
{/if} {/if}
</div> </div>
+455
View File
@@ -0,0 +1,455 @@
<script lang="ts">
import { open as openDialog } from "@tauri-apps/plugin-dialog";
import {
AlertTriangle,
Check,
CircleDot,
ExternalLink,
FolderInput,
FolderOpen,
GitBranch,
HardDrive,
LoaderCircle,
Lock,
MapPin,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
Unlock,
Wrench,
X,
} from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
type CreateMode = "existing" | "new" | "detached";
interface AddRequest {
worktreePath: string;
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
}
interface Props {
worktrees: GitWorktree[];
branches: GitBranchInfo[];
initialBranch?: string;
isLoading: boolean;
isBusy: boolean;
error?: string;
onRefresh: () => void | Promise<void>;
onOpen: (worktree: GitWorktree) => void | Promise<void>;
onAdd: (request: AddRequest) => boolean | Promise<boolean>;
onRemove: (worktree: GitWorktree, force: boolean) => boolean | Promise<boolean>;
onMove: (worktree: GitWorktree, destination: string) => void | Promise<void>;
onLock: (worktree: GitWorktree, reason: string) => boolean | Promise<boolean>;
onUnlock: (worktree: GitWorktree) => void | Promise<void>;
onPrune: () => void | Promise<void>;
onRepair: (worktree: GitWorktree, location: string) => void | Promise<void>;
onClose: () => void;
}
let {
worktrees = [],
branches = [],
initialBranch = "",
isLoading = false,
isBusy = false,
error = "",
onRefresh = () => {},
onOpen = () => {},
onAdd = () => false,
onRemove = () => false,
onMove = () => {},
onLock = () => false,
onUnlock = () => {},
onPrune = () => {},
onRepair = () => {},
onClose = () => {},
}: Props = $props();
let createOpen = $state(false);
let createMode = $state<CreateMode>("new");
let selectedBranch = $state("");
let newBranch = $state("");
let startPoint = $state("HEAD");
let destination = $state("");
let lockAfterCreate = $state(false);
let pendingRemoval = $state<GitWorktree | null>(null);
let forceRemoval = $state(false);
let pendingLock = $state<GitWorktree | null>(null);
let lockReason = $state("");
let initialized = false;
$effect(() => {
if (initialized) return;
createOpen = initialBranch.length > 0;
createMode = initialBranch ? "existing" : "new";
selectedBranch = initialBranch;
initialized = true;
});
let localBranches = $derived(branches.filter((branch) => !branch.remote));
let prunableCount = $derived(worktrees.filter((worktree) => worktree.prunable).length);
let linkedCount = $derived(Math.max(0, worktrees.length - 1));
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
function displayName(worktree: GitWorktree): string {
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
}
function pathName(path: string): string {
return path.split(/[\\/]/).filter(Boolean).pop() || path;
}
function branchAvailable(branch: string): boolean {
return !checkedOutBranches.has(branch);
}
function joinPath(parent: string, name: string): string {
const separator = parent.includes("\\") ? "\\" : "/";
return `${parent.replace(/[\\/]+$/, "")}${separator}${name}`;
}
async function chooseDestination(current = "") {
const selected = await openDialog({
title: current ? "Choose new worktree location" : "Choose worktree folder",
directory: true,
multiple: false,
defaultPath: current || undefined,
});
if (typeof selected === "string") destination = selected;
}
async function submitCreate(event: SubmitEvent) {
event.preventDefault();
if (!destination.trim()) return;
const request: AddRequest = {
worktreePath: destination.trim(),
lock: lockAfterCreate,
};
if (createMode === "existing") request.branch = selectedBranch;
if (createMode === "new") {
request.newBranch = newBranch.trim();
request.startPoint = startPoint.trim() || "HEAD";
}
if (createMode === "detached") {
request.detached = true;
request.startPoint = startPoint.trim() || "HEAD";
}
if (await onAdd(request)) {
createOpen = false;
destination = "";
newBranch = "";
}
}
async function chooseMoveDestination(worktree: GitWorktree) {
const selected = await openDialog({
title: `Choose parent folder for ${displayName(worktree)}`,
directory: true,
multiple: false,
defaultPath: worktree.path,
});
if (typeof selected === "string") {
const target = joinPath(selected, pathName(worktree.path));
if (target !== worktree.path) await onMove(worktree, target);
}
}
async function chooseRepairLocation(worktree: GitWorktree) {
const selected = await openDialog({
title: `Locate ${displayName(worktree)}`,
directory: true,
multiple: false,
});
if (typeof selected === "string") await onRepair(worktree, selected);
}
function requestRemoval(worktree: GitWorktree) {
pendingRemoval = worktree;
forceRemoval = false;
}
async function confirmRemoval() {
if (!pendingRemoval) return;
if (await onRemove(pendingRemoval, forceRemoval)) {
pendingRemoval = null;
forceRemoval = false;
}
}
function requestLock(worktree: GitWorktree) {
pendingLock = worktree;
lockReason = "";
}
async function confirmLock() {
if (!pendingLock) return;
if (await onLock(pendingLock, lockReason)) {
pendingLock = null;
lockReason = "";
}
}
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog worktree-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-dialog-title">
<header class="dialog-header worktree-dialog-header">
<div class="worktree-dialog-heading">
<span class="worktree-dialog-mark" aria-hidden="true"><HardDrive size={18} /></span>
<div>
<span class="eyebrow">Parallel workspaces</span>
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
</div>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
Refresh
</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="worktree-summary">
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
<Plus size={15} aria-hidden="true" />
New worktree
</button>
</div>
{#if error}
<div class="worktree-error" role="alert"><AlertTriangle size={15} aria-hidden="true" />{error}</div>
{/if}
<div class="worktree-content">
{#if createOpen}
<form class="worktree-create-card" onsubmit={submitCreate}>
<header>
<div>
<span class="eyebrow">Create</span>
<h3>Choose what this workspace should track</h3>
</div>
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
<X size={15} aria-hidden="true" />
</button>
</header>
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
<GitBranch size={14} aria-hidden="true" />Existing branch
</button>
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
<Plus size={14} aria-hidden="true" />New branch
</button>
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
<CircleDot size={14} aria-hidden="true" />Detached
</button>
</div>
<div class="worktree-create-fields">
{#if createMode === "existing"}
<label>
<span>Branch</span>
<select bind:value={selectedBranch} disabled={isBusy}>
<option value="" disabled>Select a local branch</option>
{#each localBranches as branch (branch.name)}
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option>
{/each}
</select>
</label>
{:else if createMode === "new"}
<label>
<span>New branch name</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
</label>
<label>
<span>Start point</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
</label>
{:else}
<label>
<span>Commit or ref</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
</label>
{/if}
<label class="worktree-path-field">
<span>Folder</span>
<div>
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />Browse
</button>
</div>
</label>
</div>
<footer>
<label class="worktree-check">
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
</label>
<button
class="btn-primary"
type="submit"
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
Create worktree
</button>
</footer>
</form>
{/if}
{#if isLoading && worktrees.length === 0}
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
{:else if worktrees.length === 0}
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
{:else}
<div class="worktree-list">
{#each worktrees as worktree (worktree.path)}
<article class:current={worktree.is_current} class:stale={worktree.prunable || worktree.missing} class="worktree-card">
<div class="worktree-rail" aria-hidden="true">
<span></span>
{#if !worktree.is_main}<i></i>{/if}
</div>
<div class="worktree-card-main">
<header>
<div class="worktree-name">
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{displayName(worktree)}</strong>
<span>{pathName(worktree.path)}</span>
</div>
</div>
<div class="worktree-badges">
{#if worktree.is_main}<span>Main</span>{/if}
{#if worktree.is_current}<span class="active">Open</span>{/if}
{#if worktree.detached}<span>Detached</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
</div>
</header>
<div class="worktree-path" title={worktree.path}><MapPin size={13} aria-hidden="true" /><code>{worktree.path}</code></div>
<div class="worktree-meta">
<span class:dirty={!worktree.clean}>
{#if worktree.clean}<Check size={12} aria-hidden="true" />Clean{:else}<CircleDot size={12} aria-hidden="true" />{worktree.changed_files} changed{/if}
</span>
{#if worktree.short_head}<span><code>{worktree.short_head}</code></span>{/if}
{#if worktree.lock_reason}<span><Lock size={12} aria-hidden="true" />{worktree.lock_reason}</span>{/if}
{#if worktree.prune_reason}<span class="danger"><AlertTriangle size={12} aria-hidden="true" />{worktree.prune_reason}</span>{/if}
</div>
<footer>
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
</button>
<div class="worktree-card-actions">
{#if worktree.prunable}
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
<Wrench size={14} aria-hidden="true" />Repair
</button>
{/if}
{#if !worktree.is_main && !worktree.missing}
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
<FolderInput size={14} aria-hidden="true" />Move
</button>
{/if}
{#if worktree.locked}
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
<Unlock size={14} aria-hidden="true" />Unlock
</button>
{:else if !worktree.is_main}
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
<Lock size={14} aria-hidden="true" />Lock
</button>
{/if}
{#if !worktree.is_main}
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
<Trash2 size={14} aria-hidden="true" />Remove
</button>
{/if}
</div>
</footer>
</div>
</article>
{/each}
</div>
{/if}
</div>
<footer class="worktree-dialog-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
</button>
</footer>
</div>
</div>
{#if pendingRemoval}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
<header class="dialog-header">
<div>
<span class="eyebrow">Remove worktree</span>
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
</div>
</header>
<div class="worktree-confirm-body">
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
<div>
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
<code>{pendingRemoval.path}</code>
{#if !pendingRemoval.clean}
<label class="worktree-check danger">
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
</label>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
<Trash2 size={15} aria-hidden="true" />Remove worktree
</button>
</footer>
</div>
</div>
{/if}
{#if pendingLock}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="worktree-lock-title">
<header class="dialog-header">
<div>
<span class="eyebrow">Protect worktree</span>
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
</div>
</header>
<div class="worktree-lock-body">
<label>
<span>Reason <small>optional</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
</label>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
</footer>
</div>
</div>
{/if}
+51
View File
@@ -21,6 +21,7 @@ import type {
GitStash, GitStash,
GitStatus, GitStatus,
GitTag, GitTag,
GitWorktree,
LocalModelOption, LocalModelOption,
PatchApplyAction, PatchApplyAction,
RepositoryBundle, RepositoryBundle,
@@ -116,6 +117,56 @@ 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 listWorktrees(path: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("list_worktrees", { path });
}
export function addWorktree(
path: string,
worktreePath: string,
options: {
branch?: string;
newBranch?: string;
startPoint?: string;
detached?: boolean;
lock?: boolean;
} = {},
): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("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<GitWorktree[]> {
return invoke<GitWorktree[]>("remove_worktree", { path, worktreePath, force });
}
export function moveWorktree(path: string, worktreePath: string, destination: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("move_worktree", { path, worktreePath, destination });
}
export function lockWorktree(path: string, worktreePath: string, reason?: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("lock_worktree", { path, worktreePath, reason: reason?.trim() || null });
}
export function unlockWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("unlock_worktree", { path, worktreePath });
}
export function pruneWorktrees(path: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("prune_worktrees", { path });
}
export function repairWorktree(path: string, worktreePath: string): Promise<GitWorktree[]> {
return invoke<GitWorktree[]>("repair_worktree", { path, worktreePath });
}
export function listTags(path: string): Promise<GitTag[]> { export function listTags(path: string): Promise<GitTag[]> {
return invoke<GitTag[]>("list_tags", { path }); return invoke<GitTag[]>("list_tags", { path });
} }
+18
View File
@@ -90,6 +90,24 @@ export interface GitBranch {
remote: boolean; 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 { export interface GitTag {
name: string; name: string;
hash: string; hash: string;