Files
GitLite/src/lib/components/CompareDialog.svelte
T
Christoph a7558e457d feat(compare): add German localization and revamp compare UI
Pass the application language into compare dialogs and add German
translations so titles, buttons and helper text render appropriately.
Refactor dialog markup to introduce a compact header with a GitCompare
icon, show changed-file counts, and surface contextual help and warnings.
Adjust and extend CSS for spacing, layout and responsive behavior.

- Pass app language into compare components and derive isGerman flag
- Replace icons and restructure headers; add counts, help and warnings
- Update styles for sizing, spacing, responsive rules and new classes
2026-08-15 18:39:10 +02:00

373 lines
14 KiB
Svelte

<script lang="ts">
import { ArrowRight, FileCode, GitCompare, 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;
fromLabel?: string;
toLabel?: string;
restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string;
language?: "en" | "de";
onClose: () => void;
onRestore?: () => void;
onSelectFile: (file: GitDiffFile) => void;
}
let {
comparison,
selectedDiffPath = "",
isBusy = false,
fromLabel = "",
toLabel = "",
restoreLabel = "",
highlightQuery = "",
language = "en",
onClose = () => {},
onRestore = undefined,
onSelectFile = () => {},
}: Props = $props();
let isGerman = $derived(language === "de");
// 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="Branch or commit comparison" tabindex="-1">
<header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div>
<h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
<p class="dialog-range">
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" />
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</p>
</div>
</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={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
<X size={18} aria-hidden="true" />
</button>
</div>
</header>
{#if comparison.files.length === 0}
<div class="blank-state">{isGerman ? "Keine Unterschiede - diese Versionen sind identisch." : "No differences - these versions are identical."}</div>
{:else}
<div class="dialog-body">
<!-- File list -->
<aside class="dialog-files" aria-label={isGerman ? "Geänderte Dateien" : "Changed files"}>
<div class="compare-files-head">
<span>{isGerman ? "Geänderte Dateien" : "Changed files"}</span>
<strong>{comparison.files.length}</strong>
</div>
{#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">{isGerman ? "Wähle eine Datei aus, um ihre Änderungen zu sehen." : "Select a file to see its changes."}</div>
{:else if splitRows.length === 0}
<div class="blank-state">{isGerman ? "Keine textuellen Änderungen für diese Datei." : "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>{isGerman ? "Vorher" : "Before"}</span>
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
</div>
<div class="split-col-label">
<span>{isGerman ? "Nachher" : "After"}</span>
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || 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>