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
524 lines
23 KiB
Svelte
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>
|