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.
384 lines
29 KiB
Svelte
384 lines
29 KiB
Svelte
<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>
|