Compare commits

..
2 Commits
Author SHA1 Message Date
Christoph a7558e457d feat(compare): add German localization and revamp compare UI
Pass the application language into compare dialogs and add German
translations so titles, buttons and helper text render appropriately.
Refactor dialog markup to introduce a compact header with a GitCompare
icon, show changed-file counts, and surface contextual help and warnings.
Adjust and extend CSS for spacing, layout and responsive behavior.

- Pass app language into compare components and derive isGerman flag
- Replace icons and restructure headers; add counts, help and warnings
- Update styles for sizing, spacing, responsive rules and new classes
2026-08-15 18:39:10 +02:00
Christoph 412e8d19b5 feat(ui): add SelectMenu component and replace native selects
Introduce a reusable SelectMenu component and replace native select
controls across the UI. This centralizes select behavior and styling,
enabling grouped options, placeholders, and a consistent popup
interaction. Add comprehensive CSS and light-theme tweaks, and update
rebase action styling to integrate the new control.

- Add a unified SelectMenu component and wire change handlers.
- Implement .select-menu styles, popup behavior, and theme overrides.
- Replace ad-hoc native selects in dialogs and rebase UI for consistency.
2026-08-15 18:26:32 +02:00
14 changed files with 494 additions and 190 deletions
+3 -1
View File
@@ -5537,6 +5537,7 @@
{canCompare} {canCompare}
{isBusy} {isBusy}
{operation} {operation}
language={appLanguage}
onCompareFromChange={(val) => { compareFrom = val; }} onCompareFromChange={(val) => { compareFrom = val; }}
onCompareToChange={(val) => { compareTo = val; }} onCompareToChange={(val) => { compareTo = val; }}
onCompare={compareSelectedTargets} onCompare={compareSelectedTargets}
@@ -5551,10 +5552,11 @@
{comparison} {comparison}
{selectedDiffPath} {selectedDiffPath}
{isBusy} {isBusy}
language={appLanguage}
fromLabel={comparisonFromLabel} fromLabel={comparisonFromLabel}
toLabel={comparisonToLabel} toLabel={comparisonToLabel}
highlightQuery={diffHighlightQuery} highlightQuery={diffHighlightQuery}
restoreLabel={pendingRestoreFile ? "Restore file" : ""} restoreLabel={pendingRestoreFile ? (appLanguage === "de" ? "Datei wiederherstellen" : "Restore file") : ""}
onClose={closeCompareDialog} onClose={closeCompareDialog}
onRestore={restorePreviewedCommitFile} onRestore={restorePreviewedCommitFile}
onSelectFile={selectDiffFile} onSelectFile={selectDiffFile}
+179 -14
View File
@@ -225,6 +225,114 @@
.btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; } .btn-sm { min-height: 26px; padding: 0 8px; font-size: 12px; }
.select-menu { position: relative; width: 100%; min-width: 0; }
.select-menu-trigger {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
width: 100%;
height: 34px;
min-height: 34px;
padding: 0 9px 0 11px;
border: 1px solid var(--color-border-input);
border-radius: 6px;
color: var(--color-ink);
background: var(--color-surface-raised);
font-size: 13px;
text-align: left;
}
.select-menu-value { display: flex; align-items: center; min-width: 0; gap: 7px; overflow: hidden; }
.select-menu-value > span { overflow: hidden; color: var(--color-ink); font-weight: 700; text-overflow: ellipsis; white-space: nowrap; }
.select-menu-value > small {
flex: 0 0 auto;
padding: 2px 5px;
border: 1px solid color-mix(in srgb, var(--color-accent) 28%, transparent);
border-radius: 4px;
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
font-size: 9px;
font-weight: 850;
letter-spacing: 0.025em;
}
.select-menu-value.placeholder > span { color: var(--color-ink-muted); font-weight: 600; }
.select-menu-trigger svg { color: var(--color-ink-faint); transition: transform 120ms ease; }
.select-menu.open .select-menu-trigger {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(90, 140, 248, 0.18);
}
.select-menu.open .select-menu-trigger svg { transform: rotate(180deg); }
.select-menu-popup {
position: fixed;
z-index: 1000;
display: grid;
gap: 2px;
padding: 5px;
overflow: auto;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface-solid);
box-shadow: 0 18px 50px rgba(0,0,0,0.5);
}
.select-menu-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin: 5px 3px 2px;
padding: 7px 5px 5px;
border-top: 1px solid var(--color-border-subtle);
color: var(--color-accent);
font-size: 10.5px;
font-weight: 900;
letter-spacing: 0.065em;
text-transform: uppercase;
}
.select-menu-group:first-child { margin-top: 0; border-top: 0; }
.select-menu-group small {
min-width: 18px;
padding: 1px 5px;
border-radius: 999px;
color: var(--color-ink);
background: var(--color-surface-hover);
font-size: 9px;
letter-spacing: 0;
text-align: center;
}
.select-menu-option {
display: grid;
grid-template-columns: minmax(0, 1fr) 14px;
align-items: center;
justify-content: initial;
width: 100%;
min-height: 30px;
padding: 6px 8px;
border-color: transparent;
border-radius: 6px;
color: var(--color-ink);
background: transparent;
font-size: 12px;
font-weight: 750;
text-align: left;
}
.select-menu-option > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.select-menu-option svg { color: var(--color-primary); }
.select-menu-option:hover:not(:disabled), .select-menu-option.active:not(:disabled) {
border-color: var(--color-border-subtle);
color: var(--color-ink);
background: rgba(255,255,255,0.06);
}
.select-menu-option.selected { color: var(--color-ink); }
.select-menu-option:disabled { cursor: not-allowed; opacity: 0.42; }
.compare-target-select .select-menu-trigger {
height: 38px;
min-height: 38px;
padding: 0 11px;
border-color: var(--color-border);
font-size: 12px;
font-weight: 700;
}
.btn-primary { .btn-primary {
border-color: rgba(111, 140, 255, 0.72); border-color: rgba(111, 140, 255, 0.72);
color: #ffffff; color: #ffffff;
@@ -3222,17 +3330,16 @@
.compare-form { .compare-form {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) 40px minmax(0, 1fr);
align-items: end; align-items: end;
gap: 8px; gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--color-border-subtle);
} }
.compare-field { display: grid; gap: 4px; min-width: 0; } .compare-field { display: grid; gap: 4px; min-width: 0; }
.compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; } .compare-field span { color: var(--color-ink-faint); font-size: 10.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.05em; }
.compare-arrow { margin-bottom: 6px; color: var(--color-ink-faint); }
.compare-target-help { .compare-target-help {
margin: 12px; display: flex;
align-items: flex-start;
gap: 8px;
padding: 10px 12px; padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle)); border: 1px solid color-mix(in srgb, var(--color-accent) 20%, var(--color-border-subtle));
border-radius: 8px; border-radius: 8px;
@@ -3241,6 +3348,9 @@
font-size: 11.5px; font-size: 11.5px;
line-height: 1.5; line-height: 1.5;
} }
.compare-target-help svg { flex: 0 0 auto; margin-top: 1px; color: var(--color-accent); }
.compare-target-warning { border-color: color-mix(in srgb, #e0a040 30%, var(--color-border-subtle)); background: color-mix(in srgb, #e0a040 7%, var(--color-surface-raised)); }
.compare-target-warning svg { color: #e0a040; }
.compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; } .compare-summary { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; padding: 10px 12px; }
.compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); } .compare-range { display: flex; align-items: center; gap: 7px; color: var(--color-ink-muted); }
@@ -3386,6 +3496,21 @@
width: min(1560px, calc(100vw - 32px)); width: min(1560px, calc(100vw - 32px));
height: min(940px, calc(100vh - 32px)); height: min(940px, calc(100vh - 32px));
} }
.compare-dialog-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 70px;
padding: 14px 18px;
border-bottom: 1px solid var(--color-border-subtle);
background: var(--app-dialog-chrome);
}
.compare-dialog-title { display: flex; align-items: center; gap: 12px; min-width: 0; }
.compare-dialog-title > div { min-width: 0; }
.compare-dialog-mark { display: grid; place-items: center; flex: 0 0 auto; width: 38px; height: 38px; border: 1px solid color-mix(in srgb, var(--color-accent) 28%, var(--color-border)); border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 9%, transparent); }
.compare-dialog-title h2 { margin: 0; color: var(--color-accent); font-size: 18px; font-weight: 700; line-height: 1.2; }
.compare-dialog-title p { margin: 3px 0 0; color: var(--color-ink); font-size: 11px; font-weight: 400; line-height: 1.35; }
.global-search-dialog { .global-search-dialog {
width: min(1180px, calc(100vw - 32px)); width: min(1180px, calc(100vw - 32px));
height: min(840px, calc(100vh - 32px)); height: min(840px, calc(100vh - 32px));
@@ -3401,12 +3526,22 @@
height: min(860px, calc(100vh - 32px)); height: min(860px, calc(100vh - 32px));
} }
.compare-select-dialog { .compare-select-dialog {
display: block; grid-template-rows: auto minmax(0, 1fr);
width: min(720px, calc(100vw - 32px)); width: min(720px, calc(100vw - 32px));
height: auto; height: auto;
max-height: calc(100vh - 32px); max-height: calc(100vh - 32px);
overflow: auto; overflow: hidden;
} }
.compare-select-shell { display: grid; grid-template-rows: minmax(0, 1fr) auto; min-height: 0; }
.compare-select-body { display: grid; align-content: start; gap: 12px; min-height: 0; padding: 18px 20px 20px; overflow: auto; }
.compare-target-panel { display: grid; gap: 16px; padding: 16px; border: 1px solid var(--color-border-subtle); border-radius: 12px; background: color-mix(in srgb, var(--app-settings-row-bg) 72%, transparent); }
.compare-target-heading h3 { margin: 0; color: var(--color-accent); font-size: 18px; font-weight: 700; line-height: 1.2; }
.compare-target-heading p { margin: 4px 0 0; color: var(--color-ink); font-size: 11px; font-weight: 400; line-height: 1.45; }
.compare-arrow-shell { display: grid; place-items: center; width: 40px; height: 40px; border: 1px solid var(--color-border-subtle); border-radius: 9px; color: var(--color-accent); background: var(--color-surface-raised); }
.compare-select-footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 62px; padding: 11px 16px; border-top: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.compare-select-footer > span { color: var(--color-ink-faint); font-size: 9.5px; }
.compare-select-footer > div { display: flex; gap: 8px; }
.compare-select-footer button { min-height: 32px; }
.interactive-rebase-dialog, .interactive-rebase-dialog,
.reflog-dialog { .reflog-dialog {
grid-template-rows: auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
@@ -3463,11 +3598,12 @@
border-radius: 5px; border-radius: 5px;
background: var(--code-surface-subtle); background: var(--code-surface-subtle);
} }
.rebase-action { height: 30px; font-family: var(--font-mono); font-weight: 800; } .rebase-action { font-family: var(--font-mono); font-weight: 800; }
.rebase-action.pick { color: var(--code-add-strong); } .rebase-action .select-menu-trigger { height: 30px; min-height: 30px; font-family: inherit; font-weight: inherit; }
.rebase-action.reword { color: var(--code-hunk-text); } .rebase-action.pick .select-menu-trigger { color: var(--code-add-strong); }
.rebase-action.squash, .rebase-action.fixup { color: #96620f; } .rebase-action.reword .select-menu-trigger { color: var(--code-hunk-text); }
.rebase-action.drop { color: var(--code-delete-strong); } .rebase-action.squash .select-menu-trigger, .rebase-action.fixup .select-menu-trigger { color: #96620f; }
.rebase-action.drop .select-menu-trigger { color: var(--code-delete-strong); }
.rebase-plan-row > code { color: var(--color-accent); font-family: var(--font-mono); font-size: 11px; font-weight: 800; } .rebase-plan-row > code { color: var(--color-accent); font-family: var(--font-mono); font-size: 11px; font-weight: 800; }
.rebase-commit-copy { display: grid; gap: 3px; min-width: 0; } .rebase-commit-copy { display: grid; gap: 3px; min-width: 0; }
.rebase-commit-copy strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; } .rebase-commit-copy strong { overflow: hidden; color: var(--color-ink); font-size: 12.5px; text-overflow: ellipsis; white-space: nowrap; }
@@ -3964,6 +4100,8 @@
.compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); } .compare-dialog .dialog-body { grid-template-columns: minmax(260px, 320px) minmax(0, 1fr); }
.dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); } .dialog-files { display: grid; align-content: start; gap: 4px; padding: 8px; overflow: auto; border-right: 1px solid var(--color-border-subtle); background: var(--app-dialog-chrome); }
.compare-files-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; min-height: 36px; margin: -8px -8px 4px; padding: 8px 12px; border-bottom: 1px solid var(--color-border-subtle); color: var(--color-ink-dim); font-size: 10px; font-weight: 850; letter-spacing: .05em; text-transform: uppercase; }
.compare-files-head strong { display: grid; place-items: center; min-width: 22px; height: 20px; padding-inline: 5px; border-radius: 10px; color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 12%, transparent); font-size: 9px; }
.dialog-file-row { .dialog-file-row {
display: grid; display: grid;
@@ -6512,6 +6650,25 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
box-shadow: 0 18px 50px rgba(28,44,74,0.18); box-shadow: 0 18px 50px rgba(28,44,74,0.18);
} }
:root[data-theme="light"] .select-menu-popup {
box-shadow: 0 18px 50px rgba(28,44,74,0.18);
}
:root[data-theme="light"] .select-menu-value > span,
:root[data-theme="light"] .select-menu-option,
:root[data-theme="light"] .select-menu-option.selected {
color: var(--color-ink);
}
:root[data-theme="light"] .select-menu-value.placeholder > span {
color: var(--color-ink-muted);
}
:root[data-theme="light"] .select-menu-option:hover:not(:disabled),
:root[data-theme="light"] .select-menu-option.active:not(:disabled) {
background: var(--color-surface-hover);
}
:root[data-theme="light"] .dialog-file-row:hover, :root[data-theme="light"] .dialog-file-row:hover,
:root[data-theme="light"] .branch-row:hover, :root[data-theme="light"] .branch-row:hover,
:root[data-theme="light"] .explorer-row:hover, :root[data-theme="light"] .explorer-row:hover,
@@ -6806,7 +6963,15 @@ input:focus, textarea:focus, select:focus { box-shadow: 0 0 0 3px color-mix(in s
.repo-summary { height: 40px; } .repo-summary { height: 40px; }
.change-lanes { grid-template-columns: 1fr; } .change-lanes { grid-template-columns: 1fr; }
.compare-form { grid-template-columns: 1fr; } .compare-form { grid-template-columns: 1fr; }
.compare-arrow { display: none; } .compare-arrow-shell { width: 100%; height: 30px; }
.compare-arrow-shell svg { transform: rotate(90deg); }
.compare-dialog-head { min-height: 60px; padding: 10px 12px; }
.compare-dialog-mark { width: 34px; height: 34px; }
.compare-dialog-title h2 { font-size: 15px; }
.compare-select-body { padding: 14px; }
.compare-target-panel { padding: 13px; }
.compare-select-footer > span { display: none; }
.compare-select-footer { justify-content: flex-end; }
.dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); } .compare-dialog .dialog-body { grid-template-columns: 1fr; grid-template-rows: minmax(120px, 0.4fr) minmax(0, 1fr); }
.global-search-options { grid-template-columns: 1fr; } .global-search-options { grid-template-columns: 1fr; }
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte"; import { GitCommitHorizontal, LoaderCircle, Sparkles, X } from "@lucide/svelte";
import type { AiCommitPlan } from "../types"; import type { AiCommitPlan } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
plan: AiCommitPlan; plan: AiCommitPlan;
@@ -62,9 +63,7 @@
<div class="split-files"> <div class="split-files">
{#each group.files as file} {#each group.files as file}
<div><code>{file}</code> <div><code>{file}</code>
<select value={groupIndex} onchange={(event) => moveFile(file, groupIndex, Number(event.currentTarget.value))} disabled={isApplying}> <SelectMenu class="split-file-target" value={String(groupIndex)} options={draft.groups.map((_, target) => ({ value: String(target), label: `Commit ${target + 1}` }))} disabled={isApplying} onChange={(value) => moveFile(file, groupIndex, Number(value))} />
{#each draft.groups as _, target}<option value={target}>Commit {target + 1}</option>{/each}
</select>
</div> </div>
{/each} {/each}
</div> </div>
@@ -98,6 +97,7 @@
.split-files{display:grid;gap:5px} .split-files{display:grid;gap:5px}
.split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)} .split-files>div{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:6px;background:var(--color-surface)}
code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap} code{min-width:0;flex:1;overflow:hidden;text-overflow:ellipsis;color:var(--color-ink-muted);font-size:11px;white-space:nowrap}
select{height:28px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface-raised);color:var(--color-ink);font-size:11px} :global(.split-file-target){width:110px;flex:0 0 110px}
:global(.split-file-target .select-menu-trigger){height:28px;min-height:28px;font-size:11px}
.dialog-footer p.invalid{color:#e0a040} .dialog-footer p.invalid{color:#e0a040}
</style> </style>
+2 -5
View File
@@ -3,6 +3,7 @@
import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte"; import { AlertCircle, Bot, Check, Cpu, Eye, EyeOff, Gauge, Globe, Key, LoaderCircle, Sparkles, X, Zap } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git"; import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types"; import type { AiSettings, CommitAiLocalProfile, CommitAiProvider, LocalModelOption } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
settings: AiSettings; settings: AiSettings;
@@ -200,11 +201,7 @@
</div> </div>
<label class="cred-field"> <label class="cred-field">
<span class="cred-field-label">Model</span> <span class="cred-field-label">Model</span>
<select bind:value={localModelId}> <SelectMenu value={localModelId} options={localModels.map((option) => ({ value: option.id, label: `${option.label} - ${formatSize(option.approx_size_mb)}` }))} onChange={(value) => { localModelId = value; }} />
{#each localModels as option (option.id)}
<option value={option.id}>{option.label} {formatSize(option.approx_size_mb)}</option>
{/each}
</select>
</label> </label>
<div class="cred-token-hint"> <div class="cred-token-hint">
<AlertCircle size={13} aria-hidden="true" /> <AlertCircle size={13} aria-hidden="true" />
+14 -13
View File
@@ -37,6 +37,7 @@
ExternalToolsSettings, ExternalToolsSettings,
ToolOpenMode, ToolOpenMode,
} from "../types"; } from "../types";
import SelectMenu from "./SelectMenu.svelte";
type SettingsPage = "general" | "tools"; type SettingsPage = "general" | "tools";
@@ -390,17 +391,17 @@
<label class="tool-default-field"> <label class="tool-default-field">
<span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span> <span>{isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}</span>
<select value={tools[activeToolKind].preset} onchange={(event) => changePreset(activeToolKind, event.currentTarget.value)} aria-label={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}> <SelectMenu
{#if availablePresets(activeToolKind).length > 0} class="tool-preset-select"
<optgroup label={isGerman ? "Installiert" : "Installed"}> value={tools[activeToolKind].preset}
{#each availablePresets(activeToolKind) as preset}<option value={preset.id}> {preset.label}</option>{/each} options={[
</optgroup> ...availablePresets(activeToolKind).map((preset) => ({ value: preset.id, label: `Installed - ${preset.label}`, group: isGerman ? "Installiert" : "Installed" })),
{/if} ...otherPresets(activeToolKind).map((preset) => ({ value: preset.id, label: preset.label, group: isGerman ? "Weitere unterstützte Programme" : "Other supported applications" })),
<optgroup label={isGerman ? "Weitere unterstützte Programme" : "Other supported applications"}> { value: "custom", label: isGerman ? "Eigenes Programm auswählen..." : "Choose a custom application..." },
{#each otherPresets(activeToolKind) as preset}<option value={preset.id}>{preset.label}</option>{/each} ]}
</optgroup> ariaLabel={isGerman ? `Standardprogramm für ${toolLabel(activeToolKind)}` : `Default application for ${toolLabel(activeToolKind)}`}
<option value="custom">{isGerman ? "Eigenes Programm auswählen…" : "Choose a custom application…"}</option> onChange={(value) => changePreset(activeToolKind, value)}
</select> />
<small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small> <small>{isGerman ? "Diese Auswahl wird gespeichert und für alle passenden Aktionen verwendet." : "This selection is saved and used for every matching action."}</small>
</label> </label>
@@ -504,8 +505,8 @@
.tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; } .tool-open-mode button span { max-width: 100%; overflow: hidden; font-size: 11px; font-weight: 800; text-overflow: ellipsis; white-space: nowrap; }
.tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; } .tool-open-mode button small { color: var(--color-ink-faint); font-size: 9px; font-weight: 550; }
.tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; } .tool-default-field, .tool-advanced-panel label { display: grid; gap: 6px; min-width: 0; color: var(--color-ink-muted); font-size: 10.5px; font-weight: 750; }
.tool-default-field select, .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; } .tool-advanced-panel input, .tool-advanced-panel textarea { width: 100%; min-width: 0; border: 1px solid var(--color-border); border-radius: 7px; color: var(--color-ink); background: var(--color-surface-raised); font: inherit; }
.tool-default-field select { height: 38px; padding: 0 11px; font-size: 12px; font-weight: 700; } :global(.tool-preset-select .select-menu-trigger) { height: 38px; min-height: 38px; padding: 0 11px; border-color: var(--color-border); font-size: 12px; font-weight: 700; }
.tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; } .tool-default-field small { color: var(--color-ink-faint); font-size: 9.5px; font-weight: 500; }
.tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); } .tool-usage-callout { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; gap: 10px; padding: 10px 11px; border-left: 2px solid var(--color-accent); border-radius: 0 7px 7px 0; background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; } .tool-usage-callout span { color: var(--color-accent); font-size: 9.5px; font-weight: 850; text-transform: uppercase; letter-spacing: .04em; }
+2 -5
View File
@@ -11,6 +11,7 @@
X, X,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { AppLanguage, GitCommit, GitRemote } from "../types"; import type { AppLanguage, GitCommit, GitRemote } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
commit: GitCommit; commit: GitCommit;
@@ -196,11 +197,7 @@
<div class="commit-note-sync-controls"> <div class="commit-note-sync-controls">
<label> <label>
<span>{text.remote}</span> <span>{text.remote}</span>
<select bind:value={selectedRemote} disabled={isBusy}> <SelectMenu value={selectedRemote} options={remotes.map((remote) => ({ value: remote.name, label: remote.name }))} disabled={isBusy} onChange={(value) => { selectedRemote = value; }} />
{#each remotes as remote (remote.name)}
<option value={remote.name}>{remote.name}</option>
{/each}
</select>
</label> </label>
<button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}> <button type="button" onclick={() => onFetch(selectedRemote)} disabled={isBusy || !selectedRemote || hasChanges}>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if} {#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Download size={15} aria-hidden="true" />{/if}
+23 -12
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { ArrowRight, FileCode, RotateCcw, X } from "@lucide/svelte"; import { ArrowRight, FileCode, GitCompare, RotateCcw, X } from "@lucide/svelte";
import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types"; import type { GitCommitComparison, GitDiffFile, FileStatusKind } from "../types";
type SplitRow = type SplitRow =
@@ -25,6 +25,7 @@
restoreLabel?: string; restoreLabel?: string;
/** When opened from a search hit, the term to highlight on matching lines. */ /** When opened from a search hit, the term to highlight on matching lines. */
highlightQuery?: string; highlightQuery?: string;
language?: "en" | "de";
onClose: () => void; onClose: () => void;
onRestore?: () => void; onRestore?: () => void;
onSelectFile: (file: GitDiffFile) => void; onSelectFile: (file: GitDiffFile) => void;
@@ -38,11 +39,14 @@
toLabel = "", toLabel = "",
restoreLabel = "", restoreLabel = "",
highlightQuery = "", highlightQuery = "",
language = "en",
onClose = () => {}, onClose = () => {},
onRestore = undefined, onRestore = undefined,
onSelectFile = () => {}, onSelectFile = () => {},
}: Props = $props(); }: Props = $props();
let isGerman = $derived(language === "de");
// Needle = first non-empty line of the search query, lowercased for matching. // Needle = first non-empty line of the search query, lowercased for matching.
let highlightNeedle = $derived( let highlightNeedle = $derived(
highlightQuery highlightQuery
@@ -225,14 +229,17 @@
> >
<div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1"> <div class="dialog compare-dialog" role="dialog" aria-modal="true" aria-label="Branch or commit comparison" tabindex="-1">
<header class="dialog-header"> <header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div> <div>
<span class="eyebrow">Compare</span> <h2>{isGerman ? "Änderungen vergleichen" : "Compare changes"}</h2>
<h2 class="dialog-range"> <p class="dialog-range">
<span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span> <span class="hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
<ArrowRight size={14} aria-hidden="true" /> <ArrowRight size={14} aria-hidden="true" />
<span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span> <span class="hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</h2> </p>
</div>
</div> </div>
<div class="dialog-header-actions"> <div class="dialog-header-actions">
{#if restoreLabel && onRestore} {#if restoreLabel && onRestore}
@@ -241,19 +248,23 @@
<span>{restoreLabel}</span> <span>{restoreLabel}</span>
</button> </button>
{/if} {/if}
<button class="dialog-close" type="button" onclick={onClose} title="Close"> <button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
</button> </button>
</div> </div>
</header> </header>
{#if comparison.files.length === 0} {#if comparison.files.length === 0}
<div class="blank-state">No differences these versions are identical.</div> <div class="blank-state">{isGerman ? "Keine Unterschiede - diese Versionen sind identisch." : "No differences - these versions are identical."}</div>
{:else} {:else}
<div class="dialog-body"> <div class="dialog-body">
<!-- File list --> <!-- File list -->
<aside class="dialog-files" aria-label="Changed files"> <aside class="dialog-files" aria-label={isGerman ? "Geänderte Dateien" : "Changed files"}>
<div class="compare-files-head">
<span>{isGerman ? "Geänderte Dateien" : "Changed files"}</span>
<strong>{comparison.files.length}</strong>
</div>
{#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)} {#each comparison.files as file (`${file.old_path ?? ""}:${file.path}`)}
<button <button
class="dialog-file-row" class="dialog-file-row"
@@ -275,9 +286,9 @@
<!-- Diff pane --> <!-- Diff pane -->
<div class="dialog-diff"> <div class="dialog-diff">
{#if !selectedFile} {#if !selectedFile}
<div class="blank-state">Select a file to see its changes.</div> <div class="blank-state">{isGerman ? "Wähle eine Datei aus, um ihre Änderungen zu sehen." : "Select a file to see its changes."}</div>
{:else if splitRows.length === 0} {:else if splitRows.length === 0}
<div class="blank-state">No textual changes for this file.</div> <div class="blank-state">{isGerman ? "Keine textuellen Änderungen für diese Datei." : "No textual changes for this file."}</div>
{:else} {:else}
<!-- Path bar --> <!-- Path bar -->
<div class="diff-header"> <div class="diff-header">
@@ -292,11 +303,11 @@
<!-- Column headers --> <!-- Column headers -->
<div class="split-col-headers"> <div class="split-col-headers">
<div class="split-col-label"> <div class="split-col-label">
<span>Before</span> <span>{isGerman ? "Vorher" : "Before"}</span>
<span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span> <span class="split-col-hash" title={comparison.from_hash}>{fromLabel || comparison.from_short}</span>
</div> </div>
<div class="split-col-label"> <div class="split-col-label">
<span>After</span> <span>{isGerman ? "Nachher" : "After"}</span>
<span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span> <span class="split-col-hash" title={comparison.to_hash}>{toLabel || comparison.to_short}</span>
</div> </div>
</div> </div>
+5 -20
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte"; import { ArrowRight, GitCompare, LoaderCircle } from "@lucide/svelte";
import type { GitCommit, GitCommitComparison } from "../types"; import type { GitCommit, GitCommitComparison } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
@@ -40,6 +41,8 @@
event.preventDefault(); event.preventDefault();
onCompare(); onCompare();
} }
let commitOptions = $derived(commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })));
</script> </script>
<section class="compare-panel panel" aria-label="Compare commits"> <section class="compare-panel panel" aria-label="Compare commits">
@@ -61,32 +64,14 @@
<form class="compare-form" onsubmit={handleSubmit}> <form class="compare-form" onsubmit={handleSubmit}>
<label class="compare-field"> <label class="compare-field">
<span>From (older)</span> <span>From (older)</span>
<select <SelectMenu value={compareFrom} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
</label> </label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" /> <ArrowRight class="compare-arrow" size={18} aria-hidden="true" />
<label class="compare-field"> <label class="compare-field">
<span>To (newer)</span> <span>To (newer)</span>
<select <SelectMenu value={compareTo} options={commitOptions} placeholder="Select a commit" showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a commit</option>
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</select>
</label> </label>
<button class="btn-primary" type="submit" disabled={!canCompare}> <button class="btn-primary" type="submit" disabled={!canCompare}>
+50 -78
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { ArrowRight, GitCompare, LoaderCircle, X } from "@lucide/svelte"; import { ArrowRight, GitCompare, Info, LoaderCircle, X } from "@lucide/svelte";
import type { GitBranch, GitCommit } from "../types"; import type { GitBranch, GitCommit } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface Props { interface Props {
commits: GitCommit[]; commits: GitCommit[];
@@ -10,6 +11,7 @@
canCompare: boolean; canCompare: boolean;
isBusy: boolean; isBusy: boolean;
operation: string; operation: string;
language?: "en" | "de";
onCompareFromChange: (val: string) => void; onCompareFromChange: (val: string) => void;
onCompareToChange: (val: string) => void; onCompareToChange: (val: string) => void;
onCompare: () => void; onCompare: () => void;
@@ -24,12 +26,15 @@
canCompare = false, canCompare = false,
isBusy = false, isBusy = false,
operation = "", operation = "",
language = "en",
onCompareFromChange = () => {}, onCompareFromChange = () => {},
onCompareToChange = () => {}, onCompareToChange = () => {},
onCompare = () => {}, onCompare = () => {},
onClose = () => {}, onClose = () => {},
}: Props = $props(); }: Props = $props();
let isGerman = $derived(language === "de");
function commitOptionLabel(item: GitCommit): string { function commitOptionLabel(item: GitCommit): string {
return `${item.short_hash} - ${item.summary}`; return `${item.short_hash} - ${item.summary}`;
} }
@@ -41,6 +46,11 @@
let localBranches = $derived(branches.filter((branch) => !branch.remote)); let localBranches = $derived(branches.filter((branch) => !branch.remote));
let remoteBranches = $derived(branches.filter((branch) => branch.remote)); let remoteBranches = $derived(branches.filter((branch) => branch.remote));
let targetCount = $derived(branches.length + commits.length); let targetCount = $derived(branches.length + commits.length);
let compareOptions = $derived([
...localBranches.map((branch) => ({ value: branchValue(branch), label: `${branch.name}${branch.current ? " (current)" : ""}`, group: "Branches - Local" })),
...remoteBranches.map((branch) => ({ value: branchValue(branch), label: branch.name, group: "Branches - Remote" })),
...commits.map((item) => ({ value: item.hash, label: commitOptionLabel(item), group: "Commits" })),
]);
function handleSubmit(event: SubmitEvent) { function handleSubmit(event: SubmitEvent) {
event.preventDefault(); event.preventDefault();
@@ -52,104 +62,66 @@
class="dialog-backdrop" class="dialog-backdrop"
role="presentation" role="presentation"
> >
<div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label="Select branches or commits to compare" tabindex="-1"> <div class="dialog compare-select-dialog" role="dialog" aria-modal="true" aria-label={isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"} tabindex="-1">
<header class="dialog-header"> <header class="compare-dialog-head">
<div class="compare-dialog-title">
<span class="compare-dialog-mark"><GitCompare size={18} aria-hidden="true" /></span>
<div> <div>
<span class="eyebrow">Compare</span> <h2>{isGerman ? "Branches oder Commits vergleichen" : "Compare branches or commits"}</h2>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Compare branches or commits</h2> <p>{isGerman ? "Zwei Repository-Stände direkt gegenüberstellen" : "Review two repository states side by side"}</p>
</div> </div>
<button class="dialog-close" type="button" onclick={onClose} title="Close"> </div>
<button class="dialog-close" type="button" onclick={onClose} title={isGerman ? "Schließen" : "Close"} aria-label={isGerman ? "Vergleich schließen" : "Close comparison"}>
<X size={18} aria-hidden="true" /> <X size={18} aria-hidden="true" />
</button> </button>
</header> </header>
<form class="compare-select-shell" onsubmit={handleSubmit}>
<div class="compare-select-body">
{#if targetCount < 2} {#if targetCount < 2}
<div class="blank-state">At least two branches or commits are needed to compare.</div> <div class="blank-state">{isGerman ? "Für einen Vergleich werden mindestens zwei Branches oder Commits benötigt." : "At least two branches or commits are needed to compare."}</div>
{:else} {:else}
<form class="compare-form" onsubmit={handleSubmit}> <section class="compare-target-panel">
<div class="compare-target-heading">
<h3>{isGerman ? "Vergleichsbereich" : "Comparison range"}</h3>
<p>{isGerman ? "Wähle Ausgangspunkt und Ziel des Vergleichs." : "Choose the starting point and target for the comparison."}</p>
</div>
<div class="compare-form">
<label class="compare-field"> <label class="compare-field">
<span>Base</span> <span>{isGerman ? "Ausgangsbasis" : "Base"}</span>
<select <SelectMenu class="compare-target-select" value={compareFrom} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareFromChange} />
value={compareFrom}
onchange={(e) => onCompareFromChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a branch or commit</option>
{#if localBranches.length > 0}
<optgroup label="Local branches">
{#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select>
</label> </label>
<ArrowRight class="compare-arrow" size={18} aria-hidden="true" /> <span class="compare-arrow-shell"><ArrowRight size={18} aria-hidden="true" /></span>
<label class="compare-field"> <label class="compare-field">
<span>Compare with</span> <span>{isGerman ? "Vergleichen mit" : "Compare with"}</span>
<select <SelectMenu class="compare-target-select" value={compareTo} options={compareOptions} placeholder={isGerman ? "Branch oder Commit wählen" : "Select a branch or commit"} showSelectedGroup disabled={isBusy} onChange={onCompareToChange} />
value={compareTo}
onchange={(e) => onCompareToChange((e.target as HTMLSelectElement).value)}
disabled={isBusy}
>
<option value="" disabled>Select a branch or commit</option>
{#if localBranches.length > 0}
<optgroup label="Local branches">
{#each localBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}{branch.current ? " (current)" : ""}</option>
{/each}
</optgroup>
{/if}
{#if remoteBranches.length > 0}
<optgroup label="Remote branches">
{#each remoteBranches as branch (branch.name)}
<option value={branchValue(branch)}>{branch.name}</option>
{/each}
</optgroup>
{/if}
{#if commits.length > 0}
<optgroup label="Recent commits">
{#each commits as item (item.hash)}
<option value={item.hash}>{commitOptionLabel(item)}</option>
{/each}
</optgroup>
{/if}
</select>
</label> </label>
</div>
</section>
<button class="btn-primary" type="submit" disabled={!canCompare}> {#if compareFrom && compareTo && compareFrom === compareTo}
<div class="compare-target-help compare-target-warning"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Wähle zwei unterschiedliche Branches oder Commits." : "Select two different branches or commits to compare."}</span></div>
{:else}
<div class="compare-target-help"><Info size={15} aria-hidden="true" /><span>{isGerman ? "Verglichen werden die vollständigen Repository-Stände. Nicht committete Änderungen im Arbeitsverzeichnis sind nicht enthalten." : "The complete repository states are compared. Uncommitted working-tree changes are not included."}</span></div>
{/if}
{/if}
</div>
<footer class="compare-select-footer">
<span>{isGerman ? `${targetCount} Ziele verfügbar` : `${targetCount} targets available`}</span>
<div>
<button class="btn-secondary" type="button" onclick={onClose}>{isGerman ? "Abbrechen" : "Cancel"}</button>
<button class="btn-primary" type="submit" disabled={!canCompare || targetCount < 2}>
{#if operation === "Comparing revisions"} {#if operation === "Comparing revisions"}
<LoaderCircle class="spin" size={16} aria-hidden="true" /> <LoaderCircle class="spin" size={16} aria-hidden="true" />
{:else} {:else}
<GitCompare size={16} aria-hidden="true" /> <GitCompare size={16} aria-hidden="true" />
{/if} {/if}
Compare {isGerman ? "Vergleich öffnen" : "Open comparison"}
</button> </button>
</div>
</footer>
</form> </form>
{#if compareFrom && compareTo && compareFrom === compareTo}
<div class="blank-state">Select two different branches or commits to compare.</div>
{:else}
<div class="compare-target-help">
The two branch tips are compared across the entire repository. Uncommitted working-tree changes are not included.
</div>
{/if}
{/if}
</div> </div>
</div> </div>
+2 -6
View File
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SelectMenu from "./SelectMenu.svelte";
import { import {
CalendarDays, CalendarDays,
FileCode, FileCode,
@@ -193,12 +194,7 @@
<label class="search-limit"> <label class="search-limit">
<span>Results</span> <span>Results</span>
<select bind:value={limit} disabled={!hasRepository || isBusy || isSearching}> <SelectMenu value={String(limit)} options={[100, 250, 500, 1000].map((value) => ({ value: String(value), label: String(value) }))} disabled={!hasRepository || isBusy || isSearching} onChange={(value) => { limit = Number(value); }} />
<option value={100}>100</option>
<option value={250}>250</option>
<option value={500}>500</option>
<option value={1000}>1000</option>
</select>
</label> </label>
<button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}> <button class="btn-primary" type="submit" disabled={!hasRepository || isBusy || isSearching || query.trim().length === 0}>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte"; import { AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types"; import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
import SelectMenu from "./SelectMenu.svelte";
interface PlanRow extends RebaseCommit { interface PlanRow extends RebaseCommit {
action: RebaseAction; action: RebaseAction;
@@ -41,6 +42,7 @@
)); ));
let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim())); let invalidReword = $derived(rows.some((row) => row.action === "reword" && !row.message.trim()));
let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy); let canStart = $derived(Boolean(base) && rows.length > 0 && keptCount > 0 && !invalidSquash && !invalidReword && !isLoading && !isBusy);
const rebaseActions: RebaseAction[] = ["pick", "reword", "squash", "fixup", "drop"];
function updateAction(index: number, action: RebaseAction) { function updateAction(index: number, action: RebaseAction) {
rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row); rows = rows.map((row, rowIndex) => rowIndex === index ? { ...row, action } : row);
@@ -82,12 +84,7 @@
<section class="rebase-base-bar"> <section class="rebase-base-bar">
<label> <label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span> <span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<select value={base} onchange={(event) => onBaseChange((event.target as HTMLSelectElement).value)} disabled={isBusy || isLoading}> <SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: `${branch.remote ? "Remote - " : "Local - "}${branch.name}` }))} placeholder="Select a base branch" disabled={isBusy || isLoading} onChange={onBaseChange} />
<option value="" disabled>Select a base branch</option>
{#each availableBases as branch (branch.name)}
<option value={branch.name}>{branch.remote ? "Remote · " : "Local · "}{branch.name}</option>
{/each}
</select>
</label> </label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p> <p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
</section> </section>
@@ -110,9 +107,7 @@
<button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button> <button type="button" onclick={() => move(index, -1)} disabled={isBusy || index === 0} title="Move up"><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button> <button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title="Move down"><ArrowDown size={14} aria-hidden="true" /></button>
</div> </div>
<select class={`rebase-action ${row.action}`} value={row.action} onchange={(event) => updateAction(index, (event.target as HTMLSelectElement).value as RebaseAction)} disabled={isBusy} aria-label={`Action for ${row.short_hash}`}> <SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={`Action for ${row.short_hash}`} onChange={(value) => updateAction(index, value as RebaseAction)} />
<option value="pick">pick</option><option value="reword">reword</option><option value="squash">squash</option><option value="fixup">fixup</option><option value="drop">drop</option>
</select>
<code>{row.short_hash}</code> <code>{row.short_hash}</code>
<div class="rebase-commit-copy"> <div class="rebase-commit-copy">
{#if row.action === "reword"} {#if row.action === "reword"}
+176
View File
@@ -0,0 +1,176 @@
<script lang="ts">
import { tick } from "svelte";
import { Check, ChevronDown } from "@lucide/svelte";
export interface SelectMenuOption {
value: string;
label: string;
group?: string;
disabled?: boolean;
}
interface Props {
value: string;
options: SelectMenuOption[];
placeholder?: string;
disabled?: boolean;
ariaLabel?: string;
class?: string;
showSelectedGroup?: boolean;
onChange: (value: string) => void;
}
let {
value,
options = [],
placeholder = "Select an option",
disabled = false,
ariaLabel = "",
class: className = "",
showSelectedGroup = false,
onChange,
}: Props = $props();
let root = $state<HTMLDivElement>();
let trigger = $state<HTMLButtonElement>();
let open = $state(false);
let activeIndex = $state(-1);
let menuStyle = $state("");
const menuId = `select-menu-${Math.random().toString(36).slice(2)}`;
let selectedOption = $derived(options.find((option) => option.value === value));
let enabledIndices = $derived(options.map((option, index) => option.disabled ? -1 : index).filter((index) => index >= 0));
function groupCount(group: string): number {
return options.filter((option) => option.group === group).length;
}
function positionMenu() {
if (!trigger) return;
const rect = trigger.getBoundingClientRect();
const viewportGap = 8;
const menuGap = 5;
const desiredHeight = Math.min(300, options.length * 34 + 24);
const spaceBelow = window.innerHeight - rect.bottom - viewportGap;
const spaceAbove = rect.top - viewportGap;
const openAbove = spaceBelow < Math.min(desiredHeight, 180) && spaceAbove > spaceBelow;
const maxHeight = Math.max(96, Math.min(desiredHeight, openAbove ? spaceAbove - menuGap : spaceBelow - menuGap));
const width = Math.max(rect.width, 180);
const left = Math.min(rect.left, window.innerWidth - width - viewportGap);
const top = openAbove ? rect.top - menuGap : rect.bottom + menuGap;
menuStyle = `left:${Math.max(viewportGap, left)}px;${openAbove ? `bottom:${window.innerHeight - top}px;` : `top:${top}px;`}min-width:${width}px;max-width:${Math.max(180, window.innerWidth - viewportGap * 2)}px;max-height:${maxHeight}px;`;
}
async function show() {
if (disabled || enabledIndices.length === 0) return;
const selectedIndex = options.findIndex((option) => option.value === value && !option.disabled);
activeIndex = selectedIndex >= 0 ? selectedIndex : enabledIndices[0];
open = true;
await tick();
positionMenu();
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
function close() {
open = false;
}
function choose(index: number) {
const option = options[index];
if (!option || option.disabled) return;
onChange(option.value);
close();
trigger?.focus();
}
function moveActive(direction: -1 | 1) {
if (enabledIndices.length === 0) return;
const current = enabledIndices.indexOf(activeIndex);
const next = current < 0
? (direction > 0 ? 0 : enabledIndices.length - 1)
: (current + direction + enabledIndices.length) % enabledIndices.length;
activeIndex = enabledIndices[next];
document.getElementById(`${menuId}-option-${activeIndex}`)?.scrollIntoView({ block: "nearest" });
}
function handleKeydown(event: KeyboardEvent) {
if (disabled) return;
if (!open && ["ArrowDown", "ArrowUp", "Enter", " "].includes(event.key)) {
event.preventDefault();
void show();
return;
}
if (!open) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
moveActive(event.key === "ArrowDown" ? 1 : -1);
} else if (event.key === "Home" || event.key === "End") {
event.preventDefault();
activeIndex = event.key === "Home" ? enabledIndices[0] : enabledIndices[enabledIndices.length - 1];
} else if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
choose(activeIndex);
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
}
close();
}
}
function handleWindowPointerDown(event: PointerEvent) {
if (open && root && !root.contains(event.target as Node)) close();
}
</script>
<svelte:window onpointerdown={handleWindowPointerDown} onresize={positionMenu} onscroll={positionMenu} />
<div class={`select-menu ${className}`} class:open bind:this={root}>
<button
bind:this={trigger}
class="select-menu-trigger"
type="button"
{disabled}
aria-label={ariaLabel || undefined}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={open ? menuId : undefined}
onclick={() => open ? close() : void show()}
onkeydown={handleKeydown}
>
<span class="select-menu-value" class:placeholder={!selectedOption} title={selectedOption?.label}>
{#if showSelectedGroup && selectedOption?.group}<small>{selectedOption.group}</small>{/if}
<span>{selectedOption?.label ?? placeholder}</span>
</span>
<ChevronDown size={14} aria-hidden="true" />
</button>
{#if open}
<div id={menuId} class="select-menu-popup" style={menuStyle} role="listbox" aria-label={ariaLabel || undefined}>
{#each options as option, index (`${option.value}:${index}`)}
{#if option.group && (index === 0 || options[index - 1]?.group !== option.group)}
<div class="select-menu-group" role="presentation">
<span>{option.group}</span>
<small>{groupCount(option.group)}</small>
</div>
{/if}
<button
id={`${menuId}-option-${index}`}
class="select-menu-option"
class:active={index === activeIndex}
class:selected={option.value === value}
type="button"
role="option"
aria-selected={option.value === value}
disabled={option.disabled}
onmouseenter={() => { if (!option.disabled) activeIndex = index; }}
onclick={() => choose(index)}
>
<span>{option.label}</span>
{#if option.value === value}<Check size={14} aria-hidden="true" />{/if}
</button>
{/each}
</div>
{/if}
</div>
+3 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte"; import { Cloud, GitBranch, Plus, Save, Trash2, X } from "@lucide/svelte";
import type { GitRemote, PullStrategy } from "../types"; import type { GitRemote, PullStrategy } from "../types";
import SelectMenu from "./SelectMenu.svelte";
export let remotes: GitRemote[] = []; export let remotes: GitRemote[] = [];
export let remoteBranches: string[] = []; export let remoteBranches: string[] = [];
@@ -76,8 +77,8 @@
</div> </div>
<div class="sync-fields"> <div class="sync-fields">
<label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><select bind:value={draftRemote}><option value="">{de ? "Automatisch wählen" : "Choose automatically"}</option>{#each remotes as remote}<option value={remote.name}>{remote.name}</option>{/each}</select><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label> <label><span>{de ? "Remote für Sync" : "Remote used for sync"}</span><SelectMenu value={draftRemote} options={[{ value: "", label: de ? "Automatisch wählen" : "Choose automatically" }, ...remotes.map((remote) => ({ value: remote.name, label: remote.name }))]} onChange={(value) => { draftRemote = value; }} /><small>{de ? "Ein Remote ist die gespeicherte Verbindung zu einem Server-Repository." : "A remote is a saved connection to a repository on a server."}</small></label>
<label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><select bind:value={draftUpstream}><option value="">{de ? "Kein Upstream" : "No upstream"}</option>{#each remoteBranches as branch}<option value={branch}>{branch}</option>{/each}</select><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label> <label><span>{de ? `Upstream für ${currentBranch || "aktuellen Branch"}` : `Upstream for ${currentBranch || "current branch"}`}</span><SelectMenu value={draftUpstream} options={[{ value: "", label: de ? "Kein Upstream" : "No upstream" }, ...remoteBranches.map((branch) => ({ value: branch, label: branch }))]} onChange={(value) => { draftUpstream = value; }} /><small>{de ? "Der Upstream ist der Remote-Branch, mit dem Pull, Push und Ahead/Behind verglichen werden." : "The upstream is the remote branch used by Pull, Push, and Ahead/Behind."}</small></label>
</div> </div>
</section> </section>
+12 -6
View File
@@ -21,6 +21,7 @@
X, X,
} from "@lucide/svelte"; } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types"; import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
import SelectMenu from "./SelectMenu.svelte";
type CreateMode = "existing" | "new" | "detached"; type CreateMode = "existing" | "new" | "detached";
@@ -260,12 +261,17 @@
{#if createMode === "existing"} {#if createMode === "existing"}
<label> <label>
<span>Branch</span> <span>Branch</span>
<select bind:value={selectedBranch} disabled={isBusy}> <SelectMenu
<option value="" disabled>Select a local branch</option> value={selectedBranch}
{#each localBranches as branch (branch.name)} placeholder="Select a local branch"
<option value={branch.name} disabled={!branchAvailable(branch.name)}>{branch.name}{!branchAvailable(branch.name) ? " (already checked out)" : ""}</option> options={localBranches.map((branch) => ({
{/each} value: branch.name,
</select> label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
disabled: !branchAvailable(branch.name),
}))}
disabled={isBusy}
onChange={(value) => { selectedBranch = value; }}
/>
</label> </label>
{:else if createMode === "new"} {:else if createMode === "new"}
<label> <label>