Files
GitLite/src/lib/components/CompareDialog.svelte
T
Christoph 96e3805568
publish / Build and publish Windows installer (release) Successful in 23m4s
publish / Build and publish Ubuntu AppImage (release) Successful in 44m12s
publish / Build and publish gitty-desktop to AUR (release) Failing after 54m38s
fix(dialog): improve escape key handling for dialogs
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
2026-08-04 21:17:04 +02:00

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>