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:
@@ -6713,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(),
|
||||||
|
|||||||
+15
-13
@@ -177,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;
|
||||||
@@ -3303,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}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3323,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 = "";
|
||||||
|
|
||||||
@@ -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;
|
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() {
|
||||||
@@ -3379,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;
|
||||||
@@ -4613,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}
|
||||||
|
|||||||
+75
-3
@@ -4167,11 +4167,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;
|
||||||
@@ -4197,6 +4223,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);
|
||||||
@@ -4242,12 +4295,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);
|
||||||
@@ -4264,12 +4322,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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,11 +290,47 @@
|
|||||||
{: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:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||||
|
<div>
|
||||||
|
<MousePointer2 size={14} aria-hidden="true" />
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||||
|
<span>Shift-click to select a range.</span>
|
||||||
|
{:else}
|
||||||
|
<strong>Select changed lines</strong>
|
||||||
|
<span>Choose individual additions or deletions below.</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<div class="line-patch-selected-actions">
|
||||||
|
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||||
|
Discard selected
|
||||||
|
</button>
|
||||||
|
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||||
|
{staged ? "Unstage selected" : "Stage selected"}
|
||||||
|
</button>
|
||||||
|
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="line-patch-workspace">
|
<div class="line-patch-workspace">
|
||||||
<div class="line-patch-scroll" bind:this={patchScroll}>
|
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||||
{#each parsed.hunks as hunk (hunk.id)}
|
{#each parsed.hunks as hunk (hunk.id)}
|
||||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||||
<div class="line-patch-hunk-head">
|
<div class="line-patch-hunk-head">
|
||||||
|
<button
|
||||||
|
class:all={hunkSelectionState(hunk) === "all"}
|
||||||
|
class:some={hunkSelectionState(hunk) === "some"}
|
||||||
|
class="line-patch-select-hunk"
|
||||||
|
type="button"
|
||||||
|
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>
|
<code>{hunk.header}</code>
|
||||||
<div class="line-patch-hunk-actions">
|
<div class="line-patch-hunk-actions">
|
||||||
{#if staged}
|
{#if staged}
|
||||||
@@ -193,7 +353,23 @@
|
|||||||
|
|
||||||
<div class="line-patch-lines">
|
<div class="line-patch-lines">
|
||||||
{#each hunk.lines as line (line.id)}
|
{#each hunk.lines as line (line.id)}
|
||||||
<div class={`line-patch-row ${line.kind}`}>
|
<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>
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||||
<code>{lineBody(line)}</code>
|
<code>{lineBody(line)}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user