The handling of the Escape key has been refined to ensure that it closes the correct dialog based on the current state. Additionally, the z-index for the compare dialog backdrop has been updated to ensure proper layering with other dialogs. - Enhanced Escape key functionality for better user experience - Updated z-index for compare dialog backdrop to avoid overlap
358 lines
13 KiB
Svelte
358 lines
13 KiB
Svelte
<script lang="ts">
|
|
import { ArrowRight, FileCode, RotateCcw, X } from "@lucide/svelte";
|
|
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
|
|
|
|
type SplitRow =
|
|
| { type: "span"; kind: "meta" | "hunk"; text: string }
|
|
| {
|
|
type: "pair";
|
|
leftNum?: number; leftText?: string; leftKind: "del" | "context" | "empty";
|
|
rightNum?: number; rightText?: string; rightKind: "add" | "context" | "empty";
|
|
};
|
|
|
|
interface DiffMarker {
|
|
start: number;
|
|
end: number;
|
|
kind: "add" | "delete" | "mixed";
|
|
}
|
|
|
|
interface Props {
|
|
comparison: GitCommitComparison;
|
|
selectedDiffPath: string;
|
|
isBusy: boolean;
|
|
restoreLabel?: string;
|
|
/** When opened from a search hit, the term to highlight on matching lines. */
|
|
highlightQuery?: string;
|
|
onClose: () => void;
|
|
onRestore?: () => void;
|
|
onSelectFile: (file: GitDiffFile) => void;
|
|
}
|
|
|
|
let {
|
|
comparison,
|
|
selectedDiffPath = "",
|
|
isBusy = false,
|
|
restoreLabel = "",
|
|
highlightQuery = "",
|
|
onClose = () => {},
|
|
onRestore = undefined,
|
|
onSelectFile = () => {},
|
|
}: Props = $props();
|
|
|
|
// Needle = first non-empty line of the search query, lowercased for matching.
|
|
let highlightNeedle = $derived(
|
|
highlightQuery
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.find((line) => line.length > 0)
|
|
?.toLowerCase() ?? ""
|
|
);
|
|
|
|
function isMatch(text?: string): boolean {
|
|
return highlightNeedle.length > 0 && !!text && text.toLowerCase().includes(highlightNeedle);
|
|
}
|
|
|
|
let beforePane = $state<HTMLDivElement | null>(null);
|
|
let afterPane = $state<HTMLDivElement | null>(null);
|
|
let isSyncingSplitScroll = false;
|
|
|
|
function syncSplitScroll(source: "before" | "after") {
|
|
if (isSyncingSplitScroll) return;
|
|
const sourcePane = source === "before" ? beforePane : afterPane;
|
|
const targetPane = source === "before" ? afterPane : beforePane;
|
|
if (!sourcePane || !targetPane) return;
|
|
|
|
isSyncingSplitScroll = true;
|
|
targetPane.scrollTop = sourcePane.scrollTop;
|
|
targetPane.scrollLeft = sourcePane.scrollLeft;
|
|
requestAnimationFrame(() => {
|
|
isSyncingSplitScroll = false;
|
|
});
|
|
}
|
|
|
|
function displayDiffFile(file: GitDiffFile): string {
|
|
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
|
|
}
|
|
|
|
function buildDiffByPath(patch: string): Map<string, string> {
|
|
const map = new Map<string, string>();
|
|
if (!patch.trim()) return map;
|
|
let current: string[] = [];
|
|
for (const line of patch.split("\n")) {
|
|
if (line.startsWith("diff --git ") && current.length > 0) {
|
|
const seg = current.join("\n");
|
|
const p = segmentPath(seg);
|
|
if (p) map.set(p, seg);
|
|
current = [];
|
|
}
|
|
current.push(line);
|
|
}
|
|
if (current.length > 0) {
|
|
const seg = current.join("\n");
|
|
const p = segmentPath(seg);
|
|
if (p) map.set(p, seg);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
function segmentPath(seg: string): string {
|
|
let plus = "";
|
|
let minus = "";
|
|
for (const line of seg.split("\n")) {
|
|
if (line.startsWith("+++ ")) {
|
|
const r = line.slice(4).trim();
|
|
plus = r.startsWith("b/") ? r.slice(2) : r;
|
|
} else if (line.startsWith("--- ")) {
|
|
const r = line.slice(4).trim();
|
|
minus = r.startsWith("a/") ? r.slice(2) : r;
|
|
} else if (line.startsWith("@@")) break;
|
|
}
|
|
return (plus && plus !== "/dev/null") ? plus : minus;
|
|
}
|
|
|
|
function buildSplitRows(patch: string): SplitRow[] {
|
|
if (!patch.trim()) return [];
|
|
const lines = patch.replace(/\n$/, "").split("\n");
|
|
const rows: SplitRow[] = [];
|
|
let leftNum = 0;
|
|
let rightNum = 0;
|
|
const dels: string[] = [];
|
|
const adds: string[] = [];
|
|
|
|
function flush() {
|
|
if (dels.length === 0 && adds.length === 0) return;
|
|
const count = Math.max(dels.length, adds.length);
|
|
for (let i = 0; i < count; i++) {
|
|
const hasDel = i < dels.length;
|
|
const hasAdd = i < adds.length;
|
|
if (hasDel) leftNum++;
|
|
if (hasAdd) rightNum++;
|
|
rows.push({
|
|
type: "pair",
|
|
leftNum: hasDel ? leftNum : undefined,
|
|
leftText: hasDel ? dels[i].slice(1) : undefined,
|
|
leftKind: hasDel ? "del" : "empty",
|
|
rightNum: hasAdd ? rightNum : undefined,
|
|
rightText: hasAdd ? adds[i].slice(1) : undefined,
|
|
rightKind: hasAdd ? "add" : "empty",
|
|
});
|
|
}
|
|
dels.length = 0;
|
|
adds.length = 0;
|
|
}
|
|
|
|
for (const line of lines) {
|
|
const isMeta =
|
|
line.startsWith("diff ") || line.startsWith("index ") ||
|
|
line.startsWith("--- ") || line.startsWith("+++ ") ||
|
|
line.startsWith("new file") || line.startsWith("deleted file") ||
|
|
line.startsWith("rename ") || line.startsWith("similarity ");
|
|
|
|
if (isMeta) {
|
|
flush();
|
|
continue;
|
|
} else if (line.startsWith("@@")) {
|
|
flush();
|
|
const m = line.match(/@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/);
|
|
if (m) { leftNum = parseInt(m[1]) - 1; rightNum = parseInt(m[2]) - 1; }
|
|
} else if (line.startsWith("\\ ")) {
|
|
flush();
|
|
} else if (line.startsWith("-")) {
|
|
dels.push(line);
|
|
} else if (line.startsWith("+")) {
|
|
adds.push(line);
|
|
} else if (line.startsWith(" ")) {
|
|
flush();
|
|
leftNum++;
|
|
rightNum++;
|
|
rows.push({
|
|
type: "pair",
|
|
leftNum, leftText: line.slice(1), leftKind: "context",
|
|
rightNum, rightText: line.slice(1), rightKind: "context",
|
|
});
|
|
} else {
|
|
flush();
|
|
rows.push({ type: "span", kind: "meta", text: line });
|
|
}
|
|
}
|
|
flush();
|
|
return rows;
|
|
}
|
|
|
|
function buildDiffMarkers(rows: SplitRow[]): DiffMarker[] {
|
|
const markers: DiffMarker[] = [];
|
|
let current: DiffMarker | null = null;
|
|
for (let index = 0; index < rows.length; index++) {
|
|
const row = rows[index];
|
|
if (row.type !== "pair" || (row.leftKind === "context" && row.rightKind === "context")) {
|
|
current = null;
|
|
continue;
|
|
}
|
|
const kind = row.leftKind === "del" && row.rightKind === "add"
|
|
? "mixed"
|
|
: row.rightKind === "add" ? "add" : "delete";
|
|
if (current && current.end === index - 1 && current.kind === kind) {
|
|
current.end = index;
|
|
} else {
|
|
current = { start: index, end: index, kind };
|
|
markers.push(current);
|
|
}
|
|
}
|
|
return markers;
|
|
}
|
|
|
|
function scrollToDiffMarker(rowIndex: number) {
|
|
const ratio = rowIndex / Math.max(splitRows.length - 1, 1);
|
|
for (const pane of [beforePane, afterPane]) {
|
|
if (pane) pane.scrollTop = ratio * Math.max(pane.scrollHeight - pane.clientHeight, 0);
|
|
}
|
|
}
|
|
|
|
let diffByPath = $derived(buildDiffByPath(comparison.patch));
|
|
let selectedFile = $derived(comparison.files.find((f) => f.path === selectedDiffPath) ?? null);
|
|
let selectedPatch = $derived(selectedFile ? (diffByPath.get(selectedFile.path) ?? "") : "");
|
|
let splitRows = $derived(buildSplitRows(selectedPatch));
|
|
let diffMarkers = $derived(buildDiffMarkers(splitRows));
|
|
</script>
|
|
|
|
<div
|
|
class="dialog-backdrop compare-dialog-backdrop"
|
|
role="presentation"
|
|
>
|
|
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Commit comparison" tabindex="-1">
|
|
|
|
<header class="dialog-header">
|
|
<div>
|
|
<span class="eyebrow">Compare</span>
|
|
<h2 class="dialog-range">
|
|
<span class="hash">{comparison.from_short}</span>
|
|
<ArrowRight size={14} aria-hidden="true" />
|
|
<span class="hash">{comparison.to_short}</span>
|
|
</h2>
|
|
</div>
|
|
<div class="dialog-header-actions">
|
|
{#if restoreLabel && onRestore}
|
|
<button class="btn-secondary compare-restore" type="button" onclick={onRestore} disabled={isBusy} title={restoreLabel}>
|
|
<RotateCcw size={15} aria-hidden="true" />
|
|
<span>{restoreLabel}</span>
|
|
</button>
|
|
{/if}
|
|
<button class="dialog-close" type="button" onclick={onClose} title="Close">
|
|
<X size={18} aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
{#if comparison.files.length === 0}
|
|
<div class="blank-state">No differences — these versions are identical.</div>
|
|
{:else}
|
|
<div class="dialog-body">
|
|
|
|
<!-- File list -->
|
|
<aside class="dialog-files" aria-label="Changed files">
|
|
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
|
|
<button
|
|
class="dialog-file-row"
|
|
class:active={selectedDiffPath === file.path}
|
|
type="button"
|
|
onclick={() => onSelectFile(file)}
|
|
title={displayDiffFile(file)}
|
|
>
|
|
<span class={`status-badge ${file.status}`}>{file.status}</span>
|
|
<strong>{displayDiffFile(file)}</strong>
|
|
<span class="diff-counts">
|
|
<span class="adds">+{file.additions}</span>
|
|
<span class="dels">-{file.deletions}</span>
|
|
</span>
|
|
</button>
|
|
{/each}
|
|
</aside>
|
|
|
|
<!-- Diff pane -->
|
|
<div class="dialog-diff">
|
|
{#if !selectedFile}
|
|
<div class="blank-state">Select a file to see its changes.</div>
|
|
{:else if splitRows.length === 0}
|
|
<div class="blank-state">No textual changes for this file.</div>
|
|
{:else}
|
|
<!-- Path bar -->
|
|
<div class="diff-header">
|
|
<FileCode size={13} aria-hidden="true" />
|
|
<span>{displayDiffFile(selectedFile)}</span>
|
|
<span class="diff-counts" style="margin-left: auto; flex-shrink: 0;">
|
|
<span class="adds">+{selectedFile.additions}</span>
|
|
<span class="dels">-{selectedFile.deletions}</span>
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Column headers -->
|
|
<div class="split-col-headers">
|
|
<div class="split-col-label">
|
|
<span>Before</span>
|
|
<span class="split-col-hash">{comparison.from_short}</span>
|
|
</div>
|
|
<div class="split-col-label">
|
|
<span>After</span>
|
|
<span class="split-col-hash">{comparison.to_short}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Split diff grid -->
|
|
<div class="split-diff-shell">
|
|
<div class="split-diff" role="table" aria-label="Side-by-side diff">
|
|
<div
|
|
class="split-pane"
|
|
bind:this={beforePane}
|
|
aria-label="Before file content"
|
|
onscroll={() => syncSplitScroll("before")}
|
|
>
|
|
<div class="split-pane-grid">
|
|
{#each splitRows as row, i (`left-${i}`)}
|
|
{#if row.type === "span"}
|
|
<div class="split-span split-{row.kind}">{row.text}</div>
|
|
{:else}
|
|
<div class="split-num" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftNum ?? ""}</div>
|
|
<div class="split-cell" class:del={row.leftKind === "del"} class:empty={row.leftKind === "empty"} class:match={isMatch(row.leftText)}>{row.leftText ?? " "}</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div
|
|
class="split-pane"
|
|
bind:this={afterPane}
|
|
aria-label="After file content"
|
|
onscroll={() => syncSplitScroll("after")}
|
|
>
|
|
<div class="split-pane-grid">
|
|
{#each splitRows as row, i (`right-${i}`)}
|
|
{#if row.type === "span"}
|
|
<div class="split-span split-{row.kind}">{row.text}</div>
|
|
{:else}
|
|
<div class="split-num" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightNum ?? ""}</div>
|
|
<div class="split-cell" class:add={row.rightKind === "add"} class:empty={row.rightKind === "empty"} class:match={isMatch(row.rightText)}>{row.rightText ?? " "}</div>
|
|
{/if}
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<nav class="diff-overview" aria-label="Change overview">
|
|
{#each diffMarkers as marker, index (`${marker.start}-${marker.end}-${marker.kind}`)}
|
|
<button
|
|
class="diff-overview-marker {marker.kind}"
|
|
type="button"
|
|
style={`--marker-position: ${(marker.start / Math.max(splitRows.length - 1, 1)) * 100}%`}
|
|
onclick={() => scrollToDiffMarker(marker.start)}
|
|
title={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
|
aria-label={`Jump to change ${index + 1} of ${diffMarkers.length}`}
|
|
></button>
|
|
{/each}
|
|
</nav>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|