Introduce a new dialog that allows users to view file changes as individual hunks and selectively stage, unstage, or discard them. This provides more granular control over modifications, similar to `git add -p`. Enhance branch panel usability by allowing double-click to checkout a branch.
190 lines
6.0 KiB
Svelte
190 lines
6.0 KiB
Svelte
<script lang="ts">
|
|
import { LoaderCircle, X } from "@lucide/svelte";
|
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
|
|
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
|
|
|
interface PatchLine {
|
|
id: string;
|
|
text: string;
|
|
kind: PatchLineKind;
|
|
}
|
|
|
|
interface PatchHunk {
|
|
id: string;
|
|
header: string;
|
|
lines: PatchLine[];
|
|
}
|
|
|
|
interface ParsedPatch {
|
|
headerLines: string[];
|
|
hunks: PatchHunk[];
|
|
binary: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
file: GitFileStatus;
|
|
staged: boolean;
|
|
patch: string;
|
|
isBusy: boolean;
|
|
isLoading: boolean;
|
|
error: string;
|
|
onClose: () => void;
|
|
onRefresh: () => void | Promise<void>;
|
|
onApply: (action: PatchApplyAction, patch: string) => void | Promise<void>;
|
|
}
|
|
|
|
let {
|
|
file,
|
|
staged = false,
|
|
patch = "",
|
|
isBusy = false,
|
|
isLoading = false,
|
|
error = "",
|
|
onClose = () => {},
|
|
onRefresh = () => {},
|
|
onApply = () => {},
|
|
}: Props = $props();
|
|
|
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
|
|
|
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
|
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
|
|
|
$effect(() => {
|
|
parsed = parsePatch(patch);
|
|
});
|
|
|
|
function parsePatch(input: string): ParsedPatch {
|
|
const normalized = input.replace(/\r\n/g, "\n");
|
|
const lines = normalized.split("\n");
|
|
if (lines[lines.length - 1] === "") lines.pop();
|
|
|
|
const headerLines: string[] = [];
|
|
const hunks: PatchHunk[] = [];
|
|
let current: PatchHunk | null = null;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@ ")) {
|
|
current = { id: `hunk-${hunks.length}`, header: line, lines: [] };
|
|
hunks.push(current);
|
|
continue;
|
|
}
|
|
|
|
if (!current) {
|
|
headerLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
const kind = patchLineKind(line);
|
|
current.lines.push({
|
|
id: `${current.id}-line-${current.lines.length}`,
|
|
text: line,
|
|
kind,
|
|
});
|
|
}
|
|
|
|
return {
|
|
headerLines,
|
|
hunks,
|
|
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
|
|
};
|
|
}
|
|
|
|
function patchLineKind(line: string): PatchLineKind {
|
|
if (line.startsWith("+") && !line.startsWith("+++")) return "add";
|
|
if (line.startsWith("-") && !line.startsWith("---")) return "delete";
|
|
if (line.startsWith(" ")) return "context";
|
|
return "meta";
|
|
}
|
|
|
|
function linePrefix(line: PatchLine): string {
|
|
if (line.kind === "add") return "+";
|
|
if (line.kind === "delete") return "-";
|
|
if (line.kind === "meta") return "\\";
|
|
return " ";
|
|
}
|
|
|
|
function lineBody(line: PatchLine): string {
|
|
if (line.kind === "meta") return line.text;
|
|
return line.text.slice(1);
|
|
}
|
|
|
|
function buildHunkPatch(hunk: PatchHunk): string {
|
|
return `${[...parsed.headerLines, hunk.header, ...hunk.lines.map((line) => line.text)].join("\n")}\n`;
|
|
}
|
|
|
|
async function applyHunkAction(action: PatchApplyAction, hunk: PatchHunk) {
|
|
if (isBusy || isLoading) return;
|
|
await onApply(action, buildHunkPatch(hunk));
|
|
}
|
|
</script>
|
|
|
|
<div class="dialog-backdrop" role="presentation">
|
|
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
|
<header class="dialog-header">
|
|
<div>
|
|
<span class="eyebrow">{scopeLabel}</span>
|
|
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
|
</div>
|
|
<div class="dialog-header-actions">
|
|
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>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="line-patch-body">
|
|
{#if isLoading}
|
|
<div class="blank-state">
|
|
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
|
Loading patch...
|
|
</div>
|
|
{:else if error}
|
|
<div class="blank-state">{error}</div>
|
|
{:else if !patch.trim()}
|
|
<div class="blank-state">No line patch available for this file.</div>
|
|
{:else if parsed.binary || parsed.hunks.length === 0}
|
|
<div class="blank-state">This change cannot be split into text lines.</div>
|
|
{:else}
|
|
<div class="line-patch-scroll">
|
|
{#each parsed.hunks as hunk (hunk.id)}
|
|
<section class="line-patch-hunk">
|
|
<div class="line-patch-hunk-head">
|
|
<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={`line-patch-row ${line.kind}`}>
|
|
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
|
<code>{lineBody(line)}</code>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|