Compare commits

...
4 Commits
Author SHA1 Message Date
Christoph e7d6a6e4f4 feat(ui): revamp line patch dialog and add merge progress notice
publish / Build and publish Ubuntu AppImage (release) Successful in 8m58s
publish / Build and publish Windows installer (release) Successful in 10m28s
publish / Build and publish AUR packages (release) Successful in 18m56s
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
2026-09-08 08:32:58 +02:00
Christoph 5aa9d291b6 refactor(ui): centralize sync colors into CSS variables
Introduce --color-sync-ahead and --color-sync-behind and update sync
indicators to use those variables. Replace hardcoded sync colors with
var() and color-mix() to unify backgrounds, shadows, and graph accents
across themes, and add light-theme overrides to preserve contrast.

- Add theme-level variables for ahead/behind sync colors
- Replace literal color values with var() and color-mix() usages
- Update dashboard styles to reference the new sync variables
2026-09-08 07:45:20 +02:00
Christoph 82a5f16a0c feat(dashboard): add list and card views with persistent toggle
Add a view switcher to the repository dashboard so users can
toggle between compact list and tile (cards) layouts. The
choice is saved to localStorage so preferences persist
across sessions. Also adjust card layout, sync/branch indicators and
accessibility attributes, and refactor pullRequestTitle to accept
badge state directly for more accurate aria labels.

