Files
GitLite/src/lib/components/ResolveDialog.svelte
T
Christoph Brandau 15d1f2bfd6 feat(external-tools): add cross-platform external tool discovery
Adds a new external tools subsystem to detect and launch
diff and editor tools across Windows, macOS, and Linux.
It exposes data models for tools, commands, and results to the UI
and serializes them for consumption by the app.

- Implement cross-platform discovery of editors and diff tools
- Expose serialized results to the UI for user selection
- Centralize per-OS known tool lists and overrides
2026-08-13 14:08:02 +02:00

524 lines
23 KiB
Svelte

<script lang="ts">
import { untrack } from "svelte";
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
type ResolveSplitRow =
| { type: "marker"; conflictIndex: number }
| {
type: "pair";
conflictIndex?: number;
leftNum?: number; leftText?: string; leftKind: "context" | "ours" | "empty";
rightNum?: number; rightText?: string; rightKind: "context" | "theirs" | "empty";
};
interface Props {
conflictedFiles: GitFileStatus[];
conflictTarget: string;
conflict: ConflictFile | null;
preparedResolutions: Record<string, PreparedResolution>;
isBusy: boolean;
operation: string;
onClose: () => void;
onSelectFile: (path: string) => void;
onMarkResolved: (path: string, resolution: PreparedResolution) => void;
onApply: () => void;
onExternalMerge: (path: string) => void | Promise<void>;
language?: "en" | "de";
mergeName?: string;
}
let {
conflictedFiles = [],
conflictTarget = "",
conflict = null,
preparedResolutions = {},
isBusy = false,
operation = "",
onClose = () => {},
onSelectFile = () => {},
onMarkResolved = () => {},
onApply = () => {},
onExternalMerge = () => {},
language = "en",
mergeName = "merge tool",
}: Props = $props();
const isGerman = $derived(language === "de");
let conflictParts = $derived<ConflictPart[]>(
conflict && !conflict.binary ? parseConflicts(conflict.content) : [],
);
let conflictChoices = $state<(ConflictChoice | null)[]>([]);
let resolveContent = $state("");
let manualMode = $state(false);
let binarySide = $state<"ours" | "theirs" | null>(null);
let resolveBeforePane = $state<HTMLDivElement | null>(null);
let resolveAfterPane = $state<HTMLDivElement | null>(null);
let isSyncingResolveScroll = false;
$effect(() => {
const c = conflict;
const target = conflictTarget;
const parts = conflictParts;
untrack(() => {
if (!c) {
conflictChoices = [];
resolveContent = "";
manualMode = false;
binarySide = null;
return;
}
const prepared = preparedResolutions[target];
if (c.binary) {
conflictChoices = [];
resolveContent = "";
manualMode = false;
binarySide = prepared?.kind === "side" ? prepared.side : null;
return;
}
binarySide = null;
conflictChoices = parts.filter((p) => p.kind === "conflict").map(() => null);
if (prepared?.kind === "content") {
resolveContent = prepared.content;
manualMode = true;
} else {
resolveContent = c.content;
manualMode = false;
}
});
});
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
let conflictRegions = $derived(conflictOnlyParts(conflictParts));
let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts));
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
let resolveHasMarkers = $derived(/^<{7}/m.test(resolvedContent) || /^>{7}/m.test(resolvedContent));
let preparedCount = $derived(Object.keys(preparedResolutions).length);
let currentPrepared = $derived(conflictTarget.length > 0 && preparedResolutions[conflictTarget] != null);
let canMarkResolved = $derived(!!conflict && !isBusy && (conflict.binary ? binarySide != null : !resolveHasMarkers));
function parseConflicts(content: string): ConflictPart[] {
const lines = content.split("\n");
const parts: ConflictPart[] = [];
let textLines: string[] = [];
let regionIndex = 0;
let index = 0;
const flushText = () => {
if (textLines.length > 0) { parts.push({ kind: "text", lines: textLines }); textLines = []; }
};
while (index < lines.length) {
const line = lines[index];
if (line.startsWith("<<<<<<<")) {
flushText(); index++;
const oursLines: string[] = [];
while (index < lines.length && !lines[index].startsWith("=======") && !lines[index].startsWith("|||||||")) oursLines.push(lines[index++]);
if (index < lines.length && lines[index].startsWith("|||||||")) { index++; while (index < lines.length && !lines[index].startsWith("=======")) index++; }
if (index < lines.length && lines[index].startsWith("=======")) index++;
const theirsLines: string[] = [];
while (index < lines.length && !lines[index].startsWith(">>>>>>>")) theirsLines.push(lines[index++]);
if (index < lines.length && lines[index].startsWith(">>>>>>>")) index++;
parts.push({ kind: "conflict", index: regionIndex++, oursLines, theirsLines });
} else { textLines.push(line); index++; }
}
flushText();
return parts;
}
function buildResolution(parts: ConflictPart[], choices: (ConflictChoice | null)[]): string {
const out: string[] = [];
for (const part of parts) {
if (part.kind === "text") { out.push(...part.lines); continue; }
const choice = choices[part.index];
if (choice === "ours") out.push(...part.oursLines);
else if (choice === "theirs") out.push(...part.theirsLines);
else if (choice === "both-ot") out.push(...part.oursLines, ...part.theirsLines);
else if (choice === "both-to") out.push(...part.theirsLines, ...part.oursLines);
else out.push("<<<<<<< current", ...part.oursLines, "=======", ...part.theirsLines, ">>>>>>> incoming");
}
return out.join("\n");
}
function conflictOnlyParts(parts: ConflictPart[]): ConflictRegion[] {
return parts.filter((part): part is ConflictRegion => part.kind === "conflict");
}
function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] {
const rows: ResolveSplitRow[] = [];
let leftNum = 0;
let rightNum = 0;
for (const part of parts) {
if (part.kind === "text") {
for (const line of part.lines) {
leftNum++;
rightNum++;
rows.push({
type: "pair",
leftNum,
leftText: line,
leftKind: "context",
rightNum,
rightText: line,
rightKind: "context",
});
}
continue;
}
rows.push({ type: "marker", conflictIndex: part.index });
const count = Math.max(part.oursLines.length, part.theirsLines.length);
for (let i = 0; i < count; i++) {
const hasOurs = i < part.oursLines.length;
const hasTheirs = i < part.theirsLines.length;
if (hasOurs) leftNum++;
if (hasTheirs) rightNum++;
rows.push({
type: "pair",
conflictIndex: part.index,
leftNum: hasOurs ? leftNum : undefined,
leftText: hasOurs ? part.oursLines[i] : undefined,
leftKind: hasOurs ? "ours" : "empty",
rightNum: hasTheirs ? rightNum : undefined,
rightText: hasTheirs ? part.theirsLines[i] : undefined,
rightKind: hasTheirs ? "theirs" : "empty",
});
}
}
return rows;
}
function syncResolveScroll(source: "before" | "after") {
if (isSyncingResolveScroll) return;
const sourcePane = source === "before" ? resolveBeforePane : resolveAfterPane;
const targetPane = source === "before" ? resolveAfterPane : resolveBeforePane;
if (!sourcePane || !targetPane) return;
isSyncingResolveScroll = true;
targetPane.scrollTop = sourcePane.scrollTop;
targetPane.scrollLeft = sourcePane.scrollLeft;
requestAnimationFrame(() => {
isSyncingResolveScroll = false;
});
}
function legacyConflictParts(): any[] {
return [];
}
function setConflictChoice(index: number, choice: ConflictChoice) {
const next = [...conflictChoices];
next[index] = choice;
conflictChoices = next;
}
function setAllConflicts(choice: ConflictChoice) {
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
}
function enableManualEdit() {
resolveContent = buildResolution(conflictParts, conflictChoices);
manualMode = true;
}
function oursActive(choice: ConflictChoice | null): boolean {
return choice === "ours" || choice === "both-ot" || choice === "both-to";
}
function theirsActive(choice: ConflictChoice | null): boolean {
return choice === "theirs" || choice === "both-ot" || choice === "both-to";
}
function displayLine(line: string): string {
return line.replace(/\r$/, "");
}
function formatBytes(size: number | null): string {
if (size == null) return "missing";
if (size < 1024) return `${size} B`;
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
}
function handleMarkResolved() {
if (!conflict || !conflictTarget || !canMarkResolved) return;
const prepared: PreparedResolution = conflict.binary
? { kind: "side", side: binarySide as "ours" | "theirs" }
: { kind: "content", content: resolvedContent };
onMarkResolved(conflictTarget, prepared);
}
</script>
<div
class="dialog-backdrop"
role="presentation"
>
<div
class="dialog"
style="grid-template-rows: auto minmax(0,1fr) auto;"
role="dialog"
aria-modal="true"
aria-label="Resolve conflicts"
tabindex="-1"
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Conflicts</h2>
</div>
<div class="dialog-header-actions">
{#if conflictTarget}
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}>
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}>
<GitMerge size={13} aria-hidden="true" />Gitty
</button>
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}>
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span>
</button>
</div>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
<X size={18} aria-hidden="true" />
</button>
</div>
</header>
{#if conflictedFiles.length === 0}
<div class="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
{:else}
<div class="dialog-body">
<aside class="dialog-files" aria-label="Conflicted files">
{#each conflictedFiles as file (file.path)}
<button
class="dialog-file-row"
class:active={conflictTarget === file.path}
class:prepared={preparedResolutions[file.path] != null}
type="button"
onclick={() => onSelectFile(file.path)}
disabled={isBusy}
title={file.path}
>
<span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
{preparedResolutions[file.path] ? "ready" : "conflicted"}
</span>
<strong>{file.path}</strong>
{#if preparedResolutions[file.path]}
<Check size={15} aria-hidden="true" />
{/if}
</button>
{/each}
</aside>
<div class="resolve-editor" aria-label="Conflict editor">
{#if !conflict}
<div class="blank-state">Select a file to resolve.</div>
{:else if conflict.binary}
<div class="resolve-binary">
<div class="resolve-binary-note">
<AlertCircle size={16} aria-hidden="true" />
<span>Binary file — pick which version to keep, then mark it resolved.</span>
</div>
<div class="resolve-binary-options">
<button
class="resolve-binary-card ours"
class:active={binarySide === "ours"}
type="button"
onclick={() => { binarySide = "ours"; }}
disabled={isBusy || conflict.ours_size == null}
>
<span class="resolve-side-label">Current (ours)</span>
<strong>{formatBytes(conflict.ours_size)}</strong>
<span class="resolve-binary-hint">{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}</span>
</button>
<button
class="resolve-binary-card theirs"
class:active={binarySide === "theirs"}
type="button"
onclick={() => { binarySide = "theirs"; }}
disabled={isBusy || conflict.theirs_size == null}
>
<span class="resolve-side-label">Incoming (theirs)</span>
<strong>{formatBytes(conflict.theirs_size)}</strong>
<span class="resolve-binary-hint">{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}</span>
</button>
</div>
</div>
<div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
{/if}
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
</button>
</div>
{:else}
<div class="resolve-toolbar">
{#if manualMode}
<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>Back to guided</button>
{:else}
<span class="resolve-toolbar-label">Apply to all:</span>
<button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>Current</button>
<button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>Incoming</button>
<button type="button" onclick={() => setAllConflicts("both-ot")} disabled={isBusy}>Both</button>
<button type="button" onclick={enableManualEdit} disabled={isBusy}>Edit manually</button>
{/if}
<span class="resolve-status">
{#if unresolvedCount > 0}
<AlertCircle size={14} aria-hidden="true" />
{unresolvedCount} of {conflictRegionCount} unresolved
{:else if resolveHasMarkers}
<AlertCircle size={14} aria-hidden="true" />
Conflict markers still present
{:else}
<Check size={14} aria-hidden="true" />
{conflictRegionCount} {conflictRegionCount === 1 ? "conflict" : "conflicts"} resolved
{/if}
</span>
</div>
{#if manualMode}
<textarea
class="resolve-textarea"
bind:value={resolveContent}
spellcheck="false"
disabled={isBusy}
></textarea>
{:else}
<div class="resolve-structured">
{#if conflictRegions.length > 0}
<div class="resolve-conflict-controls" aria-label="Conflict decisions">
{#each conflictRegions as part (part.index)}
<div class="resolve-conflict-bar" class:unresolved={conflictChoices[part.index] == null}>
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
<div class="resolve-choice-buttons">
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both - current first">Both C+I</button>
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both - incoming first">Both I+C</button>
</div>
</div>
{/each}
</div>
{/if}
<div class="resolve-split" role="table" aria-label="Full conflict comparison">
<div
class="resolve-pane"
bind:this={resolveBeforePane}
aria-label="Current file content"
onscroll={() => syncResolveScroll("before")}
>
<div class="resolve-pane-grid">
{#each resolveSplitRows as row, i (`left-${i}`)}
{#if row.type === "marker"}
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
{:else}
<div class="resolve-num" class:ours={row.leftKind === "ours"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
<div
class="resolve-cell"
class:ours={row.leftKind === "ours"}
class:empty={row.leftKind === "empty"}
class:dimmed={row.conflictIndex != null && !oursActive(conflictChoices[row.conflictIndex])}
>{displayLine(row.leftText ?? "") || " "}</div>
{/if}
{/each}
</div>
</div>
<div
class="resolve-pane"
bind:this={resolveAfterPane}
aria-label="Incoming file content"
onscroll={() => syncResolveScroll("after")}
>
<div class="resolve-pane-grid">
{#each resolveSplitRows as row, i (`right-${i}`)}
{#if row.type === "marker"}
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
{:else}
<div class="resolve-num" class:theirs={row.rightKind === "theirs"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
<div
class="resolve-cell"
class:theirs={row.rightKind === "theirs"}
class:empty={row.rightKind === "empty"}
class:dimmed={row.conflictIndex != null && !theirsActive(conflictChoices[row.conflictIndex])}
>{displayLine(row.rightText ?? "") || " "}</div>
{/if}
{/each}
</div>
</div>
</div>
{#if false}
{#each legacyConflictParts() as part, partIndex (partIndex)}
{#if part.kind === "text"}
{#if part.lines.length > 0}
<pre class="resolve-context">{#each part.lines as line}<span class="resolve-line context">{displayLine(line) || " "}</span>{/each}</pre>
{/if}
{:else}
<div class="resolve-conflict" class:unresolved={conflictChoices[part.index] == null}>
<div class="resolve-conflict-bar">
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
<div class="resolve-choice-buttons">
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both current first">Both C+I</button>
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both incoming first">Both I+C</button>
</div>
</div>
<div class="resolve-side ours" class:dimmed={!oursActive(conflictChoices[part.index])}>
<span class="resolve-side-label">Current (ours)</span>
<pre class="resolve-lines">{#each part.oursLines as line}<span class="resolve-line ours">{displayLine(line) || " "}</span>{/each}</pre>
</div>
<div class="resolve-side theirs" class:dimmed={!theirsActive(conflictChoices[part.index])}>
<span class="resolve-side-label">Incoming (theirs)</span>
<pre class="resolve-lines">{#each part.theirsLines as line}<span class="resolve-line theirs">{displayLine(line) || " "}</span>{/each}</pre>
</div>
</div>
{/if}
{/each}
{/if}
</div>
{/if}
<div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
{/if}
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
</button>
</div>
{/if}
</div>
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{preparedCount} of {conflictedFiles.length} prepared</span>
<button class="btn-primary" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Apply {preparedCount} resolved
</button>
</footer>
{/if}
</div>
</div>