Files
GitLite/src/lib/components/ResolveDialog.svelte
T
Christoph 7ee02e8652 refactor(theme): centralize status colors and overlay tints in CSS vars
Introduce semantic theme variables (e.g. --color-success, --color-danger,
--color-warning, --color-info) and a set of overlay/tint/shadow variables
(--app-hover-tint, --app-raise-tint, --app-soft-tint, --app-overlay-shadow,
--app-menu-shadow, --app-float-shadow, etc.) and use them throughout
app.css in place of many hard-coded color, background and shadow values.

This change:
- replaces literal color tokens used for badges, pills, buttons, menus,
  toasts, borders and file icons with the new semantic variables
- switches several box-shadow and overlay usages to the new shadow vars
- harmonizes light-theme surface, border and scrollbar values to explicit
  variables for easier maintenance

No structural or behavioral changes; this is purely a visual/theming
refactor to make future theme adjustments and dark/light parity simpler.
2026-09-18 10:50:32 +02:00

384 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount, untrack } from "svelte";
import { AlertCircle, Check, ChevronDown, ChevronLeft, ChevronRight, ExternalLink, FileText, GitMerge, LoaderCircle, Pencil, X } from "@lucide/svelte";
import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
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 dialogElement: HTMLDivElement;
onMount(() => {
const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
dialogElement.focus();
return () => previousFocus?.focus();
});
function trapFocus(event: KeyboardEvent) {
if (event.key !== "Tab") return;
const focusable = Array.from(dialogElement.querySelectorAll<HTMLElement>('button:not(:disabled), textarea:not(:disabled), [tabindex="0"]')).filter(element => element.getClientRects().length > 0);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (!first) { event.preventDefault(); return; }
if (event.shiftKey && (document.activeElement === first || document.activeElement === dialogElement)) { event.preventDefault(); last.focus(); }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
}
let comparison = $state<HTMLDivElement | null>(null);
let activeConflict = $state(0);
let bothMenu = $state<number | null>(null);
const t = (de: string, en: string) => isGerman ? de : en;
let previewLines = $derived(manualMode ? resolveContent.split("\n") : conflictParts.flatMap(part =>
part.kind === "text" ? part.lines : conflictChoices[part.index] == null
? [`[${t("Konflikt", "Conflict")} ${part.index + 1} ${t("noch offen", "unresolved")}]`]
: buildResolution([part], conflictChoices).split("\n")));
function navigateConflict(direction: number) {
activeConflict = Math.max(0, Math.min(conflictRegionCount - 1, activeConflict + direction));
comparison?.querySelector(`[data-conflict="${activeConflict}"]`)?.scrollIntoView({ block: "nearest" });
}
$effect(() => {
const c = conflict;
const target = conflictTarget;
const parts = conflictParts;
untrack(() => {
activeConflict = 0;
bothMenu = null;
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 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 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;
}
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",
});
}
rows.push({ type: "marker", conflictIndex: part.index });
}
return rows;
}
function setConflictChoice(index: number, choice: ConflictChoice) {
activeConflict = index;
bothMenu = null;
const next = [...conflictChoices];
next[index] = choice;
conflictChoices = next;
}
function setAllConflicts(choice: ConflictChoice) {
bothMenu = null;
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>
<svelte:window onkeydown={(event) => { if (event.key === "Escape") { if (bothMenu !== null) bothMenu = null; else if (!isBusy) onClose(); } }} onclick={() => { bothMenu = null; }} />
{#snippet bothChoices(index: number)}
<div class="both-control">
<button type="button" aria-haspopup="menu" aria-expanded={bothMenu === index} disabled={isBusy} onclick={(event) => { event.stopPropagation(); bothMenu = bothMenu === index ? null : index; }}>
{index === -1 ? t("Beide", "Both") : t("Beide übernehmen", "Accept both")}<ChevronDown size={14} />
</button>
{#if bothMenu === index}
<div class="both-menu" role="menu">
<button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-ot") : setConflictChoice(index, "both-ot")}>{t("Aktuell, dann eingehend", "Current, then incoming")}</button>
<button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-to") : setConflictChoice(index, "both-to")}>{t("Eingehend, dann aktuell", "Incoming, then current")}</button>
</div>
{/if}
</div>
{/snippet}
<div class="dialog-backdrop app-chrome-backdrop conflict-backdrop">
<div class="conflict-workbench" bind:this={dialogElement} onkeydown={trapFocus} role="dialog" aria-modal="true" aria-label={t("Konflikte lösen", "Resolve conflicts")} tabindex="-1">
<header class="workbench-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><GitMerge size={18} /></span><div class="unified-dialog-text"><h2>{t("Konflikte lösen", "Resolve conflicts")}</h2></div>
<div class="header-actions">
{#if conflictTarget}<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={mergeName}><ExternalLink size={18} />{t("Externes Merge-Tool", "External merge tool")}</button>{/if}
<button data-dialog-close class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button>
</div>
</header>
{#if conflictedFiles.length === 0}
<div class="empty-message">{t("Alle Konflikte gelöst. Du kannst den aktuellen Vorgang fortsetzen.", "All conflicts resolved. You can continue the current operation.")}</div>
{:else}
<div class="workbench-body">
<aside class="file-sidebar" aria-label={t("Dateien mit Konflikten", "Conflicted files")}>
<div class="files-heading">{t("Dateien", "Files")} · {conflictedFiles.length}</div>
<div class="file-list">
{#each conflictedFiles as file (file.path)}
{@const prepared = preparedResolutions[file.path] != null}
<button class="file-item" class:selected={conflictTarget === file.path} type="button" onclick={() => onSelectFile(file.path)} disabled={isBusy} title={file.path}>
<FileText size={19} /><span class="file-copy"><strong>{file.path}</strong><small class:ready={prepared}>{prepared ? t("Vorbereitet", "Prepared") : conflictTarget === file.path ? t("In Bearbeitung", "In progress") : t("Offen", "Open")}</small></span>
{#if prepared}<Check class="ready" size={20} />{:else}<span class="open-dot"></span>{/if}
</button>
{/each}
</div>
<div class="file-progress">{preparedCount} {t("von", "of")} {conflictedFiles.length} {t("vorbereitet", "prepared")}</div>
</aside>
<main class="conflict-main">
<header class="file-header">
<h3 title={conflictTarget}>{conflictTarget || t("Datei auswählen", "Select a file")}</h3>
{#if conflictRegionCount > 0 && !manualMode}
<nav aria-label={t("Konfliktnavigation", "Conflict navigation")}><span>{t("Konflikt", "Conflict")} {activeConflict + 1} {t("von", "of")} {conflictRegionCount}</span><button type="button" aria-label={t("Vorheriger Konflikt", "Previous conflict")} disabled={activeConflict === 0} onclick={() => navigateConflict(-1)}><ChevronLeft size={20} /></button><button type="button" aria-label={t("Nächster Konflikt", "Next conflict")} disabled={activeConflict >= conflictRegionCount - 1} onclick={() => navigateConflict(1)}><ChevronRight size={20} /></button></nav>
{/if}
</header>
{#if !conflict}
<div class="empty-message">{t("Wähle eine Datei aus, um ihren Konflikt zu lösen.", "Select a file to resolve its conflict.")}</div>
{:else if conflict.binary}
<div class="binary-content"><p><AlertCircle size={18} />{t("Binärdatei wähle die Version, die erhalten bleiben soll.", "Binary file — choose the version to keep.")}</p>
<div class="binary-options">{#each ["ours", "theirs"] as side}
{@const size = side === "ours" ? conflict.ours_size : conflict.theirs_size}
<button type="button" class:chosen={binarySide === side} disabled={isBusy || size == null} onclick={() => { binarySide = side as "ours" | "theirs"; }}><strong>{side === "ours" ? t("Aktuell (ours)", "Current (ours)") : t("Eingehend (theirs)", "Incoming (theirs)")}</strong><span>{size == null ? t("Auf dieser Seite gelöscht", "Deleted on this side") : formatBytes(size)}</span>{#if binarySide === side}<Check size={20} />{/if}</button>
{/each}</div>
</div>
{:else}
<div class="comparison-toolbar">
{#if manualMode}<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>{t("Zurück zur Auswahl", "Back to guided")}</button>
{:else}<span>{t("Für alle Konflikte", "For all conflicts")}</span><div class="bulk-actions"><button type="button" onclick={() => setAllConflicts("ours")} disabled={isBusy}>{t("Aktuell", "Current")}</button><button type="button" onclick={() => setAllConflicts("theirs")} disabled={isBusy}>{t("Eingehend", "Incoming")}</button>{@render bothChoices(-1)}</div><button class="manual-button" type="button" onclick={enableManualEdit} disabled={isBusy}><Pencil size={16} />{t("Manuell bearbeiten", "Edit manually")}</button>{/if}
</div>
{#if manualMode}
<textarea class="manual-editor" aria-label={t("Konflikt manuell bearbeiten", "Edit conflict manually")} bind:value={resolveContent} spellcheck="false" disabled={isBusy}></textarea>
{:else}
<div class="comparison-scroll" bind:this={comparison}>
<div class="comparison-grid">
<div class="side-heading ours-heading">{t("Aktuell (ours)", "Current (ours)")}</div><div class="side-heading theirs-heading">{t("Eingehend (theirs)", "Incoming (theirs)")}</div>
{#each resolveSplitRows as row, i (i)}
{#if row.type === "marker"}
{@const choice = conflictChoices[row.conflictIndex]}
<div class="conflict-decision" data-conflict={row.conflictIndex}>
<strong>{t("Konflikt", "Conflict")} {row.conflictIndex + 1}{#if choice == null}<span class="pending"> · {t("Offen", "Open")}</span>{/if}</strong>
<button class="accept-ours" class:chosen={choice === "ours"} aria-pressed={choice === "ours"} type="button" disabled={isBusy} onclick={() => setConflictChoice(row.conflictIndex, "ours")}>{t("Aktuell übernehmen", "Accept current")}</button>
<button class="accept-theirs" class:chosen={choice === "theirs"} aria-pressed={choice === "theirs"} type="button" disabled={isBusy} onclick={() => setConflictChoice(row.conflictIndex, "theirs")}>{t("Eingehend übernehmen", "Accept incoming")}</button>
{@render bothChoices(row.conflictIndex)}
{#if choice}<small class="decision-label">{choice === "ours" ? t("Aktuell gewählt", "Current selected") : choice === "theirs" ? t("Eingehend gewählt", "Incoming selected") : choice === "both-ot" ? t("Aktuell → Eingehend", "Current → Incoming") : t("Eingehend → Aktuell", "Incoming → Current")}</small>{/if}
</div>
{:else}
<div class="code-line" class:ours-line={row.leftKind === "ours"} class:dimmed={row.conflictIndex != null && conflictChoices[row.conflictIndex] != null && !oursActive(conflictChoices[row.conflictIndex])}><span>{row.leftNum ?? ""}</span><code>{displayLine(row.leftText ?? "") || " "}</code></div>
<div class="code-line incoming-line" class:theirs-line={row.rightKind === "theirs"} class:dimmed={row.conflictIndex != null && conflictChoices[row.conflictIndex] != null && !theirsActive(conflictChoices[row.conflictIndex])}><span>{row.rightNum ?? ""}</span><code>{displayLine(row.rightText ?? "") || " "}</code></div>
{/if}
{/each}
</div>
</div>
{/if}
<section class="result-preview" aria-label={t("Ergebnisvorschau", "Result preview")}>
<header><strong>{t("Ergebnisvorschau", "Result preview")}</strong><span class:pending={resolveHasMarkers} class:ready={!resolveHasMarkers} aria-live="polite">{manualMode && resolveHasMarkers ? t("Konfliktmarker vorhanden", "Conflict markers remain") : unresolvedCount > 0 ? `${unresolvedCount} ${t(unresolvedCount === 1 ? "Konflikt offen" : "Konflikte offen", unresolvedCount === 1 ? "conflict open" : "conflicts open")}` : t("Alle Konflikte gelöst", "All conflicts resolved")}</span></header>
<div class="preview-code">{#each previewLines as line, i}<div class="code-line"><span>{i + 1}</span><code class:pending={line.startsWith(`[${t("Konflikt", "Conflict")} `)}>{displayLine(line) || " "}</code></div>{/each}</div>
</section>
{/if}
{#if conflict}<div class="file-actions"><span>{!canMarkResolved ? t("Alle Konflikte dieser Datei lösen, dann vorbereiten.", "Resolve all conflicts in this file, then prepare it.") : currentPrepared ? t("Diese Datei ist vorbereitet.", "This file is prepared.") : t("Ergebnis prüfen und Datei vorbereiten.", "Review the result and prepare the file.")}</span><button type="button" disabled={!canMarkResolved} onclick={handleMarkResolved}>{currentPrepared ? t("Vorbereitung aktualisieren", "Update preparation") : t("Datei vorbereiten", "Prepare file")}</button></div>{/if}
</main>
</div>
<footer class="workbench-footer"><span>{t("Änderungen werden erst beim Anwenden gespeichert.", "Changes are saved only when applied.")}</span><button class="apply-button" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>{#if isBusy}<LoaderCircle class="spin" size={16} />{/if}{preparedCount} {t(preparedCount === 1 ? "vorbereitete Lösung anwenden" : "vorbereitete Lösungen anwenden", preparedCount === 1 ? "prepared resolution to apply" : "prepared resolutions to apply")}</button></footer>
{/if}
</div>
</div>
<style>
.conflict-workbench{--ours:var(--color-accent);--theirs:#b78ae6;display:flex;flex-direction:column;width:min(1700px,100%);height:min(960px,100%);min-width:0;overflow:hidden;border:1px solid var(--color-border-input);border-radius:6px;background:var(--app-dialog-bg);color:var(--color-ink);box-shadow:var(--app-dialog-shadow);font-size:13px}
.conflict-workbench button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:30px;padding:5px 10px;border:1px solid var(--color-border-input);border-radius:3px;background:transparent;color:var(--color-ink);font-size:13px;font-weight:400;white-space:nowrap}
.conflict-workbench button:hover:not(:disabled){background:var(--color-surface-hover);border-color:var(--color-ink-faint)}.conflict-workbench button:disabled{opacity:.45;cursor:default}.conflict-workbench button:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}
.workbench-header{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:52px;padding:10px 16px;border-bottom:1px solid var(--color-border);background:var(--app-dialog-chrome)}h2{display:flex;align-items:center;gap:10px;margin:0;font-size:15px;font-weight:600}.header-actions{display:flex;gap:12px;align-items:center}.header-actions .close-button{border:0;padding:6px}
.workbench-body{display:flex;flex:1;min-height:0}.file-sidebar{display:flex;flex-direction:column;flex:0 0 220px;min-width:0;border-right:1px solid var(--color-border);padding:0 10px}.files-heading{padding:14px 10px 12px;font-size:13px;color:var(--color-ink-muted)}.file-list{flex:1;overflow:auto}.file-sidebar .file-item{display:flex;width:100%;gap:12px;padding:10px 10px;border:1px solid transparent;align-items:flex-start;justify-content:flex-start;text-align:left;white-space:normal}.file-sidebar .file-item.selected{background:var(--color-surface);border-left-color:var(--color-accent)}.file-copy{display:grid;gap:5px;flex:1;min-width:0}.file-copy strong{font-weight:400;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.file-copy small{font-size:13px;color:var(--color-warning)}.file-item :global(svg){flex-shrink:0}.open-dot{height:11px;width:11px;background:var(--color-warning);border-radius:50%;margin-top:5px;flex-shrink:0}.ready,.file-copy small.ready,.file-item :global(svg.ready){color:var(--color-success)}.file-progress{border-top:1px solid var(--color-border);padding:12px 10px;color:var(--color-ink-muted)}
.conflict-main{display:flex;flex-direction:column;flex:1;min-width:0;min-height:0}.file-header{display:flex;justify-content:space-between;align-items:center;gap:12px;margin:0 10px;padding:10px 6px;border-bottom:1px solid var(--color-border);min-height:48px}.file-header h3{font-size:14px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin:0}.file-header nav{display:flex;align-items:center;gap:12px;white-space:nowrap}.file-header nav span{margin-right:8px}.file-header nav button{padding:7px 9px}
.comparison-toolbar{display:flex;gap:10px;align-items:center;padding:8px 12px;flex-wrap:wrap}.comparison-toolbar>span{color:var(--color-ink-muted)}.bulk-actions{display:flex}.bulk-actions>button+button{border-left:0}.comparison-toolbar .manual-button{margin-left:auto;border:0;color:var(--ours);padding-right:0}
.comparison-scroll{flex:1.25;min-height:150px;overflow:auto;margin:0 10px;border:1px solid var(--color-border)}.comparison-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);align-content:start;min-width:760px}.side-heading{position:sticky;top:0;z-index:2;background:var(--app-dialog-bg);padding:7px 10px;border-top:2px solid var(--ours);font-size:13px}.theirs-heading{border-color:var(--theirs);border-left:1px solid var(--color-border)}
.code-line{display:flex;min-width:0;line-height:20px;min-height:20px;font-family:var(--font-mono);font-size:13px}.code-line>span{flex:0 0 44px;text-align:right;padding-right:14px;color:var(--color-ink-faint);user-select:none}.code-line code{font:inherit;white-space:pre;overflow-x:auto;min-width:0;flex:1;padding-right:12px}.incoming-line{border-left:1px solid var(--color-border)}.ours-line{background:color-mix(in srgb,var(--ours) 13%,transparent)}.theirs-line{background:color-mix(in srgb,var(--theirs) 13%,transparent)}.dimmed code{opacity:.45}
.conflict-decision{grid-column:1/-1;display:flex;align-items:center;flex-wrap:wrap;gap:10px;padding:7px 10px;border-top:1px solid var(--color-border);border-bottom:1px solid var(--color-border);background:color-mix(in srgb,var(--color-surface) 45%,var(--app-dialog-bg));scroll-margin-top:36px}.conflict-decision strong{font-weight:500;margin-right:12px}.pending{color:var(--color-warning)}.conflict-decision .accept-ours{color:var(--ours);border-color:color-mix(in srgb,var(--ours) 65%,var(--color-border))}.conflict-decision .accept-theirs{color:var(--theirs);border-color:color-mix(in srgb,var(--theirs) 65%,var(--color-border))}.conflict-decision .chosen{background:color-mix(in srgb,var(--ours) 10%,transparent)}.conflict-decision .accept-theirs.chosen{background:color-mix(in srgb,var(--theirs) 10%,transparent)}.decision-label{color:var(--ours);font-size:12px}.both-control{position:relative}.both-menu{position:absolute;right:0;top:100%;z-index:5;min-width:220px;padding:4px;background:var(--app-dialog-chrome);border:1px solid var(--color-border-input);box-shadow:var(--app-panel-shadow)}.both-menu button{width:100%;border:0;text-align:left;justify-content:flex-start}
.result-preview{display:flex;flex:1;min-height:120px;flex-direction:column;margin:10px 10px 0;border:1px solid var(--color-border)}.result-preview header{display:flex;justify-content:space-between;align-items:center;gap:12px;padding:7px 10px;border-bottom:1px solid var(--color-border)}.result-preview strong{font-weight:500;font-size:13px}.preview-code{overflow:auto;flex:1;padding:6px 0}.preview-code code{overflow:visible}.file-actions{display:flex;justify-content:space-between;align-items:center;gap:16px;min-height:48px;padding:8px 12px;border-top:1px solid var(--color-border)}.file-actions>span{color:var(--color-ink-muted)}.file-actions button{background:var(--color-surface)}.workbench-footer{display:flex;align-items:center;justify-content:space-between;gap:20px;padding:10px 16px;border-top:1px solid var(--color-border);min-height:52px}.workbench-footer>span{color:var(--color-ink-muted)}.workbench-footer .apply-button{background:var(--color-primary);border-color:var(--color-primary);color:white;padding:6px 12px;font-weight:500}.workbench-footer .apply-button:hover:not(:disabled){background:var(--color-primary-dark)}
.empty-message{flex:1;padding:32px;color:var(--color-ink-muted)}.manual-editor{flex:1.25;min-height:150px;resize:none;margin:0 10px;padding:14px;font:13px/20px var(--font-mono);background:var(--app-input-bg);color:var(--color-ink);border:1px solid var(--color-border-input)}.binary-content{flex:1;padding:24px}.binary-content p{display:flex;gap:10px;align-items:center;color:var(--color-ink-muted)}.binary-options{display:flex;gap:16px;margin-top:24px}.binary-options button{flex:1;flex-direction:column;gap:12px;padding:24px;white-space:normal}.binary-options button.chosen{border-color:var(--ours);background:color-mix(in srgb,var(--ours) 10%,transparent)}
.conflict-backdrop{display:flex;align-items:center;justify-content:center}
.conflict-workbench{outline:none}.open-dot{border-radius:50%!important}
@media(max-width:1100px){.file-sidebar{flex-basis:210px}.conflict-workbench{font-size:13px}.conflict-workbench button{font-size:12px;padding:7px 10px}.file-copy strong{font-size:13px}.comparison-toolbar{gap:10px;padding:10px}.decision-label{display:none}.file-header{padding:12px 4px}.file-header nav{gap:4px}.code-line{font-size:12px}}
@media(max-width:700px){.conflict-backdrop{padding:8px}.conflict-workbench{width:100%;height:100%}.workbench-header{padding:10px;gap:8px}h2{font-size:13px;gap:8px}.header-actions{gap:6px}.header-actions button{font-size:11px}.workbench-body{flex-direction:column}.file-sidebar{flex:0 0 auto;border-right:0;border-bottom:1px solid var(--color-border)}.files-heading{font-size:13px;padding:8px}.file-list{display:flex;max-height:80px}.file-sidebar .file-item{flex:0 0 180px;min-width:180px;width:180px;padding:8px}.file-copy small{white-space:nowrap;font-size:12px}.file-progress{display:none}.file-header{min-height:48px;padding:6px}.file-header h3{font-size:13px}.file-header nav span{font-size:11px}.comparison-toolbar{padding:6px;gap:6px}.comparison-scroll{min-height:100px}.result-preview{min-height:100px}.file-actions{min-height:48px;padding:6px;gap:8px}.file-actions>span,.workbench-footer>span{font-size:11px}.workbench-footer{min-height:60px;padding:8px;gap:8px}.workbench-footer .apply-button{white-space:normal;padding:8px;font-size:13px}.result-preview header{padding:6px}.result-preview strong{font-size:13px}}
</style>