- Persist viewMode with STORAGE_KEY and add setViewMode helper.
- Add view toggle UI with ARIA states and new icons.
- Introduce list/table and tiles CSS and refine status display.
2026-09-07 22:58:36 +02:00
Christoph daa3298c44 Update version to 2026.9.2 2026-09-07 22:09:14 +02:00
9 changed files with 368 additions and 438 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.9.1", "version": "2026.9.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gitty", "name": "gitty",
"version": "2026.9.1", "version": "2026.9.2",
"dependencies": { "dependencies": {
"@lucide/svelte": "^1.21.0", "@lucide/svelte": "^1.21.0",
"@tailwindcss/vite": "^4.3.1", "@tailwindcss/vite": "^4.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gitty", "name": "gitty",
"version": "2026.9.1", "version": "2026.9.2",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Gitty", "productName": "Gitty",
"version": "2026.9.1", "version": "2026.9.2",
"identifier": "com.gitty", "identifier": "com.gitty",
"build": { "build": {
"beforeDevCommand": "npm run prepare:lfs && npm run dev", "beforeDevCommand": "npm run prepare:lfs && npm run dev",
+2 -7
View File
@@ -29,6 +29,7 @@
import ExplorerPanel from "./lib/components/ExplorerPanel.svelte"; import ExplorerPanel from "./lib/components/ExplorerPanel.svelte";
import HistoryPanel from "./lib/components/HistoryPanel.svelte"; import HistoryPanel from "./lib/components/HistoryPanel.svelte";
import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte"; import InitRepositoryDialog from "./lib/components/InitRepositoryDialog.svelte";
import MergeProgressNotice from "./lib/components/MergeProgressNotice.svelte";
import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte"; import MergeBranchDialog from "./lib/components/MergeBranchDialog.svelte";
import NewBranchDialog from "./lib/components/NewBranchDialog.svelte"; import NewBranchDialog from "./lib/components/NewBranchDialog.svelte";
import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte"; import RenameBranchDialog from "./lib/components/RenameBranchDialog.svelte";
@@ -5500,13 +5501,7 @@
{/if} {/if}
{#if workspaceActive && mergeInProgress} {#if workspaceActive && mergeInProgress}
<section class="notice conflict" role="status"> <MergeProgressNotice conflictCount={conflictedFiles.length} {isBusy} language={appLanguage} onResolve={openResolveDialog} onContinue={continueMerge} onAbort={abortMerge} />
<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>
{:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress} {:else if workspaceActive && hasConflicts && !rebaseInProgress && !cherryPickInProgress}
<section class="notice conflict" role="alert"> <section class="notice conflict" role="alert">
<GitMerge size={17} aria-hidden="true" /> <GitMerge size={17} aria-hidden="true" />
+30 -21
View File
@@ -30,6 +30,8 @@
} }
:root { :root {
--color-sync-ahead: #e0a040;
--color-sync-behind: #7aacff;
--app-color-scheme: dark; --app-color-scheme: dark;
--app-bg: #222328; --app-bg: #222328;
--app-button-bg: #32363d; --app-button-bg: #32363d;
@@ -743,8 +745,8 @@
.tb-sync-group { display: inline-flex; align-items: center; gap: 4px; min-width: max-content; } .tb-sync-group { display: inline-flex; align-items: center; gap: 4px; min-width: max-content; }
.tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; } .tb-sync { display: inline-flex; align-items: center; padding: 1px 6px; border-radius: 999px; font-size: 11px; font-weight: 800; white-space: nowrap; flex-shrink: 0; }
.tb-sync.ahead { color: #e0a040; background: rgba(224,160,64,0.13); } .tb-sync.ahead { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
.tb-sync.behind { color: #7aacff; background: rgba(122,172,255,0.13); } .tb-sync.behind { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
.tb-no-repo { color: var(--color-bar-muted); font-size: 12px; font-style: italic; } .tb-no-repo { color: var(--color-bar-muted); font-size: 12px; font-style: italic; }
@@ -842,8 +844,8 @@
font-size: 9.5px; font-size: 9.5px;
font-weight: 850; font-weight: 850;
} }
.repo-action-count.behind { color: #7aacff; } .repo-action-count.behind { color: var(--color-sync-behind); }
.repo-action-count.ahead { color: #e0a040; } .repo-action-count.ahead { color: var(--color-sync-ahead); }
.repo-action.sync-primary.publish-local { .repo-action.sync-primary.publish-local {
color: #f0bd6b; color: #f0bd6b;
background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045)); background: linear-gradient(180deg, rgba(224,160,64,.1), rgba(224,160,64,.045));
@@ -1305,8 +1307,8 @@
font-style: normal; font-style: normal;
font-weight: 800; font-weight: 800;
} }
.repo-row-meta .ahead { color: #e0a040; background: rgba(224,160,64,0.13); } .repo-row-meta .ahead { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
.repo-row-meta .behind { color: #7aacff; background: rgba(122,172,255,0.13); } .repo-row-meta .behind { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
.repo-row-icon { .repo-row-icon {
min-height: 36px; min-height: 36px;
min-width: 36px; min-width: 36px;
@@ -1823,8 +1825,8 @@
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
} }
.sync-stats strong:first-of-type { color: #e0a040; background: rgba(224,160,64,0.13); } .sync-stats strong:first-of-type { color: var(--color-sync-ahead); background: color-mix(in srgb, var(--color-sync-ahead) 13%, transparent); }
.sync-stats strong:last-of-type { color: #7aacff; background: rgba(122,172,255,0.13); } .sync-stats strong:last-of-type { color: var(--color-sync-behind); background: color-mix(in srgb, var(--color-sync-behind) 13%, transparent); }
.sync-stats span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); } .sync-stats span { color: var(--color-ink-dim); background: rgba(94,110,156,0.13); }
.sync-stats .sync-local-only { .sync-stats .sync-local-only {
display: inline-flex; display: inline-flex;
@@ -3362,12 +3364,13 @@
opacity: 0.08; opacity: 0.08;
} }
.graph-svg path.graph-segment-ahead { .graph-svg path.graph-segment-ahead {
stroke: #e0a040; stroke: var(--color-sync-ahead);
filter: drop-shadow(0 0 3px rgba(224,160,64,0.24)); filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-ahead) 24%, transparent));
} }
.graph-svg path.graph-segment-behind { .graph-svg path.graph-segment-behind {
stroke: var(--color-sync-behind);
stroke-dasharray: 4 4; stroke-dasharray: 4 4;
filter: drop-shadow(0 0 3px rgba(122,172,255,0.2)); filter: drop-shadow(0 0 3px color-mix(in srgb, var(--color-sync-behind) 20%, transparent));
} }
.graph-svg path.graph-ref-connector { .graph-svg path.graph-ref-connector {
opacity: 0.35; opacity: 0.35;
@@ -3391,12 +3394,12 @@
box-shadow: none; box-shadow: none;
} }
.graph-dot.ahead { .graph-dot.ahead {
background: #e0a040; background: var(--color-sync-ahead);
box-shadow: 0 0 0 1px rgba(224,160,64,0.42), 0 0 10px rgba(224,160,64,0.16); box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-sync-ahead) 42%, transparent), 0 0 10px color-mix(in srgb, var(--color-sync-ahead) 16%, transparent);
} }
.graph-dot.behind { .graph-dot.behind {
background: var(--dot-color, #5a8cf8); background: var(--color-sync-behind);
box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8), 0 0 10px rgba(122,172,255,0.18); box-shadow: 0 0 0 1px var(--color-sync-behind), 0 0 10px color-mix(in srgb, var(--color-sync-behind) 18%, transparent);
} }
.graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); } .graph-dot.tip { width: 14px; height: 14px; box-shadow: 0 0 0 1px var(--dot-color, #5a8cf8); }
.graph-dot.merge { .graph-dot.merge {
@@ -3455,10 +3458,10 @@
} }
.graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); } .graph-row + .graph-row .commit-body { border-top: 1px solid rgba(226,232,240,0.075); }
.graph-row.graph-ahead-row .commit-body { .graph-row.graph-ahead-row .commit-body {
box-shadow: inset 3px 0 0 rgba(224,160,64,0.72); box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-sync-ahead) 72%, transparent);
} }
.graph-row.graph-behind-row .commit-body { .graph-row.graph-behind-row .commit-body {
box-shadow: inset 3px 0 0 rgba(122,172,255,0.72); box-shadow: inset 3px 0 0 color-mix(in srgb, var(--color-sync-behind) 72%, transparent);
} }
.graph-row:hover .commit-body { background: rgba(30,39,57,0.72); } .graph-row:hover .commit-body { background: rgba(30,39,57,0.72); }
.graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; } .graph-row:hover .graph-svg path { opacity: 1; stroke-width: 2.65; }
@@ -6739,8 +6742,8 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.workspace-status-spacer { flex: 1 1 auto; } .workspace-status-spacer { flex: 1 1 auto; }
.workspace-health > span { width: 8px; height: 8px; border-radius: 50%; background: #d9891b; } .workspace-health > span { width: 8px; height: 8px; border-radius: 50%; background: #d9891b; }
.workspace-health.clean > span { background: #2da44e; } .workspace-health.clean > span { background: #2da44e; }
.workspace-statusbar .ahead { color: #d9891b; } .workspace-statusbar .ahead { color: var(--color-sync-ahead); }
.workspace-statusbar .behind { color: var(--color-primary); } .workspace-statusbar .behind { color: var(--color-sync-behind); }
.workspace-statusbar .workspace-local-only { .workspace-statusbar .workspace-local-only {
padding: 2px 6px; padding: 2px 6px;
border: 1px dashed rgba(224,160,64,.42); border: 1px dashed rgba(224,160,64,.42);
@@ -7793,11 +7796,11 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
} }
:root[data-theme="light"] .workspace-branch svg { color: #0b63e6; } :root[data-theme="light"] .workspace-branch svg { color: #0b63e6; }
:root[data-theme="light"] .workspace-statusbar .ahead { :root[data-theme="light"] .workspace-statusbar .ahead {
color: #9a5200; color: var(--color-sync-ahead);
font-weight: 800; font-weight: 800;
} }
:root[data-theme="light"] .workspace-statusbar .behind { :root[data-theme="light"] .workspace-statusbar .behind {
color: #0755c8; color: var(--color-sync-behind);
font-weight: 800; font-weight: 800;
} }
:root[data-theme="light"] .repo-action.sync-primary.publish-local, :root[data-theme="light"] .repo-action.sync-primary.publish-local,
@@ -9059,3 +9062,9 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
padding: 0; padding: 0;
justify-content: center; justify-content: center;
} }
/* Shared sync colors keep every indicator consistent in the light theme. */
:root[data-theme="light"] {
--color-sync-ahead: #9a5200;
--color-sync-behind: #0755c8;
}
+79 -114
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <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"; import type { GitFileStatus, PatchApplyAction } from "../types";
type PatchLineKind = "context" | "add" | "delete" | "meta"; type PatchLineKind = "context" | "add" | "delete" | "meta";
@@ -60,18 +60,26 @@
const isGerman = $derived(language === "de"); const isGerman = $derived(language === "de");
let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false }); let parsed = $state<ParsedPatch>({ headerLines: [], hunks: [], binary: false });
let patchWidth = $state(0);
let patchScroll = $state<HTMLDivElement | null>(null); let patchScroll = $state<HTMLDivElement | null>(null);
let selectedLineIds = $state<Set<string>>(new Set()); let selectedLineIds = $state<Set<string>>(new Set());
let lastSelectedLineId = $state(""); 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 displayPath = $derived(file.old_path ? `${file.old_path} -> ${file.path}` : file.path);
let selectableLines = $derived( let selectableLines = $derived(
parsed.hunks.flatMap((hunk) => hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete")), 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); let selectedCount = $derived(selectableLines.filter((line) => selectedLineIds.has(line.id)).length);
$effect(() => { $effect(() => {
activeHunk = 0;
parsed = parsePatch(patch); parsed = parsePatch(patch);
selectedLineIds = new Set(); selectedLineIds = new Set();
lastSelectedLineId = ""; lastSelectedLineId = "";
@@ -191,6 +199,7 @@
} }
function toggleHunkSelection(hunk: PatchHunk) { function toggleHunkSelection(hunk: PatchHunk) {
if (isBusy || isLoading) return;
const changed = hunk.lines.filter((line) => line.kind === "add" || line.kind === "delete"); 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 allSelected = changed.length > 0 && changed.every((line) => selectedLineIds.has(line.id));
const next = new Set(selectedLineIds); const next = new Set(selectedLineIds);
@@ -266,149 +275,105 @@
function scrollToHunk(hunkId: string) { function scrollToHunk(hunkId: string) {
const target = patchScroll?.querySelector<HTMLElement>(`[data-hunk-id="${hunkId}"]`); 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> </script>
<div class="dialog-backdrop" role="presentation"> <div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label="Line patch"> <div class="dialog line-patch-dialog" role="dialog" aria-modal="true" aria-label={t("Geänderte Zeilen", "Changed lines")}>
<header class="dialog-header"> <header class="dialog-header">
<div> <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>
<span class="eyebrow">{scopeLabel}</span> <div class="dialog-header-actions">
<p class="dialog-title" title={displayPath}>{displayPath}</p> <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>
</div> <button class="icon-action" type="button" onclick={onRefresh} disabled={isBusy || isLoading} aria-label={t("Aktualisieren", "Refresh")} title={t("Aktualisieren", "Refresh")}><RefreshCw size={16} /></button>
<div class="dialog-header-actions line-patch-header-actions"> <button class="icon-action" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={16} /></button>
<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> </div>
</header> </header>
<div class="line-patch-body"> <div class="line-patch-body">
{#if isLoading} {#if isLoading}
<div class="blank-state"> <div class="blank-state"><LoaderCircle class="spin" size={18} />{t("Änderungen werden geladen …", "Loading changes …")}</div>
<LoaderCircle class="spin" size={18} aria-hidden="true" /> {:else if error}<div class="blank-state" role="alert">{error}</div>
Loading patch... {:else if !patch.trim()}<div class="blank-state">{t("Keine Änderungen für diese Datei vorhanden.", "No changes available for this file.")}</div>
</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 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>
{:else} {:else}
<div class:active={selectedCount > 0} class="line-patch-selection-bar"> <div class="patch-toolbar">
<div> <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>
<MousePointer2 size={14} aria-hidden="true" /> <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>
{#if selectedCount > 0} <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>
<strong>{selectedCount} {selectedCount === 1 ? "line" : "lines"} selected</strong> <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>
<span>Shift-click to select a range.</span>
{:else}
<strong>Select changed lines</strong>
<span>Choose individual additions or deletions below.</span>
{/if}
</div> </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>
<div class="line-patch-workspace" style={`--patch-visible-width: ${patchWidth}px`}>
<div class="line-patch-workspace"> <div class="line-patch-scroll" bind:this={patchScroll} bind:clientWidth={patchWidth} onscroll={updateActiveHunk}>
<div class="line-patch-scroll" bind:this={patchScroll}> {#each parsed.hunks as hunk, index (hunk.id)}
{#each parsed.hunks as hunk (hunk.id)} <section class="line-patch-hunk" data-hunk-id={hunk.id} aria-label={`${t("Abschnitt", "Hunk")} ${index + 1}`}>
<section class="line-patch-hunk" data-hunk-id={hunk.id}>
<div class="line-patch-hunk-head"> <div class="line-patch-hunk-head">
<button <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")}>
class:all={hunkSelectionState(hunk) === "all"} {#if hunkSelectionState(hunk) === "all"}<Check size={11} />{:else if hunkSelectionState(hunk) === "some"}<Minus size={11} />{/if}
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> </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"> <div class="line-patch-hunk-actions">
{#if staged} <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 discard" type="button" onclick={() => applyHunkAction("discard-staged", hunk)} disabled={isBusy}> <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>
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}
</div> </div>
</div> </div>
<div class="line-patch-lines"> <div class="line-patch-lines">
{#each hunk.lines as line (line.id)} {#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}`}> <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"} {#if line.kind === "add" || line.kind === "delete"}
<button <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)")}>
class="line-patch-line-select" {#if selectedLineIds.has(line.id)}<Check size={11} />{/if}
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> </button>
{:else} {:else}<span class="line-patch-line-select-placeholder"></span>{/if}
<span class="line-patch-line-select-placeholder"></span> <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>
{/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> </div>
{/each} {/each}
</div> </div>
</section> </section>
{/each} {/each}
</div> </div>
<nav class="diff-overview line-patch-overview" aria-label="Change overview"> <nav class="diff-overview line-patch-overview" aria-label={t("Änderungsübersicht", "Change overview")}>
{#each parsed.hunks as hunk, index (hunk.id)} {#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}
<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> </nav>
</div> </div>
{/if} {/if}
</div> </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>
</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>
+90 -19
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte"; import { ArrowDown, ArrowUp, Check, ChevronDown, ChevronRight, Circle, Download, FolderGit2, GitBranch, GitPullRequest, LayoutGrid, List, LoaderCircle, Plus, Search, Star, TriangleAlert, Trash2, X } from "@lucide/svelte";
import { listIntegrationReviewRequests, listRemotes } from "../git"; import { listIntegrationReviewRequests, listRemotes } from "../git";
import { configuredIntegrationSources, integrationCredentialKey } from "../integrations"; import { configuredIntegrationSources, integrationCredentialKey } from "../integrations";
import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types"; import type { GitIntegrationSettings, GitIntegrationSource, IntegrationReviewRequest, StoredCredential } from "../types";
@@ -46,6 +46,7 @@
const STORAGE_KEY = "gitty.dashboard.v1"; const STORAGE_KEY = "gitty.dashboard.v1";
let query = ""; let query = "";
let viewMode: "cards" | "list" = "list";
let selectedWorkspace = ""; let selectedWorkspace = "";
let collapsedCategories = new Set<string>(); let collapsedCategories = new Set<string>();
let workspaces: Workspace[] = []; let workspaces: Workspace[] = [];
@@ -92,7 +93,8 @@
function loadPreferences() { function loadPreferences() {
try { try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown }; const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) ?? "{}") as { workspaces?: unknown; assignments?: unknown; viewMode?: unknown };
viewMode = saved.viewMode === "cards" ? "cards" : "list";
if (Array.isArray(saved.workspaces)) { if (Array.isArray(saved.workspaces)) {
workspaces = saved.workspaces.filter((item): item is Workspace => Boolean( workspaces = saved.workspaces.filter((item): item is Workspace => Boolean(
item && typeof item === "object" item && typeof item === "object"
@@ -111,10 +113,15 @@
} }
function savePreferences() { function savePreferences() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments })); } try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ workspaces, assignments, viewMode })); }
catch { /* Preferences remain optional when storage is unavailable. */ } catch { /* Preferences remain optional when storage is unavailable. */ }
} }
function setViewMode(mode: "cards" | "list") {
viewMode = mode;
savePreferences();
}
function openWorkspaceDialog() { function openWorkspaceDialog() {
workspaceName = ""; workspaceName = "";
workspaceError = ""; workspaceError = "";
@@ -285,8 +292,7 @@
})); }));
} }
function pullRequestTitle(repo: DashboardRepository): string { function pullRequestTitle(badge: PullRequestBadgeState | undefined): string {
const badge = pullRequestBadges[repo.path];
if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests"; if (!badge || badge.status === "loading") return de ? "Offene Pull Requests werden geladen" : "Loading open pull requests";
if (badge.status === "error" || badge.status === "unavailable") return badge.message; if (badge.status === "error" || badge.status === "unavailable") return badge.message;
return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`; return de ? `${badge.count} offene Pull Requests` : `${badge.count} open pull requests`;
@@ -314,6 +320,10 @@
<Search size={14} /> <Search size={14} />
<input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} /> <input bind:value={query} autocomplete="off" spellcheck="false" placeholder={de ? "Repositories suchen …" : "Search repositories …"} aria-label={de ? "Repositories suchen" : "Search repositories"} />
</label> </label>
<div class="view-switch" role="group" aria-label={de ? "Repository-Ansicht" : "Repository view"}>
<button type="button" class:active={viewMode === "list"} aria-pressed={viewMode === "list"} aria-label={de ? "Listenansicht" : "List view"} title={de ? "Listenansicht" : "List view"} onclick={() => setViewMode("list")}><List size={16} /></button>
<button type="button" class:active={viewMode === "cards"} aria-pressed={viewMode === "cards"} aria-label={de ? "Kachelansicht" : "Card view"} title={de ? "Kachelansicht" : "Card view"} onclick={() => setViewMode("cards")}><LayoutGrid size={15} /></button>
</div>
<div class="workspace-tools"> <div class="workspace-tools">
<SelectMenu <SelectMenu
class="workspace-select" class="workspace-select"
@@ -331,7 +341,7 @@
<div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div> <div class="dashboard-summary">{repos.length} Repositories <span>·</span> {changedCount} {de ? "mit Änderungen" : "with changes"}</div>
<div class="dashboard-content"> <div class="dashboard-content" class:table-view={viewMode === "list"}>
{#each categories as category (category.id)} {#each categories as category (category.id)}
<section class="dashboard-section"> <section class="dashboard-section">
<header class="section-header"> <header class="section-header">
@@ -342,28 +352,26 @@
</header> </header>
{#if !collapsedCategories.has(category.id)} {#if !collapsedCategories.has(category.id)}
{#if category.repos.length > 0} {#if category.repos.length > 0}
<div class="repo-grid"> <div class="repo-grid" class:list-view={viewMode === "list"} class:tiles-view={viewMode === "cards"}>
{#each category.repos as repo (repo.path)} {#each category.repos as repo (repo.path)}
{@const pullRequests = pullRequestBadges[repo.path]} {@const pullRequests = pullRequestBadges[repo.path]}
<article class="repo-card" class:open={repo.isOpen}> <article class="repo-card" class:open={repo.isOpen} class:favorite-category={category.id === "favorites"}>
<button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy} title={repo.path}> <button class="card-main" type="button" onclick={() => openCard(repo.path)} disabled={isBusy}>
<span class="card-title"><FolderGit2 size={15} /><strong>{repo.name}</strong></span> <span class="card-title"><FolderGit2 size={15} /><strong title={repo.path}>{repo.name}</strong></span>
<span class="card-path">{repo.path}</span> <span class="card-branch" title={repo.branch ?? undefined}><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
<span class="card-branch"><GitBranch size={13} />{repo.branch ?? (de ? "Branch unbekannt" : "Unknown branch")}</span>
<span class="card-status"> <span class="card-status">
{#if !repo.known}<span class="muted"><Circle size={10} />{de ? "Status nicht geladen" : "Status not loaded"}</span> {#if !repo.known}<span class="muted"><Circle class="filled" size={9} />{de ? "Status nicht geladen" : "Status not loaded"}</span>
{:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span> {:else if repo.changed > 0}<span class="changed"><Circle class="filled" size={9} />{repo.changed} {de ? (repo.changed === 1 ? "Änderung" : "Änderungen") : (repo.changed === 1 ? "change" : "changes")}</span>
{:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if} {:else}<span class="clean"><Circle class="filled" size={9} />{de ? "Sauber" : "Clean"}</span>{/if}
{#if repo.ahead > 0}<span class="ahead"><ArrowUp size={13} />{repo.ahead} {de ? "voraus" : "ahead"}</span>{/if}
{#if repo.behind > 0}<span class="behind"><ArrowDown size={13} />{repo.behind} {de ? "zurück" : "behind"}</span>{/if}
</span> </span>
<span class="list-sync">{#if repo.known}<span class:ahead={repo.ahead > 0} title={de ? `${repo.ahead} Commits voraus` : `${repo.ahead} commits ahead`}><ArrowUp size={14} />{repo.ahead}</span><span class:behind={repo.behind > 0} title={de ? `${repo.behind} Commits zurück` : `${repo.behind} commits behind`}><ArrowDown size={14} />{repo.behind}</span>{:else}<span class="muted"></span>{#if viewMode === "cards"}<span class="muted"></span>{/if}{/if}</span>
</button> </button>
<div class="card-actions"> <div class="card-actions">
<button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(repo)}`} title={pullRequestTitle(repo)}> <button class:has-open-prs={pullRequests?.status === "ready" && pullRequests.count > 0} class:pr-error={pullRequests?.status === "error"} class="pr-badge" type="button" onclick={(event) => openPullRequests(event, repo)} disabled={isBusy || !pullRequests || pullRequests.status !== "ready"} aria-label={`${repo.name}: ${pullRequestTitle(pullRequests)}`} title={pullRequestTitle(pullRequests)}>
{#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : ""}</span>{/if} {#if !pullRequests || pullRequests.status === "loading"}<LoaderCircle class="spin" size={14} />{:else if pullRequests.status === "error"}<TriangleAlert size={14} />{:else}<GitPullRequest size={14} /><span>{pullRequests.status === "ready" ? pullRequests.count : ""}</span>{/if}
</button> </button>
<button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button> <button class:active={repo.favorite} type="button" onclick={(event) => favoriteCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Favorit umschalten" : "Toggle favorite"}`} aria-pressed={repo.favorite}><Star size={15} /></button>
{#if repo.isOpen}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if} {#if repo.isOpen && category.id === "open"}<button type="button" onclick={(event) => closeCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Schließen" : "Close"}`} title={de ? "Repository schließen" : "Close repository"}><X size={15} /></button>{/if}
{#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if} {#if category.id === "recent"}<button type="button" onclick={(event) => removeRecentCard(event, repo.path)} disabled={isBusy} aria-label={`${repo.name}: ${de ? "Aus Recent entfernen" : "Remove from recent"}`} title={de ? "Aus Recent entfernen" : "Remove from recent"}><X size={15} /></button>{/if}
</div> </div>
</article> </article>
@@ -398,11 +406,74 @@
.repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5} .repo-dashboard{display:flex;flex:1;min-height:0;flex-direction:column;overflow:hidden;color:var(--color-ink);background:var(--app-bg);font-size:12px}button,input{font:inherit}button{color:inherit;cursor:pointer}button:disabled{cursor:default;opacity:.5}
.dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff} .dashboard-header{display:flex;min-height:58px;align-items:center;justify-content:space-between;gap:16px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-header h1{margin:0;font-size:18px;line-height:1.2;font-weight:700}.header-actions,.workspace-tools{display:flex;align-items:center;gap:8px}.header-actions button,.workspace-tools button{display:inline-flex;min-height:30px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}button.primary{border-color:var(--color-primary);background:var(--color-primary);color:#fff}
.dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)} .dashboard-toolbar{display:flex;min-height:48px;align-items:center;gap:8px;padding:8px 20px;border-bottom:1px solid var(--color-border-subtle)}.dashboard-search{display:flex;width:300px;height:30px;align-items:center;gap:8px;padding:0 9px;border:1px solid var(--color-border-input);border-radius:var(--ui-radius-sm);color:var(--color-ink-faint);background:var(--app-input-bg)}.dashboard-search:focus-within{border-color:var(--color-accent);box-shadow:0 0 0 2px color-mix(in srgb,var(--color-primary) 14%,transparent)}.dashboard-search :global(svg){flex:0 0 auto}.dashboard-search input{width:100%;min-width:0;height:100%;padding:0;border:0;border-radius:0;outline:0;color:var(--color-ink);background:transparent;box-shadow:none}.dashboard-search input:focus,.dashboard-search input:focus-visible{border:0;outline:0;box-shadow:none}.workspace-tools{margin-left:auto}.workspace-tools :global(.workspace-select){width:240px}.workspace-tools :global(.workspace-select .select-menu-trigger){height:30px;min-height:30px;padding:0 9px;border-color:var(--color-border-input);background:var(--app-input-bg);font-size:12px;font-weight:500}.workspace-tools .workspace-delete{width:30px;min-width:30px;padding:0;border-color:color-mix(in srgb,#df626b 55%,var(--color-border));color:#df747b;background:color-mix(in srgb,#c92f3a 8%,var(--app-button-bg))}.workspace-tools .workspace-delete:hover:not(:disabled){border-color:#df626b;color:#fff;background:#c93b45;box-shadow:0 0 0 2px color-mix(in srgb,#c92f3a 18%,transparent)}
.dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:12px}.section-header{min-height:31px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)} .dashboard-summary{padding:8px 20px;color:var(--color-ink-muted)}.dashboard-summary span{padding:0 6px;color:var(--color-ink-faint)}.dashboard-content{flex:1;min-height:0;overflow:auto;padding:0 20px 24px}.dashboard-section+.dashboard-section{margin-top:20px}.section-header{min-height:31px;margin-bottom:6px}.section-toggle{display:flex;width:100%;min-height:31px;align-items:center;justify-content:flex-start;gap:7px;padding:0;border:0;background:transparent;color:var(--color-ink);text-align:left}.section-toggle:hover:not(:disabled){border:0;background:transparent;color:var(--color-ink)}.section-toggle :global(svg){flex:0 0 auto;color:var(--color-ink-muted)}.section-toggle strong{font-size:13px;line-height:1.2;font-weight:750}.section-toggle span{color:var(--color-ink-faint);font-weight:400}.section-toggle i{height:1px;flex:1;background:var(--color-border-subtle)}.category-empty{margin:0;padding:9px 12px;border:1px solid var(--color-border-subtle);color:var(--color-ink-faint);background:var(--color-surface)}
.repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-path{width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed,.behind{color:#eeb94e}.clean{color:#68c878}.ahead{color:var(--color-accent)}.muted{color:var(--color-ink-muted)} .repo-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px}.repo-card{position:relative;min-width:0;height:138px;border:1px solid var(--color-border);background:var(--color-surface)}.repo-card:hover,.repo-card:focus-within{border-color:var(--color-border-input);background:var(--color-surface-hover)}.card-main{display:flex;width:100%;height:100%;flex-direction:column;align-items:flex-start;gap:7px;padding:12px 104px 11px 14px;border:0;background:transparent;text-align:left}.card-main:focus-visible{outline:2px solid var(--color-accent);outline-offset:-2px}.card-title{display:flex;max-width:100%;align-items:center;gap:8px}.card-title strong{overflow:hidden;font-size:14px;font-weight:700;text-overflow:ellipsis;white-space:nowrap}.card-branch,.card-status,.card-status>span{display:flex;align-items:center;gap:6px}.card-branch{max-width:100%;overflow:hidden;color:var(--color-ink-muted);text-overflow:ellipsis;white-space:nowrap}.card-status{flex-wrap:wrap;gap:8px 18px;margin-top:auto}.card-status :global(.filled){fill:currentColor}.changed{color:#eeb94e}.behind{color:var(--color-sync-behind)}.clean{color:#68c878}.ahead{color:var(--color-sync-ahead)}.muted{color:var(--color-ink-muted)}
.card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)} .card-actions{position:absolute;top:8px;right:8px;z-index:2;display:flex;align-items:center;gap:2px}.card-actions button{display:grid;width:26px;height:26px;place-items:center;padding:0;border:0;color:var(--color-ink-muted);background:transparent}.card-actions button:hover:not(:disabled){color:var(--color-ink);background:var(--color-surface-dim)}.card-actions button.active{color:var(--color-accent)}.card-actions button.active :global(svg){fill:color-mix(in srgb,var(--color-accent) 22%,transparent)}.card-actions .pr-badge{display:flex;width:auto;min-width:31px;grid-template-columns:none;align-items:center;justify-content:center;gap:4px;padding:0 5px;color:var(--color-ink-faint);font-size:11px}.card-actions .pr-badge:disabled{opacity:.72}.card-actions .pr-badge.has-open-prs{color:var(--color-accent)}.card-actions .pr-badge.pr-error{color:#e0a35b}.card-actions .pr-badge:hover:not(:disabled){color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 9%,transparent)}
.dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)} .dashboard-footer{display:flex;min-height:30px;align-items:center;padding:0 20px;border-top:1px solid var(--color-border-subtle);color:var(--color-ink-muted)}
.workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)} .workspace-dialog{width:min(420px,calc(100vw - 32px));margin:auto;padding:0;border:1px solid var(--color-border);color:var(--color-ink);background:var(--app-dialog-bg);box-shadow:var(--app-dialog-shadow);font-size:12px}.workspace-dialog::backdrop{background:var(--app-dialog-backdrop)}.workspace-dialog form>header,.workspace-dialog form>footer{display:flex;align-items:center;justify-content:space-between;padding:10px 12px;background:var(--app-dialog-chrome)}.workspace-dialog form>header{border-bottom:1px solid var(--color-border)}.workspace-dialog form>footer{justify-content:flex-end;gap:8px;border-top:1px solid var(--color-border)}.workspace-dialog h2{margin:0;font-size:13px}.workspace-dialog header button{width:26px;height:26px;padding:0;border:0;background:transparent}.workspace-dialog form>label,.workspace-dialog form>input,.workspace-dialog fieldset,.dialog-error{margin-right:12px;margin-left:12px}.workspace-dialog form>label{display:block;margin-top:12px;margin-bottom:5px;color:var(--color-ink-muted)}.workspace-dialog form>input{width:calc(100% - 24px);height:30px;padding:0 8px;border:1px solid var(--color-border-input);color:var(--color-ink);background:var(--app-input-bg)}.dialog-error{margin-top:7px;color:#ed9292}.workspace-dialog fieldset{margin-top:14px;margin-bottom:14px;padding:0;border:0}.workspace-dialog legend{margin-bottom:6px;color:var(--color-ink-muted)}.workspace-repos{max-height:190px;overflow:auto;border:1px solid var(--color-border-subtle)}.workspace-repos label{display:grid;grid-template-columns:auto minmax(100px,auto) 1fr;align-items:center;gap:8px;min-height:32px;padding:4px 8px;border-bottom:1px solid var(--color-border-subtle)}.workspace-repos label:last-child{border-bottom:0}.workspace-repos small{overflow:hidden;color:var(--color-ink-faint);text-overflow:ellipsis;white-space:nowrap}.workspace-dialog footer button{display:inline-flex;min-height:28px;align-items:center;gap:6px;padding:0 10px;border:1px solid var(--color-border);background:var(--app-button-bg)}
@media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}} @media(max-width:1000px){.dashboard-toolbar{flex-wrap:wrap;gap:8px}.dashboard-search{flex:1 1 260px}.workspace-tools{margin-left:0}.repo-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}} @media(max-width:680px){.dashboard-header{min-height:auto;align-items:flex-start;flex-direction:column}.header-actions{flex-wrap:wrap}.workspace-tools{width:100%}.workspace-tools :global(.workspace-select){min-width:0;flex:1}.repo-grid{grid-template-columns:1fr}.repo-card{height:132px}}
.view-switch{display:flex;flex:0 0 auto;height:30px;border:1px solid var(--color-border-input);background:var(--app-input-bg)}
.view-switch button{display:grid;width:32px;min-height:0;height:28px;place-items:center;padding:0;border:0;border-radius:0;color:var(--color-ink-muted);background:transparent}
.view-switch button+button{border-left:1px solid var(--color-border-subtle)}
.view-switch button.active{color:var(--color-accent);background:color-mix(in srgb,var(--color-accent) 12%,var(--app-input-bg));box-shadow:inset 0 -2px var(--color-accent)}
.view-switch button:hover{color:var(--color-ink);background:var(--color-surface-hover)}
.view-switch button:focus-visible{outline:2px solid var(--color-accent);outline-offset:2px}
/* Approved list design: shared columns across every category. */
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
.table-view .repo-card{display:grid;grid-template-columns:minmax(0,1fr) minmax(170px,22%)}
.list-view .card-main{display:grid;grid-template-columns:var(--repo-list-columns);gap:16px;padding:0 18px;align-items:center}
.repo-grid.list-view{display:block;border:0}
.table-view .repo-card{height:auto;min-height:48px;border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
.table-view .repo-card:hover,.table-view .repo-card:focus-within{background:color-mix(in srgb,var(--color-accent) 5%,var(--color-surface))}
.table-view .repo-card:focus-within{box-shadow:inset 2px 0 var(--color-accent)}
.list-view .card-main{height:auto;min-height:48px;min-width:0;justify-content:stretch;align-content:center;text-align:left}
.list-view .card-title{min-width:0;gap:9px}.list-view .card-title strong{font-size:12px;font-weight:500}.list-view .card-title :global(svg){flex:none}
.list-view .card-branch{min-width:0;font-size:12px}.list-view .card-branch :global(svg){flex:none}
.list-view .card-status{margin:0;gap:5px;font-size:12px;flex-wrap:nowrap;white-space:nowrap}.list-view .card-status .muted{font-style:italic}
.list-sync{display:flex;align-items:center;gap:18px;color:var(--color-ink-faint);font-size:12px}.list-sync>span{display:flex;align-items:center;gap:5px}
.list-view .card-actions{position:static;display:grid;grid-template-columns:minmax(60px,1fr) 40px 40px;gap:0;align-items:center;margin:0}
.list-view .card-actions .pr-badge{justify-self:start;justify-content:flex-start;padding:0;gap:8px;color:var(--color-accent)}
.list-view .pr-badge span{min-width:18px;padding:1px 4px;border:1px solid var(--color-border);border-radius:2px;color:var(--color-ink-muted);font-size:11px;text-align:center}
.list-view .card-actions button{height:28px;width:28px}.list-view .card-actions button :global(svg){width:16px;height:16px}
.list-view .card-actions button.active :global(svg){fill:var(--color-accent)}
.table-view .category-empty{border:0;border-bottom:1px solid var(--color-border-subtle);background:transparent}
.list-view .card-actions .pr-badge{width:auto;min-width:60px}.list-view .card-actions .pr-badge :global(svg){flex:0 0 16px}
/* Approved tile layout: repository identity above a shared status footer. */
.tiles-view{gap:12px}
.tiles-view .repo-card{height:150px;border-radius:3px;background:color-mix(in srgb,var(--color-surface) 35%,var(--app-bg))}
.tiles-view .repo-card:focus-within{box-shadow:inset 3px 0 var(--color-accent)}
.tiles-view .card-main{display:grid;grid-template-columns:minmax(0,1fr) 104px 42px;grid-template-rows:24px 24px 1fr 30px;gap:0 10px;padding:16px 16px 10px;align-items:center;align-content:stretch}
.tiles-view .card-main:after{position:absolute;left:16px;right:16px;bottom:51px;height:1px;background:var(--color-border);content:"";pointer-events:none}
.tiles-view .card-title{grid-column:1/-1;grid-row:1;min-width:0;padding:0 60px 0 48px;max-width:100%;display:block}
.tiles-view .card-title strong{display:block;font-size:13px;font-weight:650;line-height:22px}
.tiles-view .card-title>:global(svg){position:absolute;top:21px;left:18px;width:32px;height:32px;color:var(--color-ink-muted);stroke-width:1.4}
.tiles-view .card-branch{grid-column:1/-1;grid-row:2;padding-left:48px;min-width:0;display:block;overflow:hidden;text-overflow:ellipsis;font-size:11px;line-height:22px}
.tiles-view .card-branch>:global(svg){display:inline-block;margin-right:6px;vertical-align:-2px}
.tiles-view .card-status{grid-column:1;grid-row:4;min-width:0;flex-wrap:nowrap;margin:0;font-size:11px;gap:0;white-space:nowrap}
.tiles-view .card-status>span{min-width:0;overflow:hidden;text-overflow:ellipsis}
.tiles-view .list-sync{grid-column:2;grid-row:4;gap:16px;font-size:11px}
.tiles-view .card-actions{position:static;display:contents}
.tiles-view .card-actions button{position:absolute;top:14px;right:14px;width:26px;height:26px}
.tiles-view .card-actions button[aria-pressed]{right:44px}
.tiles-view .card-actions .pr-badge{top:auto;bottom:12px;right:14px;width:42px;min-width:0;height:26px;gap:6px;padding:0;font-size:12px;color:var(--color-ink-muted)}
.tiles-view .card-actions .pr-badge.has-open-prs{color:var(--color-accent)}
.tiles-view .card-actions .pr-badge.pr-error{color:#e0a35b}
.tiles-view .card-actions .pr-badge>:global(svg){width:16px;height:16px;flex:none}
.tiles-view .card-actions button.active>:global(svg){fill:var(--color-accent)}
.table-view{--repo-list-columns:minmax(180px,1.8fr) minmax(140px,1fr) minmax(150px,1fr) minmax(110px,.75fr)}
.table-view .dashboard-section{min-width:900px}
.list-view .card-title strong,.list-view .card-branch,.list-view .card-status{font-size:13px}
.list-view .card-actions .pr-badge.pr-error{color:#e0a35b}
.list-view .card-actions .pr-badge:disabled{color:var(--color-ink-faint)}
@media(min-width:1001px) and (max-width:1150px){.tiles-view{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:420px){.tiles-view .card-main{grid-template-columns:minmax(0,1fr) 82px 34px;gap:0 6px;padding-right:12px;padding-left:12px}.tiles-view .list-sync{gap:8px}.tiles-view .card-title{padding-left:42px}.tiles-view .card-branch{padding-left:42px}.tiles-view .card-title>:global(svg){left:14px;width:28px;height:28px}.tiles-view .card-status{font-size:10px}}
.tiles-view .card-title strong{font-size:14px}
.tiles-view .card-branch,.tiles-view .card-status,.tiles-view .list-sync{font-size:12px}
.tiles-view .card-status .changed,.tiles-view .card-status .clean{color:var(--color-ink-muted)}
.tiles-view .card-status .changed>:global(svg){color:#eeb94e;flex:none}.tiles-view .card-status .clean>:global(svg){color:#68c878;flex:none}
.tiles-view .favorite-category .card-actions button[aria-pressed]{right:14px}
.tiles-view .favorite-category .card-title{padding-right:30px}
.list-view .card-status .changed,.list-view .card-status .clean{color:var(--color-ink-muted)}
.list-view .card-status .changed>:global(svg){color:#eeb94e}.list-view .card-status .clean>:global(svg){color:#68c878}
</style> </style>
+133 -273
View File
@@ -1,9 +1,8 @@
<script lang="ts"> <script lang="ts">
import { untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { AlertCircle, Check, ExternalLink, GitMerge, LoaderCircle, X } from "@lucide/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"; import type { ConflictChoice, ConflictFile, ConflictPart, GitFileStatus, PreparedResolution } from "../types";
type ConflictRegion = Extract<ConflictPart, { kind: "conflict" }>;
type ResolveSplitRow = type ResolveSplitRow =
| { type: "marker"; conflictIndex: number } | { type: "marker"; conflictIndex: number }
| { | {
@@ -53,9 +52,33 @@
let resolveContent = $state(""); let resolveContent = $state("");
let manualMode = $state(false); let manualMode = $state(false);
let binarySide = $state<"ours" | "theirs" | null>(null); let binarySide = $state<"ours" | "theirs" | null>(null);
let resolveBeforePane = $state<HTMLDivElement | null>(null); let dialogElement: HTMLDivElement;
let resolveAfterPane = $state<HTMLDivElement | null>(null); onMount(() => {
let isSyncingResolveScroll = false; 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(() => { $effect(() => {
const c = conflict; const c = conflict;
@@ -63,6 +86,8 @@
const parts = conflictParts; const parts = conflictParts;
untrack(() => { untrack(() => {
activeConflict = 0;
bothMenu = null;
if (!c) { if (!c) {
conflictChoices = []; conflictChoices = [];
resolveContent = ""; resolveContent = "";
@@ -95,7 +120,6 @@
}); });
let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length); let conflictRegionCount = $derived(conflictParts.filter((p) => p.kind === "conflict").length);
let conflictRegions = $derived(conflictOnlyParts(conflictParts));
let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts)); let resolveSplitRows = $derived(buildResolveSplitRows(conflictParts));
let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length); let unresolvedCount = $derived(manualMode ? 0 : conflictChoices.filter((c) => c == null).length);
let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices)); let resolvedContent = $derived(manualMode ? resolveContent : buildResolution(conflictParts, conflictChoices));
@@ -147,10 +171,6 @@
return out.join("\n"); return out.join("\n");
} }
function conflictOnlyParts(parts: ConflictPart[]): ConflictRegion[] {
return parts.filter((part): part is ConflictRegion => part.kind === "conflict");
}
function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] { function buildResolveSplitRows(parts: ConflictPart[]): ResolveSplitRow[] {
const rows: ResolveSplitRow[] = []; const rows: ResolveSplitRow[] = [];
let leftNum = 0; let leftNum = 0;
@@ -174,7 +194,6 @@
continue; continue;
} }
rows.push({ type: "marker", conflictIndex: part.index });
const count = Math.max(part.oursLines.length, part.theirsLines.length); const count = Math.max(part.oursLines.length, part.theirsLines.length);
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const hasOurs = i < part.oursLines.length; const hasOurs = i < part.oursLines.length;
@@ -192,36 +211,22 @@
rightKind: hasTheirs ? "theirs" : "empty", rightKind: hasTheirs ? "theirs" : "empty",
}); });
} }
rows.push({ type: "marker", conflictIndex: part.index });
} }
return rows; 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) { function setConflictChoice(index: number, choice: ConflictChoice) {
activeConflict = index;
bothMenu = null;
const next = [...conflictChoices]; const next = [...conflictChoices];
next[index] = choice; next[index] = choice;
conflictChoices = next; conflictChoices = next;
} }
function setAllConflicts(choice: ConflictChoice) { function setAllConflicts(choice: ConflictChoice) {
bothMenu = null;
conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice); conflictChoices = conflictParts.filter((p) => p.kind === "conflict").map(() => choice);
} }
@@ -258,266 +263,121 @@
} }
</script> </script>
<div <svelte:window onkeydown={(event) => { if (event.key === "Escape") { if (bothMenu !== null) bothMenu = null; else if (!isBusy) onClose(); } }} onclick={() => { bothMenu = null; }} />
class="dialog-backdrop"
role="presentation" {#snippet bothChoices(index: number)}
> <div class="both-control">
<div <button type="button" aria-haspopup="menu" aria-expanded={bothMenu === index} disabled={isBusy} onclick={(event) => { event.stopPropagation(); bothMenu = bothMenu === index ? null : index; }}>
class="dialog" {index === -1 ? t("Beide", "Both") : t("Beide übernehmen", "Accept both")}<ChevronDown size={14} />
style="grid-template-rows: auto minmax(0,1fr) auto;" </button>
role="dialog" {#if bothMenu === index}
aria-modal="true" <div class="both-menu" role="menu">
aria-label="Resolve conflicts" <button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-ot") : setConflictChoice(index, "both-ot")}>{t("Aktuell, dann eingehend", "Current, then incoming")}</button>
tabindex="-1" <button type="button" role="menuitem" onclick={() => index === -1 ? setAllConflicts("both-to") : setConflictChoice(index, "both-to")}>{t("Eingehend, dann aktuell", "Incoming, then current")}</button>
>
<header class="dialog-header">
<div>
<span class="eyebrow">Resolve</span>
<h2 class="dialog-title">Conflicts</h2>
</div> </div>
<div class="dialog-header-actions"> {/if}
{#if conflictTarget} </div>
<div class="tool-surface-choice" aria-label={isGerman ? "Konflikt bearbeiten mit" : "Edit conflict with"}> {/snippet}
<span>{isGerman ? "Bearbeiten mit" : "Edit with"}</span>
<button class="active" type="button" aria-pressed="true" title={isGerman ? "In Gitty bearbeiten" : "Edit in Gitty"}> <div class="dialog-backdrop app-chrome-backdrop conflict-backdrop">
<GitMerge size={13} aria-hidden="true" />Gitty <div class="conflict-workbench" bind:this={dialogElement} onkeydown={trapFocus} role="dialog" aria-modal="true" aria-label={t("Konflikte lösen", "Resolve conflicts")} tabindex="-1">
</button> <header class="workbench-header">
<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={isGerman ? `In ${mergeName} öffnen` : `Open in ${mergeName}`}> <h2><GitMerge size={24} />{t("Konflikte lösen", "Resolve conflicts")}</h2>
<ExternalLink size={13} aria-hidden="true" /><span>{mergeName}</span> <div class="header-actions">
</button> {#if conflictTarget}<button type="button" onclick={() => onExternalMerge(conflictTarget)} disabled={isBusy} title={mergeName}><ExternalLink size={18} />{t("Externes Merge-Tool", "External merge tool")}</button>{/if}
</div> <button class="close-button" type="button" onclick={onClose} disabled={isBusy} aria-label={t("Schließen", "Close")}><X size={22} /></button>
{/if}
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Konflikte schließen" : "Close conflicts"}>
<X size={18} aria-hidden="true" />
</button>
</div> </div>
</header> </header>
{#if conflictedFiles.length === 0} {#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} {:else}
<div class="dialog-body"> <div class="workbench-body">
<aside class="dialog-files" aria-label="Conflicted files"> <aside class="file-sidebar" aria-label={t("Dateien mit Konflikten", "Conflicted files")}>
{#each conflictedFiles as file (file.path)} <div class="files-heading">{t("Dateien", "Files")} · {conflictedFiles.length}</div>
<button <div class="file-list">
class="dialog-file-row" {#each conflictedFiles as file (file.path)}
class:active={conflictTarget === file.path} {@const prepared = preparedResolutions[file.path] != null}
class: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}>
type="button" <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>
onclick={() => onSelectFile(file.path)} {#if prepared}<Check class="ready" size={20} />{:else}<span class="open-dot"></span>{/if}
disabled={isBusy}
title={file.path}
>
<span class={`status-badge ${preparedResolutions[file.path] ? "added" : "conflicted"}`}>
{preparedResolutions[file.path] ? "ready" : "conflicted"}
</span>
<strong>{file.path}</strong>
{#if preparedResolutions[file.path]}
<Check size={15} aria-hidden="true" />
{/if}
</button>
{/each}
</aside>
<div class="resolve-editor" aria-label="Conflict editor">
{#if !conflict}
<div class="blank-state">Select a file to resolve.</div>
{:else if conflict.binary}
<div class="resolve-binary">
<div class="resolve-binary-note">
<AlertCircle size={16} aria-hidden="true" />
<span>Binary file — pick which version to keep, then mark it resolved.</span>
</div>
<div class="resolve-binary-options">
<button
class="resolve-binary-card ours"
class:active={binarySide === "ours"}
type="button"
onclick={() => { binarySide = "ours"; }}
disabled={isBusy || conflict.ours_size == null}
>
<span class="resolve-side-label">Current (ours)</span>
<strong>{formatBytes(conflict.ours_size)}</strong>
<span class="resolve-binary-hint">{conflict.ours_size == null ? "Deleted on this side" : "Keep this version"}</span>
</button>
<button
class="resolve-binary-card theirs"
class:active={binarySide === "theirs"}
type="button"
onclick={() => { binarySide = "theirs"; }}
disabled={isBusy || conflict.theirs_size == null}
>
<span class="resolve-side-label">Incoming (theirs)</span>
<strong>{formatBytes(conflict.theirs_size)}</strong>
<span class="resolve-binary-hint">{conflict.theirs_size == null ? "Deleted on this side" : "Keep this version"}</span>
</button>
</div>
</div>
<div class="resolve-actions">
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span>
{#if currentPrepared}
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
{/if}
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
</button> </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> </div>
{:else} {:else}
<div class="resolve-toolbar"> <div class="comparison-toolbar">
{#if manualMode} {#if manualMode}<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>{t("Zurück zur Auswahl", "Back to guided")}</button>
<button type="button" onclick={() => { manualMode = false; }} disabled={isBusy}>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}
{: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> </div>
{#if manualMode} {#if manualMode}
<textarea <textarea class="manual-editor" aria-label={t("Konflikt manuell bearbeiten", "Edit conflict manually")} bind:value={resolveContent} spellcheck="false" disabled={isBusy}></textarea>
class="resolve-textarea"
bind:value={resolveContent}
spellcheck="false"
disabled={isBusy}
></textarea>
{:else} {:else}
<div class="resolve-structured"> <div class="comparison-scroll" bind:this={comparison}>
{#if conflictRegions.length > 0} <div class="comparison-grid">
<div class="resolve-conflict-controls" aria-label="Conflict decisions"> <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 conflictRegions as part (part.index)} {#each resolveSplitRows as row, i (i)}
<div class="resolve-conflict-bar" class:unresolved={conflictChoices[part.index] == null}> {#if row.type === "marker"}
<span class="resolve-conflict-label">Conflict {part.index + 1}</span> {@const choice = conflictChoices[row.conflictIndex]}
<div class="resolve-choice-buttons"> <div class="conflict-decision" data-conflict={row.conflictIndex}>
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button> <strong>{t("Konflikt", "Conflict")} {row.conflictIndex + 1}{#if choice == null}<span class="pending"> · {t("Offen", "Open")}</span>{/if}</strong>
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button> <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 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 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>
<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> {@render bothChoices(row.conflictIndex)}
</div> {#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> </div>
{/each} {:else}
</div> <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>
{/if} <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>
<div class="resolve-split" role="table" aria-label="Full conflict comparison">
<div
class="resolve-pane"
bind:this={resolveBeforePane}
aria-label="Current file content"
onscroll={() => syncResolveScroll("before")}
>
<div class="resolve-pane-grid">
{#each resolveSplitRows as row, i (`left-${i}`)}
{#if row.type === "marker"}
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
{:else}
<div class="resolve-num" class:ours={row.leftKind === "ours"} class:empty={row.leftKind === "empty"}>{row.leftNum ?? ""}</div>
<div
class="resolve-cell"
class:ours={row.leftKind === "ours"}
class:empty={row.leftKind === "empty"}
class:dimmed={row.conflictIndex != null && !oursActive(conflictChoices[row.conflictIndex])}
>{displayLine(row.leftText ?? "") || " "}</div>
{/if}
{/each}
</div>
</div>
<div
class="resolve-pane"
bind:this={resolveAfterPane}
aria-label="Incoming file content"
onscroll={() => syncResolveScroll("after")}
>
<div class="resolve-pane-grid">
{#each resolveSplitRows as row, i (`right-${i}`)}
{#if row.type === "marker"}
<div class="resolve-split-marker" class:unresolved={conflictChoices[row.conflictIndex] == null}>Conflict {row.conflictIndex + 1}</div>
{:else}
<div class="resolve-num" class:theirs={row.rightKind === "theirs"} class:empty={row.rightKind === "empty"}>{row.rightNum ?? ""}</div>
<div
class="resolve-cell"
class:theirs={row.rightKind === "theirs"}
class:empty={row.rightKind === "empty"}
class:dimmed={row.conflictIndex != null && !theirsActive(conflictChoices[row.conflictIndex])}
>{displayLine(row.rightText ?? "") || " "}</div>
{/if}
{/each}
</div>
</div>
</div>
{#if false}
{#each legacyConflictParts() as part, partIndex (partIndex)}
{#if part.kind === "text"}
{#if part.lines.length > 0}
<pre class="resolve-context">{#each part.lines as line}<span class="resolve-line context">{displayLine(line) || " "}</span>{/each}</pre>
{/if} {/if}
{:else} {/each}
<div class="resolve-conflict" class:unresolved={conflictChoices[part.index] == null}> </div>
<div class="resolve-conflict-bar">
<span class="resolve-conflict-label">Conflict {part.index + 1}</span>
<div class="resolve-choice-buttons">
<button type="button" class:active={conflictChoices[part.index] === "ours"} onclick={() => setConflictChoice(part.index, "ours")} disabled={isBusy}>Current</button>
<button type="button" class:active={conflictChoices[part.index] === "theirs"} onclick={() => setConflictChoice(part.index, "theirs")} disabled={isBusy}>Incoming</button>
<button type="button" class:active={conflictChoices[part.index] === "both-ot"} onclick={() => setConflictChoice(part.index, "both-ot")} disabled={isBusy} title="Both current first">Both C+I</button>
<button type="button" class:active={conflictChoices[part.index] === "both-to"} onclick={() => setConflictChoice(part.index, "both-to")} disabled={isBusy} title="Both incoming first">Both I+C</button>
</div>
</div>
<div class="resolve-side ours" class:dimmed={!oursActive(conflictChoices[part.index])}>
<span class="resolve-side-label">Current (ours)</span>
<pre class="resolve-lines">{#each part.oursLines as line}<span class="resolve-line ours">{displayLine(line) || " "}</span>{/each}</pre>
</div>
<div class="resolve-side theirs" class:dimmed={!theirsActive(conflictChoices[part.index])}>
<span class="resolve-side-label">Incoming (theirs)</span>
<pre class="resolve-lines">{#each part.theirsLines as line}<span class="resolve-line theirs">{displayLine(line) || " "}</span>{/each}</pre>
</div>
</div>
{/if}
{/each}
{/if}
</div> </div>
{/if} {/if}
<section class="result-preview" aria-label={t("Ergebnisvorschau", "Result preview")}>
<div class="resolve-actions"> <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>
<span class="resolve-path" title={conflictTarget}>{conflictTarget}</span> <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>
{#if currentPrepared} </section>
<span class="prepared-tag"><Check size={14} aria-hidden="true" /> Prepared</span>
{/if}
<button type="button" onclick={handleMarkResolved} disabled={!canMarkResolved}>
<Check size={16} aria-hidden="true" />
{currentPrepared ? "Update decision" : "Mark as resolved"}
</button>
</div>
{/if} {/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> </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>
<footer class="dialog-footer">
<span class="dialog-footer-info">{preparedCount} of {conflictedFiles.length} prepared</span>
<button class="btn-primary" type="button" onclick={onApply} disabled={isBusy || preparedCount === 0}>
{#if operation.startsWith("Resolving")}
<LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else}
<Check size={16} aria-hidden="true" />
{/if}
Apply {preparedCount} resolved
</button>
</footer>
{/if} {/if}
</div> </div>
</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>