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 @@