From 24201c1302accbc16013c1c98509f5a2a44ae287 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Sun, 26 Jul 2026 22:10:33 +0200 Subject: [PATCH] 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 @@