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.
This commit is contained in:
Christoph Brandau
2026-07-26 22:10:33 +02:00
parent 38b7f1a536
commit 24201c1302
5 changed files with 350 additions and 70 deletions
+15 -13
View File
@@ -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}
<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}
scope={pendingDiscard.kind === "hunk" ? "hunk" : "file"}
scope={pendingDiscard.kind === "patch" ? pendingDiscard.scope : "file"}
{isBusy}
onConfirm={confirmDiscard}
onClose={closeDiscardConfirm}