Adds startup clone support with a new clone request type and parsing. It wires a CLI and IPC pathway to forward a clone to a running app. UI and docs were updated to reflect startup clone behavior. - Introduce StartupCloneRequest and argument parsing. - Wire IPC to pass clone requests and clone on startup. - UI updated to queue clone requests and trigger clone.
415 lines
16 KiB
Svelte
415 lines
16 KiB
Svelte
<script lang="ts">
|
|
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
|
import type { GitFileStatus, PatchApplyAction } from "../types";
|
|
|
|
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
|
|
|
interface PatchLine {
|
|
id: string;
|
|
text: string;
|
|
kind: PatchLineKind;
|
|
oldLine: number | null;
|
|
newLine: number | null;
|
|
}
|
|
|
|
interface PatchHunk {
|
|
id: string;
|
|
header: string;
|
|
lines: PatchLine[];
|
|
oldStart: number;
|
|
newStart: number;
|
|
suffix: string;
|
|
}
|
|
|
|
interface ParsedPatch {
|
|
headerLines: string[];
|
|
hunks: PatchHunk[];
|
|
binary: boolean;
|
|
}
|
|
|
|
interface Props {
|
|
file: GitFileStatus;
|
|
staged: boolean;
|
|
patch: string;
|
|
isBusy: boolean;
|
|
isLoading: boolean;
|
|
error: string;
|
|
language?: "en" | "de";
|
|
diffName?: string;
|
|
onClose: () => void;
|
|
onRefresh: () => void | Promise<void>;
|
|
onApply: (action: PatchApplyAction, patch: string, scope: "hunk" | "lines") => void | Promise<void>;
|
|
onExternalDiff: () => void | Promise<void>;
|
|
}
|
|
|
|
let {
|
|
file,
|
|
staged = false,
|
|
patch = "",
|
|
isBusy = false,
|
|
isLoading = false,
|
|
error = "",
|
|
language = "en",
|
|
diffName = "diff tool",
|
|
onClose = () => {},
|
|
onRefresh = () => {},
|
|
onApply = () => {},
|
|
onExternalDiff = () => {},
|
|
}: Props = $props();
|
|
|
|
const isGerman = $derived(language === "de");
|
|
|
|
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
|
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 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(() => {
|
|
parsed = parsePatch(patch);
|
|
selectedLineIds = new Set();
|
|
lastSelectedLineId = "";
|
|
});
|
|
|
|
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;
|
|
let oldCursor = 0;
|
|
let newCursor = 0;
|
|
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@ ")) {
|
|
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);
|
|
continue;
|
|
}
|
|
|
|
if (!current) {
|
|
headerLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
const kind = patchLineKind(line);
|
|
const oldLine = kind === "context" || kind === "delete" ? oldCursor : null;
|
|
const newLine = kind === "context" || kind === "add" ? newCursor : null;
|
|
current.lines.push({
|
|
id: `${current.id}-line-${current.lines.length}`,
|
|
text: line,
|
|
kind,
|
|
oldLine,
|
|
newLine,
|
|
});
|
|
if (oldLine !== null) oldCursor += 1;
|
|
if (newLine !== null) newCursor += 1;
|
|
}
|
|
|
|
return {
|
|
headerLines,
|
|
hunks,
|
|
binary: /(^|\n)(Binary files|GIT binary patch|literal \d+)/.test(normalized),
|
|
};
|
|
}
|
|
|
|
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 {
|
|
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), "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 {
|
|
const totalLines = parsed.hunks.reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
|
const precedingLines = parsed.hunks.slice(0, index).reduce((sum, hunk) => sum + Math.max(hunk.lines.length, 1), 0);
|
|
return (precedingLines / Math.max(totalLines - 1, 1)) * 100;
|
|
}
|
|
|
|
function hunkKind(hunk: PatchHunk): "add" | "delete" | "mixed" {
|
|
const hasAdd = hunk.lines.some((line) => line.kind === "add");
|
|
const hasDelete = hunk.lines.some((line) => line.kind === "delete");
|
|
return hasAdd && hasDelete ? "mixed" : hasAdd ? "add" : "delete";
|
|
}
|
|
|
|
function scrollToHunk(hunkId: string) {
|
|
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
|
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
|
}
|
|
</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 line-patch-header-actions">
|
|
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
|
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
|
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
|
<FileDiff size={13} aria-hidden="true" />Gitty
|
|
</button>
|
|
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
|
|
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
|
|
</button>
|
|
</div>
|
|
<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: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-scroll" bind:this={patchScroll}>
|
|
{#each parsed.hunks as hunk (hunk.id)}
|
|
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
|
<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>
|
|
<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: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>
|
|
<code>{lineBody(line)}</code>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
</section>
|
|
{/each}
|
|
</div>
|
|
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
|
{#each parsed.hunks as hunk, index (hunk.id)}
|
|
<button
|
|
class="diff-overview-marker {hunkKind(hunk)}"
|
|
type="button"
|
|
style={`--marker-position: ${hunkPosition(index)}%`}
|
|
onclick={() => scrollToHunk(hunk.id)}
|
|
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
|
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
|
></button>
|
|
{/each}
|
|
</nav>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</div>
|