feat(ui): revamp line patch dialog and add merge progress notice
Revamp the line patch dialog to improve usability and clarity. Improve accessibility and German/English localization for UI text. Add toolbar, hunk navigation, per-line checkbox selection and footer. Extract merge-in-progress UI into a reusable merge progress notice. - Replace selection UI with checkboxes, range selection, and counts - Add hunk navigation, active-hunk tracking, and scroll synchronization - Extract merge-in-progress UI into a reusable notice component
This commit is contained in:
+2
-7
@@ -29,6 +29,7 @@
|
||||
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
|
||||
import HistoryPanel from "./lib/components/HistoryPanel.svelte";
|
||||
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
|
||||
import MergeProgressNotice from "./lib/components/MergeProgressNotice.svelte";
|
||||
import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte";
|
||||
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
|
||||
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
|
||||
@@ -5500,13 +5501,7 @@
|
||||
{/if}
|
||||
|
||||
{#if workspaceActive && mergeInProgress}
|
||||
<section class="notice conflict" role="status">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
<span>{hasConflicts ? "Merge in progress. Resolve all conflicts, then continue." : "Merge is ready to be completed."}</span>
|
||||
{#if hasConflicts}<button type="button" onclick={openResolveDialog} disabled={isBusy}>Resolve conflicts</button>{/if}
|
||||
<button type="button" onclick={continueMerge} disabled={isBusy || hasConflicts}>Continue</button>
|
||||
<button type="button" onclick={abortMerge} disabled={isBusy}>Abort</button>
|
||||
</section>
|
||||
<MergeProgressNotice conflictCount={conflictedFiles.length} {isBusy} language={appLanguage} onResolve={openResolveDialog} onContinue={continueMerge} onAbort={abortMerge} />
|
||||
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
|
||||
<section class="notice conflict" role="alert">
|
||||
<GitMerge size={17} aria-hidden="true" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { Check, ExternalLink, FileDiff, LoaderCircle, MousePointer2, X } from "@lucide/svelte";
|
||||
import { ArrowDown, ArrowUp, Check, ExternalLink, FileDiff, LoaderCircle, Minus, Plus, RefreshCw, Trash2, X } from "@lucide/svelte";
|
||||
import type { GitFileStatus, PatchApplyAction } from "../types";
|
||||
|
||||
type PatchLineKind = "context" | "add" | "delete" | "meta";
|
||||
@@ -60,18 +60,26 @@
|
||||
const isGerman = $derived(language === "de");
|
||||
|
||||
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
|
||||
let patchWidth = $state(0);
|
||||
let patchScroll = $state<HTMLDivElement | null>(null);
|
||||
let selectedLineIds = $state<Set<string>>(new Set());
|
||||
let lastSelectedLineId = $state("");
|
||||
|
||||
let scopeLabel = $derived(staged ? "Staged changes" : "Unstaged changes");
|
||||
const t = (de: string, en: string) => isGerman ? de : en;
|
||||
let scopeLabel = $derived(staged ? t("Gestagte Änderungen", "Staged changes") : t("Nicht gestagte Änderungen", "Unstaged changes"));
|
||||
let activeHunk = $state(0);
|
||||
let hasTextPatch = $derived(!isLoading && !error && !!patch.trim() && !parsed.binary && parsed.hunks.length > 0);
|
||||
function clearSelection() { selectedLineIds = new Set(); lastSelectedLineId = ""; }
|
||||
let displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
|
||||
let selectableLines = $derived(
|
||||
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")),
|
||||
);
|
||||
let addedCount = $derived(selectableLines.filter(line => line.kind === "add").length);
|
||||
let deletedCount = $derived(selectableLines.filter(line => line.kind === "delete").length);
|
||||
let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
|
||||
|
||||
$effect(() => {
|
||||
activeHunk = 0;
|
||||
parsed = parsePatch(patch);
|
||||
selectedLineIds = new Set();
|
||||
lastSelectedLineId = "";
|
||||
@@ -191,6 +199,7 @@
|
||||
}
|
||||
|
||||
function toggleHunkSelection(hunk: PatchHunk) {
|
||||
if (isBusy || isLoading) return;
|
||||
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete");
|
||||
const allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
|
||||
const next = new Set(selectedLineIds);
|
||||
@@ -266,149 +275,105 @@
|
||||
|
||||
function scrollToHunk(hunkId: string) {
|
||||
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`);
|
||||
if (patchScroll && target) patchScroll.scrollTop = Math.max(target.offsetTop - 8, 0);
|
||||
if (patchScroll && target) {
|
||||
patchScroll.scrollTop += target.getBoundingClientRect().top - patchScroll.getBoundingClientRect().top;
|
||||
activeHunk = parsed.hunks.findIndex(hunk => hunk.id === hunkId);
|
||||
}
|
||||
}
|
||||
|
||||
function updateActiveHunk() {
|
||||
if (!patchScroll) return;
|
||||
if (patchScroll.scrollTop > 0 && patchScroll.scrollTop + patchScroll.clientHeight >= patchScroll.scrollHeight - 2) {
|
||||
activeHunk = Math.max(0, parsed.hunks.length - 1);
|
||||
return;
|
||||
}
|
||||
const top = patchScroll.getBoundingClientRect().top;
|
||||
const sections = Array.from(patchScroll.querySelectorAll<HTMLElement>("[data-hunk-id]"));
|
||||
let index = 0;
|
||||
sections.forEach((section, i) => { if (section.getBoundingClientRect().top <= top + 2) index = i; });
|
||||
activeHunk = index;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch">
|
||||
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
|
||||
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
|
||||
<header class="dialog-header">
|
||||
<div>
|
||||
<span class="eyebrow">{scopeLabel}</span>
|
||||
<p class="dialog-title" title={displayPath}>{displayPath}</p>
|
||||
</div>
|
||||
<div class="dialog-header-actions line-patch-header-actions">
|
||||
<div class="tool-surface-choice" aria-label={isGerman ? "Diff öffnen mit" : "Open diff with"}>
|
||||
<span>{isGerman ? "Öffnen mit" : "Open with"}</span>
|
||||
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty anzeigen" : "Show in Gitty"}>
|
||||
<FileDiff size={13} aria-hidden="true" />Gitty
|
||||
</button>
|
||||
<button type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={isGerman ? `In ${diffName} öffnen` : `Open in ${diffName}`}>
|
||||
<ExternalLink size={13} aria-hidden="true" /><span>{diffName}</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading}>Refresh</button>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
<div class="patch-identity"><FileDiff size={23} aria-hidden="true" /><div><p class="dialog-title" title={displayPath}>{displayPath}</p><span class="patch-scope">{scopeLabel}</span></div></div>
|
||||
<div class="dialog-header-actions">
|
||||
<button class="external-diff" type="button" onclick={onExternalDiff} disabled={isBusy || isLoading} title={t(`In ${diffName} öffnen`, `Open in ${diffName}`)}><span>{t("Extern öffnen", "Open externally")}</span><ExternalLink size={14} /><span>·</span><span>{diffName}</span></button>
|
||||
<button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
|
||||
<button class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="line-patch-body">
|
||||
{#if isLoading}
|
||||
<div class="blank-state">
|
||||
<LoaderCircle class="spin" size={18} aria-hidden="true" />
|
||||
Loading patch...
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="blank-state">{error}</div>
|
||||
{:else if !patch.trim()}
|
||||
<div class="blank-state">No line patch available for this file.</div>
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}
|
||||
<div class="blank-state">This change cannot be split into text lines.</div>
|
||||
<div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
|
||||
{:else if error}<div class="blank-state" role="alert">{error}</div>
|
||||
{:else if !patch.trim()}<div class="blank-state">{t("Keine Änderungen für diese Datei vorhanden.", "No changes available for this file.")}</div>
|
||||
{:else if parsed.binary || parsed.hunks.length === 0}<div class="blank-state">{t("Diese Änderung lässt sich nicht in Textzeilen aufteilen.", "This change cannot be split into text lines.")}</div>
|
||||
{:else}
|
||||
<div class:active={selectedCount > 0} class="line-patch-selection-bar">
|
||||
<div>
|
||||
<MousePointer2 size={14} aria-hidden="true" />
|
||||
{#if selectedCount > 0}
|
||||
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong>
|
||||
<span>Shift-click to select a range.</span>
|
||||
{:else}
|
||||
<strong>Select changed lines</strong>
|
||||
<span>Choose individual additions or deletions below.</span>
|
||||
{/if}
|
||||
<div class="patch-toolbar">
|
||||
<strong>{t("Geänderte Zeilen auswählen", "Select changed lines")}</strong><span class="range-hint">{t("Shift-Klick für einen Bereich", "Shift-click to select a range")}</span>
|
||||
<div class="patch-summary"><span class="add-count">+{addedCount}</span><span class="delete-count">−{deletedCount}</span><span>{parsed.hunks.length} {t(parsed.hunks.length === 1 ? "Abschnitt" : "Abschnitte", parsed.hunks.length === 1 ? "hunk" : "hunks")}</span>
|
||||
<button class="icon-action" type="button" onclick={() => scrollToHunk(parsed.hunks[activeHunk - 1].id)} disabled={activeHunk === 0} aria-label={t("Vorheriger Abschnitt", "Previous hunk")} title={t("Vorheriger Abschnitt", "Previous hunk")}><ArrowUp size={16} /></button>
|
||||
<button class="icon-action" type="button" onclick={() => scrollToHunk(parsed.hunks[activeHunk + 1].id)} disabled={activeHunk >= parsed.hunks.length - 1} aria-label={t("Nächster Abschnitt", "Next hunk")} title={t("Nächster Abschnitt", "Next hunk")}><ArrowDown size={16} /></button>
|
||||
</div>
|
||||
{#if selectedCount > 0}
|
||||
<div class="line-patch-selected-actions">
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy}>
|
||||
Discard selected
|
||||
</button>
|
||||
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy}>
|
||||
{staged ? "Unstage selected" : "Stage selected"}
|
||||
</button>
|
||||
<button class="btn-sm" type="button" onclick={() => { selectedLineIds = new Set(); }} disabled={isBusy}>Clear</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="line-patch-workspace">
|
||||
<div class="line-patch-scroll" bind:this={patchScroll}>
|
||||
{#each parsed.hunks as hunk (hunk.id)}
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
|
||||
<div class="line-patch-workspace" style={`--patch-visible-width: ${patchWidth}px`}>
|
||||
<div class="line-patch-scroll" bind:this={patchScroll} bind:clientWidth={patchWidth} onscroll={updateActiveHunk}>
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||
<section class="line-patch-hunk" data-hunk-id={hunk.id} aria-label={`${t("Abschnitt", "Hunk")} ${index + 1}`}>
|
||||
<div class="line-patch-hunk-head">
|
||||
<button
|
||||
class:all={hunkSelectionState(hunk) === "all"}
|
||||
class:some={hunkSelectionState(hunk) === "some"}
|
||||
class="line-patch-select-hunk"
|
||||
type="button"
|
||||
onclick={() => toggleHunkSelection(hunk)}
|
||||
aria-label={`Select changed lines in ${hunk.header}`}
|
||||
aria-pressed={hunkSelectionState(hunk) === "all"}
|
||||
title="Select all changed lines in this hunk"
|
||||
>
|
||||
{#if hunkSelectionState(hunk) !== "none"}<Check size={12} aria-hidden="true" />{/if}
|
||||
<button class:all={hunkSelectionState(hunk) === "all"} class:some={hunkSelectionState(hunk) === "some"} class="line-patch-select-hunk" type="button" role="checkbox" aria-checked={hunkSelectionState(hunk) === "some" ? "mixed" : hunkSelectionState(hunk) === "all"} onclick={() => toggleHunkSelection(hunk)} disabled={isBusy || isLoading} aria-label={`${t("Abschnitt auswählen", "Select hunk")} ${index + 1}`} title={t("Alle geänderten Zeilen dieses Abschnitts auswählen", "Select all changed lines in this hunk")}>
|
||||
{#if hunkSelectionState(hunk) === "all"}<Check size={11} />{:else if hunkSelectionState(hunk) === "some"}<Minus size={11} />{/if}
|
||||
</button>
|
||||
<code>{hunk.header}</code>
|
||||
<strong>{t("Abschnitt", "Hunk")} {index + 1}</strong><code title={hunk.header}>{hunk.header}</code>
|
||||
<div class="line-patch-hunk-actions">
|
||||
{#if staged}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button unstage" type="button" onclick={() => applyHunkAction("unstage", hunk)} disabled={isBusy}>
|
||||
Unstage Hunk
|
||||
</button>
|
||||
{:else}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction("discard-unstaged", hunk)} disabled={isBusy}>
|
||||
Discard Hunk
|
||||
</button>
|
||||
<button class="line-patch-hunk-button stage" type="button" onclick={() => applyHunkAction("stage", hunk)} disabled={isBusy}>
|
||||
Stage Hunk
|
||||
</button>
|
||||
{/if}
|
||||
<button class="line-patch-hunk-button discard" type="button" onclick={() => applyHunkAction(staged ? "discard-staged" : "discard-unstaged", hunk)} disabled={isBusy}><Trash2 size={14} />{t("Verwerfen", "Discard")}</button>
|
||||
<button class="line-patch-hunk-button {staged ? "unstage" : "stage"}" type="button" onclick={() => applyHunkAction(staged ? "unstage" : "stage", hunk)} disabled={isBusy}>{#if staged}<Minus size={14} />{:else}<Plus size={14} />{/if}{staged ? t("Abschnitt unstagen", "Unstage hunk") : t("Abschnitt stagen", "Stage hunk")}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="line-patch-lines">
|
||||
{#each hunk.lines as line (line.id)}
|
||||
<div class:selected={selectedLineIds.has(line.id)} class:selectable={line.kind === "add" || line.kind === "delete"} class={`line-patch-row ${line.kind}`}>
|
||||
{#if line.kind === "add" || line.kind === "delete"}
|
||||
<button
|
||||
class="line-patch-line-select"
|
||||
type="button"
|
||||
onclick={(event) => toggleLine(event, line)}
|
||||
aria-label={`${selectedLineIds.has(line.id) ? "Deselect" : "Select"} ${line.kind === "add" ? "added" : "deleted"} line ${line.newLine ?? line.oldLine ?? ""}`}
|
||||
aria-pressed={selectedLineIds.has(line.id)}
|
||||
title="Select line (Shift-click for range)"
|
||||
>
|
||||
{#if selectedLineIds.has(line.id)}<Check size={11} aria-hidden="true" />{/if}
|
||||
<button class="line-patch-line-select" type="button" role="checkbox" aria-checked={selectedLineIds.has(line.id)} onclick={(event) => toggleLine(event, line)} disabled={isBusy || isLoading} aria-label={`${line.kind === "add" ? t("Hinzugefügte Zeile", "Added line") : t("Entfernte Zeile", "Deleted line")} ${line.newLine ?? line.oldLine ?? ""}`} title={t("Zeile auswählen (Shift-Klick für Bereich)", "Select line (Shift-click for range)")}>
|
||||
{#if selectedLineIds.has(line.id)}<Check size={11} />{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="line-patch-line-select-placeholder"></span>
|
||||
{/if}
|
||||
<span class="line-patch-line-number">{line.oldLine ?? ""}</span>
|
||||
<span class="line-patch-line-number">{line.newLine ?? ""}</span>
|
||||
<span class="line-patch-prefix">{linePrefix(line)}</span>
|
||||
<code>{lineBody(line)}</code>
|
||||
{:else}<span class="line-patch-line-select-placeholder"></span>{/if}
|
||||
<span class="line-patch-line-number">{line.oldLine ?? ""}</span><span class="line-patch-line-number">{line.newLine ?? ""}</span><span class="line-patch-prefix">{linePrefix(line)}</span><code>{lineBody(line)}</code>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/each}
|
||||
</div>
|
||||
<nav class="diff-overview line-patch-overview" aria-label="Change overview">
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}
|
||||
<button
|
||||
class="diff-overview-marker {hunkKind(hunk)}"
|
||||
type="button"
|
||||
style={`--marker-position: ${hunkPosition(index)}%`}
|
||||
onclick={() => scrollToHunk(hunk.id)}
|
||||
title={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||
aria-label={`Jump to hunk ${index + 1} of ${parsed.hunks.length}`}
|
||||
></button>
|
||||
{/each}
|
||||
<nav class="diff-overview line-patch-overview" aria-label={t("Änderungsübersicht", "Change overview")}>
|
||||
{#each parsed.hunks as hunk, index (hunk.id)}<button class="diff-overview-marker {hunkKind(hunk)}" type="button" style={`--marker-position: ${hunkPosition(index)}%`} onclick={() => scrollToHunk(hunk.id)} title={`${t("Zu Abschnitt", "Jump to hunk")} ${index + 1}`} aria-label={`${t("Zu Abschnitt", "Jump to hunk")} ${index + 1}`}></button>{/each}
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasTextPatch}
|
||||
<footer class="patch-footer">
|
||||
<div class="selection-summary"><span class="selection-symbol" class:has-selection={selectedCount > 0}><Check size={13} /></span><strong aria-live="polite">{selectedCount} {t(selectedCount === 1 ? "Zeile ausgewählt" : "Zeilen ausgewählt", selectedCount === 1 ? "line selected" : "lines selected")}</strong><button class="clear-selection" type="button" onclick={clearSelection} disabled={isBusy || selectedCount === 0}>{t("Auswahl aufheben", "Clear selection")}</button></div>
|
||||
<div class="selection-actions"><button class="discard-selection" type="button" onclick={() => applySelected(staged ? "discard-staged" : "discard-unstaged")} disabled={isBusy || selectedCount === 0}>{t("Auswahl verwerfen", "Discard selected")}</button><button class="stage-selection" type="button" onclick={() => applySelected(staged ? "unstage" : "stage")} disabled={isBusy || selectedCount === 0}>{selectedCount} {t(selectedCount === 1 ? "Zeile" : "Zeilen", selectedCount === 1 ? "line" : "lines")} {staged ? t("unstagen", "to unstage") : t("stagen", "to stage")}</button></div>
|
||||
</footer>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.line-patch-dialog{width:min(1700px,100%);height:min(960px,100%);grid-template-rows:auto minmax(0,1fr) auto;font-size:13px}
|
||||
.dialog-header{padding:12px 18px}.patch-identity{display:flex;align-items:center;gap:12px;min-width:0}.patch-identity>div{min-width:0}.patch-identity :global(svg){flex:none;color:var(--color-ink-muted)}.patch-identity .dialog-title{font-size:15px;line-height:1.4;margin:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.patch-scope{display:block;color:var(--color-ink-muted);font-size:12px;margin-top:2px}
|
||||
.dialog-header-actions{gap:8px}.dialog-header-actions .external-diff{display:flex;align-items:center;gap:8px;border:0;background:transparent;font-size:12px;color:var(--color-ink-muted);padding:5px 10px}.line-patch-dialog .icon-action{display:inline-flex;align-items:center;justify-content:center;flex:none;width:30px;min-width:30px;height:30px;min-height:30px;padding:0;border:1px solid var(--color-border);background:transparent;color:var(--color-ink-muted)}
|
||||
.patch-toolbar{display:flex;align-items:center;gap:20px;padding:8px 18px;min-height:46px;border-bottom:1px solid var(--color-border-subtle);background:var(--app-dialog-bg)}.patch-toolbar strong{font-size:12px;font-weight:600}.range-hint{color:var(--color-ink-faint);font-size:12px}.patch-summary{display:flex;align-items:center;gap:10px;margin-left:auto;color:var(--color-ink-muted);font-size:12px;white-space:nowrap}.patch-summary .add-count{color:var(--code-add-text)}.patch-summary .delete-count{color:var(--code-delete-text);margin-right:10px}
|
||||
.line-patch-scroll{position:relative}.line-patch-hunk{min-width:100%;width:max-content}.line-patch-hunk-head{width:var(--patch-visible-width, 100%);min-width:0;max-width:100%;height:44px;gap:14px;padding:6px 14px;background:var(--app-dialog-chrome)}.line-patch-hunk-head>strong{font-size:12px;font-weight:600;white-space:nowrap}.line-patch-hunk-head code{font-size:12px;color:var(--color-ink-faint);overflow:hidden;text-overflow:ellipsis;min-width:0}.line-patch-hunk-actions{margin-left:auto;padding-left:10px;gap:8px}.line-patch-hunk-button{display:inline-flex;align-items:center;gap:6px;min-height:28px;padding:4px 9px;font-size:12px;font-weight:500;background:transparent}.line-patch-hunk-button.discard{border-color:transparent}.line-patch-hunk-button.discard:hover:not(:disabled){border-color:transparent}
|
||||
.line-patch-dialog .line-patch-select-hunk,.line-patch-dialog .line-patch-line-select{display:flex;align-items:center;justify-content:center;width:14px;min-width:14px;max-width:14px;height:14px;min-height:14px;max-height:14px;padding:0;line-height:1;border:1px solid var(--color-ink-faint);background:transparent;box-shadow:none;align-self:center;border-radius:2px!important;color:var(--app-bg);flex:none}
|
||||
.line-patch-dialog .line-patch-select-hunk.all,.line-patch-dialog .line-patch-select-hunk.some,.line-patch-dialog .line-patch-line-select[aria-checked="true"]{background:var(--color-accent);border-color:var(--color-accent)}
|
||||
.line-patch-lines{font-size:13px}.line-patch-row{grid-template-columns:28px 44px 44px 24px minmax(max-content,1fr);align-items:center;height:22px;min-height:22px;padding:0 14px;line-height:22px}.line-patch-row code{font-size:13px;line-height:22px}.line-patch-line-number{font-size:11px;line-height:22px}.line-patch-row.selected{box-shadow:inset 3px 0 0 var(--color-accent);filter:none}.line-patch-row.add.selected{background:color-mix(in srgb,var(--color-accent) 5%,var(--code-add-bg))}.line-patch-row.delete.selected{background:color-mix(in srgb,var(--color-accent) 5%,var(--code-delete-bg))}
|
||||
.patch-footer{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:60px;padding:10px 18px;border-top:1px solid var(--color-border);background:var(--app-dialog-chrome)}.selection-summary,.selection-actions{display:flex;align-items:center;gap:12px}.selection-summary strong{font-size:12px;font-weight:600;white-space:nowrap}.selection-symbol{display:flex;width:16px;height:16px;align-items:center;justify-content:center;border:1px solid var(--color-border-input);color:var(--color-ink-faint)}.selection-symbol.has-selection{background:var(--color-accent);color:var(--app-bg);border-color:var(--color-accent)}.patch-footer button{min-height:30px;padding:5px 12px;font-size:12px;white-space:nowrap}.patch-footer .clear-selection{border:0;background:transparent;color:var(--color-ink-muted)}.patch-footer .discard-selection{border:1px solid color-mix(in srgb,var(--code-delete-text) 60%,var(--color-border));color:var(--code-delete-text);background:transparent}.patch-footer .stage-selection{background:var(--color-primary);border-color:var(--color-primary);color:white}.patch-footer .stage-selection:hover:not(:disabled){background:var(--color-primary-dark)}
|
||||
@media(max-width:850px){.range-hint{display:none}.patch-toolbar{gap:10px}.patch-footer{flex-wrap:wrap}.selection-actions{margin-left:auto}.line-patch-hunk-head{gap:8px}.line-patch-hunk-head code{max-width:180px}.dialog-header-actions .external-diff{max-width:180px;overflow:hidden}}
|
||||
@media(max-width:560px){.dialog-header{padding:10px}.patch-identity{gap:8px}.patch-identity .dialog-title{font-size:13px}.external-diff span{display:none}.dialog-header-actions .external-diff{width:30px;min-width:30px;height:30px;padding:0;justify-content:center;overflow:visible}.patch-toolbar{flex-wrap:wrap;padding:8px}.patch-summary{margin-left:0}.patch-footer{padding:8px}.selection-summary{gap:8px}.selection-actions{width:100%;justify-content:flex-end}.line-patch-hunk-head{height:auto;min-height:44px;flex-wrap:wrap}.line-patch-hunk-actions{width:100%;justify-content:flex-end}.line-patch-hunk-head code{max-width:160px}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import { Check, ChevronRight, GitMerge, X } from "@lucide/svelte";
|
||||
let { conflictCount, isBusy, language, onResolve, onContinue, onAbort }: {
|
||||
conflictCount: number; isBusy: boolean; language: "de" | "en";
|
||||
onResolve: () => void; onContinue: () => void; onAbort: () => void;
|
||||
} = $props();
|
||||
const de = $derived(language === "de");
|
||||
const hasConflicts = $derived(conflictCount > 0);
|
||||
</script>
|
||||
|
||||
<section class="merge-progress" class:ready={!hasConflicts} role="status">
|
||||
<span class="status-icon">{#if hasConflicts}<GitMerge size={18} />{:else}<Check size={18} />{/if}</span>
|
||||
<div class="status-copy">
|
||||
<strong>{hasConflicts ? (de ? "Merge läuft" : "Merge in progress") : (de ? "Bereit zum Abschließen" : "Ready to complete")}</strong>
|
||||
<span>{hasConflicts ? `${conflictCount} ${de ? (conflictCount === 1 ? "Datei mit Konflikten" : "Dateien mit Konflikten") : (conflictCount === 1 ? "file with conflicts" : "files with conflicts")}` : (de ? "Alle Konflikte sind gelöst." : "All conflicts are resolved.")}</span>
|
||||
</div>
|
||||
<div class="merge-actions">
|
||||
{#if hasConflicts}<button class="resolve-action" type="button" onclick={onResolve} disabled={isBusy}><GitMerge size={14} />{de ? "Konflikte lösen" : "Resolve conflicts"}</button>{/if}
|
||||
<button class="continue-action" type="button" onclick={onContinue} disabled={isBusy || hasConflicts} title={hasConflicts ? (de ? "Zuerst alle Konflikte lösen" : "Resolve all conflicts first") : undefined}>{de ? "Fortsetzen" : "Continue"}<ChevronRight size={14} /></button>
|
||||
<span class="action-divider" aria-hidden="true"></span>
|
||||
<button class="abort-action" type="button" onclick={onAbort} disabled={isBusy}><X size={14} />{de ? "Abbrechen" : "Abort"}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.merge-progress{--status-color:var(--color-sync-ahead);display:flex;align-items:center;gap:12px;flex-shrink:0;min-height:50px;padding:8px 14px;border-bottom:1px solid var(--color-border);border-left:3px solid var(--status-color);background:color-mix(in srgb,var(--status-color) 5%,var(--app-bg));color:var(--color-ink);font-size:13px}
|
||||
.merge-progress.ready{--status-color:#68c878}.status-icon{display:flex;align-items:center;color:var(--status-color)}.status-copy{display:flex;align-items:center;gap:14px;min-width:0;flex-wrap:wrap}.status-copy strong{font-size:13px;font-weight:600;white-space:nowrap}.status-copy>span{color:var(--color-ink-muted);font-size:12px}
|
||||
.merge-actions{display:flex;align-items:center;gap:8px;margin-left:auto;flex-shrink:0}.merge-actions button{display:inline-flex;align-items:center;justify-content:center;gap:7px;min-height:30px;padding:5px 10px;border:1px solid var(--color-border-input);background:transparent;color:var(--color-ink-muted);font-size:12px;font-weight:500;white-space:nowrap}.merge-actions button:hover:not(:disabled){background:var(--color-surface-hover);color:var(--color-ink)}.merge-actions button:disabled{opacity:.4}.merge-actions .resolve-action{border-color:color-mix(in srgb,var(--status-color) 45%,var(--color-border));background:color-mix(in srgb,var(--status-color) 10%,transparent);color:var(--status-color)}.ready .continue-action{color:var(--status-color);border-color:color-mix(in srgb,var(--status-color) 45%,var(--color-border));background:color-mix(in srgb,var(--status-color) 10%,transparent)}.action-divider{height:20px;width:1px;background:var(--color-border);margin:0 3px}.merge-actions .abort-action{border-color:transparent}.merge-actions .abort-action:hover:not(:disabled){color:#ef8080;background:color-mix(in srgb,#ef8080 8%,transparent)}
|
||||
@media(max-width:700px){.merge-progress{flex-wrap:wrap;gap:8px}.status-copy{gap:8px}.merge-actions{width:100%;justify-content:flex-end}.merge-actions button{font-size:11px;padding:5px 8px}}
|
||||
</style>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from "svelte";
|
||||
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/svelte";
|
||||
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 ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
|
||||
type ResolveSplitRow =
|
||||
| { type: "marker"; conflictIndex: number }
|
||||
| {
|
||||
@@ -53,9 +52,33 @@
|
||||
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;
|
||||
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;
|
||||
@@ -63,6 +86,8 @@
|
||||
const parts = conflictParts;
|
||||
|
||||
untrack(() => {
|
||||
activeConflict = 0;
|
||||
bothMenu = null;
|
||||
if (!c) {
|
||||
conflictChoices = [];
|
||||
resolveContent = "";
|
||||
@@ -95,7 +120,6 @@
|
||||
});
|
||||
|
||||
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));
|
||||
@@ -147,10 +171,6 @@
|
||||
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;
|
||||
@@ -174,7 +194,6 @@
|
||||
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;
|
||||
@@ -192,36 +211,22 @@
|
||||
rightKind: hasTheirs ? "theirs" : "empty",
|
||||
});
|
||||
}
|
||||
rows.push({ type: "marker", conflictIndex: part.index });
|
||||
}
|
||||
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -258,266 +263,121 @@
|
||||
}
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
{/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">
|
||||
<h2><GitMerge size={24} />{t("Konflikte lösen", "Resolve conflicts")}</h2>
|
||||
<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 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="blank-state">All conflicts resolved. Continue the current operation when ready.</div>
|
||||
<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="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"}
|
||||
<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="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 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="resolve-textarea"
|
||||
bind:value={resolveContent}
|
||||
spellcheck="false"
|
||||
disabled={isBusy}
|
||||
></textarea>
|
||||
<textarea class="manual-editor" aria-label={t("Konflikt manuell bearbeiten", "Edit conflict manually")} 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 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>
|
||||
{/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>
|
||||
{: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}
|
||||
{: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}
|
||||
{/each}
|
||||
</div>
|
||||
</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>
|
||||
<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}
|
||||
</div>
|
||||
{#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="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>
|
||||
<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:#eda33d}.file-item :global(svg){flex-shrink:0}.open-dot{height:11px;width:11px;background:#eda33d;border-radius:50%;margin-top:5px;flex-shrink:0}.ready,.file-copy small.ready,.file-item :global(svg.ready){color:#68c878}.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:#eda33d}.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>
|
||||
|
||||
Reference in New Issue
Block a user