feat(confirm): centralize confirmation dialogs and add i18n

Introduce a generic ConfirmDialog and a promise-based requestConfirmation API in
App.svelte so callers can await user responses instead of using window.confirm.
Provide helper builders (branchDeleteConfirmRequest, discardConfirmRequest) to
create dialog content for common cases. Many call sites were switched to use
requestConfirmation and now render the in-app ConfirmDialog; the previous
specialized confirm components (BranchDeleteConfirmDialog, DiscardConfirmDialog)
were removed.

Add lightweight i18n support (setLanguage, t()) and new messages/i18n modules,
and replace hardcoded English strings in several components (e.g. AiSettingsPage,
BlameDialog and many confirmation prompts) with translated keys.

Summary of effects:
- Replaces native window.confirm with awaitable in-app ConfirmDialog dialogs.
- Centralizes confirmation UI and content construction in App.svelte.
- Adds i18n plumbing and updates UI text to use t().
- Removes two specialized confirm dialog components and adds src/lib/components/ConfirmDialog.svelte.
This commit is contained in:
2026-09-17 21:41:35 +02:00
parent b00e3e5c18
commit 3e87c8f6a9
19 changed files with 1373 additions and 589 deletions
+14 -13
View File
@@ -3,6 +3,7 @@
import { Bot, Eye, EyeOff, Globe, Key } from "@lucide/svelte";
import { credDelete, credLoad, credSave } from "../git";
import type { AiSettings, CommitAiProvider } from "../types";
import { t } from "../i18n.svelte";
interface Props {
settings: AiSettings;
@@ -81,7 +82,7 @@
async function persistKey(target: CloudProvider, value: string) {
if (value === originalKeys[target]) return;
if (!keysLoaded) throw new Error("API keys could not be loaded. Existing credentials have been preserved.");
if (!keysLoaded) throw new Error(t("ai.keysNotLoaded"));
const key = CRED_KEYS[target];
const trimmed = value.trim();
if (trimmed) {
@@ -92,7 +93,7 @@
}
export async function saveSettings(): Promise<AiSettings> {
if (loadingKeys) throw new Error("Please wait for AI settings to load.");
if (loadingKeys) throw new Error(t("ai.waitForSettings"));
saving = true;
error = "";
try {
@@ -119,7 +120,7 @@
</script>
<div class="ai-settings-form">
<div class="ai-provider-options" role="radiogroup" aria-label="AI provider">
<div class="ai-provider-options" role="radiogroup" aria-label={t("ai.providerLabel")}>
<button type="button" class="ai-provider-option" class:active={provider === "openai"} onclick={() => { provider = "openai"; }}>
<Bot size={16} aria-hidden="true" />
OpenAI
@@ -130,17 +131,17 @@
</button>
<button type="button" class="ai-provider-option" class:active={provider === "custom"} onclick={() => { provider = "custom"; }}>
<Globe size={16} aria-hidden="true" />
Custom endpoint
{t("ai.custom")}
</button>
</div>
{#if provider === "openai"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<span class="cred-field-label">{t("ai.model")}</span>
<input type="text" bind:value={openaiModel} placeholder="gpt-4o-mini" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<span class="cred-field-label">{t("ai.apiKey")}</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
@@ -158,11 +159,11 @@
</div>
{:else if provider === "anthropic"}
<label class="cred-field">
<span class="cred-field-label">Model</span>
<span class="cred-field-label">{t("ai.model")}</span>
<input type="text" bind:value={anthropicModel} placeholder="claude-3-5-haiku-latest" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key</span>
<span class="cred-field-label">{t("ai.apiKey")}</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
@@ -180,21 +181,21 @@
</div>
{:else}
<label class="cred-field">
<span class="cred-field-label">Endpoint URL</span>
<span class="cred-field-label">{t("ai.endpointUrl")}</span>
<input type="text" bind:value={customBaseUrl} placeholder="http://localhost:11434/v1" autocomplete="off" spellcheck="false" />
</label>
<label class="cred-field">
<span class="cred-field-label">Model</span>
<span class="cred-field-label">{t("ai.model")}</span>
<input type="text" bind:value={customModel} placeholder="llama3.1" autocomplete="off" spellcheck="false" />
</label>
<div class="cred-field">
<span class="cred-field-label">API key (optional)</span>
<span class="cred-field-label">{t("ai.apiKeyOptional")}</span>
<div class="cred-input">
<Key size={15} class="cred-field-icon" aria-hidden="true" />
<input
type={showKey ? "text" : "password"}
bind:value={customApiKey}
placeholder="Optional"
placeholder={t("ai.optional")}
autocomplete="off"
spellcheck="false"
disabled={loadingKeys}
@@ -206,7 +207,7 @@
</div>
<div class="cred-token-hint">
<Globe size={13} aria-hidden="true" />
<span>For local OpenAI-compatible servers like Ollama or LM Studio. The base URL should end in /v1.</span>
<span>{t("ai.customHint")}</span>
</div>
{/if}
+15 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import {FileText, FileCode, LoaderCircle, Search, X } from "@lucide/svelte";
import type { GitBlameLine } from "../types";
import { t } from "../i18n.svelte";
const commitDateFormatter = new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
@@ -75,7 +76,7 @@
}
function groupTooltip(group: BlameGroup): string {
if (group.isUncommitted) return "Not committed yet";
if (group.isUncommitted) return t("blame.uncommitted");
return `${group.authorName} <${group.authorEmail}>\n${group.summary}\n${group.hash}`;
}
@@ -132,16 +133,16 @@
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label="File blame">
<div class="dialog blame-dialog" role="dialog" aria-modal="true" aria-label={t("blame.dialogLabel")}>
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><FileText size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Blame</span>
<span class="eyebrow">{t("blame.eyebrow")}</span>
<p class="dialog-title" title={filePath}>{filePath}</p>
</div>
<div class="dialog-header-actions">
<span class="pill pill-count">{lines.length}</span>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={t("common.close")}>
<X size={16} aria-hidden="true" />
</button>
</div>
@@ -151,12 +152,12 @@
{#if isLoading}
<div class="blank-state">
<LoaderCircle class="spin" size={18} aria-hidden="true" />
Loading blame...
{t("blame.loading")}
</div>
{:else if error}
<div class="blank-state">{error}</div>
{:else if lines.length === 0}
<div class="blank-state">No blame information available for this file.</div>
<div class="blank-state">{t("blame.empty")}</div>
{:else}
<div class="diff-header blame-code-header">
<FileCode size={13} aria-hidden="true" />
@@ -169,23 +170,23 @@
bind:value={blameSearch}
autocomplete="off"
spellcheck="false"
placeholder="Search blame"
aria-label="Search blame"
placeholder={t("blame.searchPlaceholder")}
aria-label={t("blame.searchPlaceholder")}
/>
{#if searchActive}
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label="Clear blame search">
<button class="btn-sm blame-search-clear" type="button" onclick={() => { blameSearch = ""; }} aria-label={t("blame.searchClear")}>
<X size={14} aria-hidden="true" />
</button>
{/if}
</div>
<div class="split-col-headers blame-column-headers">
<div class="split-col-label blame-commit-col-label">Commit</div>
<div class="split-col-label blame-code-col-label">Code</div>
<div class="split-col-label blame-commit-col-label">{t("blame.columnCommit")}</div>
<div class="split-col-label blame-code-col-label">{t("blame.columnCode")}</div>
</div>
<div class="split-diff blame-diff" role="table" aria-label="File blame">
<div class="split-diff blame-diff" role="table" aria-label={t("blame.dialogLabel")}>
<div class="split-pane blame-scroll">
{#if groups.length === 0}
<div class="blank-state">No matches found.</div>
<div class="blank-state">{t("blame.noMatches")}</div>
{:else}
<div class="blame-code-table">
{#each groups as group (group.id)}
@@ -197,7 +198,7 @@
{/each}
</span>
<span class="blame-author">
{#each textSegments(group.isUncommitted ? "Not committed yet" : group.authorName) as segment, index (`author-${group.id}-${index}`)}
{#each textSegments(group.isUncommitted ? t("blame.uncommitted") : group.authorName) as segment, index (`author-${group.id}-${index}`)}
{#if segment.matched}<mark class="blame-search-hit">{segment.text}</mark>{:else}{segment.text}{/if}
{/each}
</span>
@@ -1,92 +0,0 @@
<script lang="ts">
import { AlertTriangle, GitBranch, LoaderCircle, Trash2, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo } from "../types";
interface Props {
branch: GitBranchInfo;
force: boolean;
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
branch,
force = false,
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
let title = $derived(branch.remote ? "Delete remote branch?" : force ? "Force delete branch?" : "Delete branch?");
let remoteParts = $derived(branch.remote ? branch.name.split(/\/(.+)/) : []);
let branchName = $derived(branch.remote ? remoteParts[1] || branch.name : branch.name);
let branchLocation = $derived(branch.remote ? remoteParts[0] || "Remote" : "Local repository");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog branch-delete-dialog" role="dialog" aria-modal="true" aria-labelledby="branch-delete-title">
<header class="dialog-header branch-delete-header unified-dialog-header">
<div class="branch-delete-heading unified-dialog-heading">
<span class:force class="branch-delete-heading-icon unified-dialog-icon" aria-hidden="true">
<Trash2 size={16} />
</span>
<div class="unified-dialog-text">
<span class="eyebrow">{branch.remote ? "Remote branch" : force ? "Force delete" : "Delete branch"}</span>
<p class="dialog-title" id="branch-delete-title">{title}</p>
</div>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-delete-body">
<div class:force class="discard-warning-icon branch-delete-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p class="branch-delete-lead">
{#if branch.remote}
This branch will be removed from the shared remote repository.
{:else if force}
This branch is not fully merged. Some commits may only exist here.
{:else}
This branch will be removed from your local repository.
{/if}
</p>
<div class="branch-delete-target" title={branch.name}>
<span class="branch-delete-target-icon" aria-hidden="true"><GitBranch size={16} /></span>
<span class="branch-delete-target-copy">
<code>{branchName}</code>
<span>{branchLocation}</span>
</span>
<span class:remote={branch.remote} class="branch-delete-scope">{branch.remote ? "Remote" : "Local"}</span>
</div>
<p class="discard-warning-text">
{#if branch.remote}
This affects everyone using <strong>{remoteParts[0] || "the remote"}</strong>. Your local branch is kept.
{:else if force}
Force deletion can make unmerged commits difficult to recover.
{:else}
Git will stop the deletion if the branch contains unmerged commits.
{/if}
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger branch-delete-confirm" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<Trash2 size={15} aria-hidden="true" />
{/if}
{branch.remote ? "Delete from remote" : force ? "Force delete" : "Delete"}
</button>
</footer>
</div>
</div>
+50 -49
View File
@@ -24,6 +24,7 @@
} from "@lucide/svelte";
import { tick } from "svelte";
import type { GitBranch as GitBranchInfo } from "../types";
import { t } from "../i18n.svelte";
type Scope = "local" | "remote";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -307,12 +308,12 @@
function branchTitle(branch: GitBranchInfo) {
const tracking = trackingFor(branch);
const lines = [branch.name];
if (branch.current) lines.push("Current branch (HEAD)");
if (tracking.kind === "tracked") lines.push(`Tracks ${tracking.upstream}`);
if (tracking.kind === "gone") lines.push(`Upstream ${tracking.upstream} is gone`);
if (tracking.kind === "local") lines.push("Local only not published");
if (tracking.kind === "remote-tracked") lines.push(`Checked out locally as ${tracking.local}`);
if (!branch.current) lines.push("Double-click to checkout");
if (branch.current) lines.push(t("branches.tipCurrent"));
if (tracking.kind === "tracked") lines.push(t("branches.tipTracks", { upstream: tracking.upstream }));
if (tracking.kind === "gone") lines.push(t("branches.tipGone", { upstream: tracking.upstream }));
if (tracking.kind === "local") lines.push(t("branches.tipLocalOnly"));
if (tracking.kind === "remote-tracked") lines.push(t("branches.tipCheckedOut", { name: tracking.local }));
if (!branch.current) lines.push(t("branches.tipDoubleClick"));
return lines.join("\n");
}
@@ -519,7 +520,7 @@
onclick={() => toggleBranchFolder(row.id)}
oncontextmenu={(event) => openFolderContextMenu(event, row)}
aria-expanded={open}
title={`${row.name} · ${row.branchCount} ${row.branchCount === 1 ? "branch" : "branches"}`}
title={row.branchCount === 1 ? t("branches.folderTitleOne", { name: row.name }) : t("branches.folderTitle", { name: row.name, count: row.branchCount })}
>
<span class="bp-chevron" aria-hidden="true">
{#if open}<ChevronDown size={12} />{:else}<ChevronRight size={12} />{/if}
@@ -535,7 +536,7 @@
</span>
<span class="bp-name">{row.name}</span>
{#if row.current && !open}
<span class="bp-current-dot" title="Contains current branch"></span>
<span class="bp-current-dot" title={t("branches.containsCurrent")}></span>
{/if}
<span class="bp-count">{row.branchCount}</span>
</button>
@@ -564,13 +565,13 @@
</span>
<span class="bp-meta">
{#if tracking.kind === "tracked"}
<span class="bp-track" aria-label={`Tracks ${tracking.upstream}`}><Cloud size={11} /></span>
<span class="bp-track" aria-label={t("branches.labelTracks", { upstream: tracking.upstream })}><Cloud size={11} /></span>
{:else if tracking.kind === "gone"}
<span class="bp-track gone" aria-label="Upstream gone"><CloudOff size={11} /></span>
<span class="bp-track gone" aria-label={t("branches.labelGone")}><CloudOff size={11} /></span>
{:else if tracking.kind === "local"}
<span class="bp-track local" aria-label="Local only"><Laptop size={11} /></span>
<span class="bp-track local" aria-label={t("branches.labelLocalOnly")}><Laptop size={11} /></span>
{:else if tracking.kind === "remote-tracked"}
<span class="bp-track linked" aria-label={`Checked out as ${tracking.local}`}><Link2 size={11} /></span>
<span class="bp-track linked" aria-label={t("branches.labelCheckedOut", { name: tracking.local })}><Link2 size={11} /></span>
{/if}
{#if row.branch.current}
<span class="bp-head-tag">HEAD</span>
@@ -581,8 +582,8 @@
type="button"
onclick={(event) => openBranchMenuFromButton(event, row.branch)}
disabled={isBusy}
title="Branch actions"
aria-label={`Actions for ${row.branch.name}`}
title={t("branches.actions")}
aria-label={t("branches.actionsFor", { name: row.branch.name })}
>
<Ellipsis size={13} aria-hidden="true" />
</button>
@@ -591,17 +592,17 @@
{/each}
{/snippet}
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="Branches">
<section class="panel branch-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label={t("branches.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />Branches</h2>
<h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />{t("branches.title")}</h2>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
type="button"
onclick={openCreateForm}
disabled={!hasRepository || isBusy}
title="Create new branch"
aria-label="Create new branch"
title={t("branches.create")}
aria-label={t("branches.create")}
>
<Plus size={14} aria-hidden="true" />
</button>
@@ -611,8 +612,8 @@
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand branches" : "Collapse branches"}
aria-label={collapsed ? "Expand branches panel" : "Collapse branches panel"}
title={collapsed ? t("branches.expand") : t("branches.collapse")}
aria-label={collapsed ? t("branches.expandPanel") : t("branches.collapsePanel")}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
@@ -626,7 +627,7 @@
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<p class="blank-state">Open a repository to list branches.</p>
<p class="blank-state">{t("branches.openRepo")}</p>
{:else}
<div class="bp-body">
<div class="bp-top">
@@ -639,14 +640,14 @@
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="new-branch-name"
aria-label="New branch name"
placeholder={t("branches.namePlaceholder")}
aria-label={t("branches.nameLabel")}
onkeydown={(event) => { if (event.key === "Escape") closeCreateForm(); }}
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title="Create branch">
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newBranchName.trim().length === 0} title={t("branches.createAction")}>
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title="Cancel">
<button class="branch-create-action" type="button" onclick={closeCreateForm} disabled={isBusy} title={t("common.cancel")}>
<X size={14} aria-hidden="true" />
</button>
</form>
@@ -654,7 +655,7 @@
{#if currentBranch}
{@const tracking = trackingFor(currentBranch)}
<button class="bp-current" type="button" onclick={revealCurrentBranch} title="Reveal current branch in list">
<button class="bp-current" type="button" onclick={revealCurrentBranch} title={t("branches.revealCurrent")}>
<span class="bp-current-icon" aria-hidden="true"><CircleDot size={14} /></span>
<span class="bp-current-text">
<strong>{currentBranch.name}</strong>
@@ -662,9 +663,9 @@
{#if tracking.kind === "tracked"}
<Cloud size={10} aria-hidden="true" /> {tracking.upstream}
{:else if tracking.kind === "gone"}
<CloudOff size={10} aria-hidden="true" /> {tracking.upstream} (gone)
<CloudOff size={10} aria-hidden="true" /> {t("branches.upstreamGone", { upstream: tracking.upstream })}
{:else}
<Laptop size={10} aria-hidden="true" /> Not published
<Laptop size={10} aria-hidden="true" /> {t("branches.notPublished")}
{/if}
</small>
</span>
@@ -680,18 +681,18 @@
type="text"
autocomplete="off"
spellcheck="false"
placeholder="Filter branches"
aria-label="Filter branches"
placeholder={t("branches.filter")}
aria-label={t("branches.filter")}
/>
{#if filterText}
<button class="bp-filter-clear" type="button" onclick={() => { filterText = ""; filterInput?.focus(); }} title="Clear filter" aria-label="Clear filter">
<button class="bp-filter-clear" type="button" onclick={() => { filterText = ""; filterInput?.focus(); }} title={t("branches.filterClear")} aria-label={t("branches.filterClear")}>
<X size={12} aria-hidden="true" />
</button>
{/if}
</label>
</div>
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label="Branch list">
<div class="bp-scroll" bind:this={scrollElement} role="tree" aria-label={t("branches.listLabel")}>
<div class="bp-group">
<button
class="bp-group-head"
@@ -701,13 +702,13 @@
>
{#if showLocal}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<Laptop size={12} aria-hidden="true" />
<span>Local</span>
<span>{t("common.local")}</span>
<span class="bp-group-count">{filtering ? `${visibleLocal.length}/${localBranches.length}` : localBranches.length}</span>
</button>
{#if showLocal}
{#if localBranches.length === 0}
<div class="bp-empty">No local branches.</div>
<div class="bp-empty">{t("branches.emptyLocal")}</div>
{:else}
{@render branchRows(localBranchRows)}
{/if}
@@ -723,13 +724,13 @@
>
{#if showRemote}<ChevronDown size={12} aria-hidden="true" />{:else}<ChevronRight size={12} aria-hidden="true" />{/if}
<Cloud size={12} aria-hidden="true" />
<span>Remote</span>
<span>{t("common.remote")}</span>
<span class="bp-group-count">{filtering ? `${visibleRemote.length}/${remoteBranches.length}` : remoteBranches.length}</span>
</button>
{#if showRemote}
{#if remoteBranches.length === 0}
<div class="bp-empty">No remote branches.</div>
<div class="bp-empty">{t("branches.emptyRemote")}</div>
{:else}
{@render branchRows(remoteBranchRows)}
{/if}
@@ -737,7 +738,7 @@
</div>
{#if filtering && visibleLocal.length === 0 && visibleRemote.length === 0}
<div class="bp-empty">No branches match “{filterText.trim()}”.</div>
<div class="bp-empty">{t("branches.noMatch", { query: filterText.trim() })}</div>
{/if}
</div>
</div>
@@ -750,32 +751,32 @@
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextBranch.name}`}
aria-label={t("branches.actionsFor", { name: contextBranch.name })}
>
<button type="button" role="menuitem" onclick={checkoutContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Checkout
{t("common.checkout")}
</button>
<button type="button" role="menuitem" onclick={compareContextBranch} disabled={isBusy}>
<GitCompare size={14} aria-hidden="true" />
Compare with...
{t("branches.menuCompare")}
</button>
<button type="button" role="menuitem" onclick={mergeContextBranch} disabled={isBusy || contextBranch.current}>
<GitMerge size={14} aria-hidden="true" />
Merge into current
{t("branches.menuMerge")}
</button>
<button type="button" role="menuitem" onclick={rebaseContextBranch} disabled={isBusy || contextBranch.current}>
<GitBranch size={14} aria-hidden="true" />
Rebase current onto this
{t("branches.menuRebase")}
</button>
<button type="button" role="menuitem" onclick={createContextWorktree} disabled={isBusy || contextBranch.remote || contextBranch.current}>
<HardDrive size={14} aria-hidden="true" />
Open in new worktree
{t("branches.menuWorktree")}
</button>
<div class="menu-separator" role="separator"></div>
<button type="button" role="menuitem" onclick={renameContextBranch} disabled={isBusy}>
<Pencil size={14} aria-hidden="true" />
{contextBranch.remote ? "Rename remote..." : "Rename"}
{contextBranch.remote ? t("branches.menuRenameRemote") : t("common.rename")}
</button>
<button
class="danger"
@@ -783,10 +784,10 @@
role="menuitem"
onclick={deleteContextBranch}
disabled={isBusy || contextBranch.current}
title={contextBranch.current ? "Current branch cannot be deleted" : contextBranch.remote ? "Delete remote branch" : "Delete local branch"}
title={contextBranch.current ? t("branches.cannotDeleteCurrent") : contextBranch.remote ? t("branches.deleteRemoteBranch") : t("branches.deleteLocalBranch")}
>
<Trash2 size={14} aria-hidden="true" />
{contextBranch.remote ? "Delete remote" : "Delete"}
{contextBranch.remote ? t("branches.menuDeleteRemote") : t("common.delete")}
</button>
</div>
{/if}
@@ -798,7 +799,7 @@
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for branch folder ${contextFolder.name}`}
aria-label={t("branches.folderActionsFor", { name: contextFolder.name })}
>
<button
class="danger"
@@ -806,10 +807,10 @@
role="menuitem"
onclick={deleteContextFolder}
disabled={isBusy || contextFolder.branches.every((branch) => branch.current)}
title={contextFolder.current ? "The current branch will be kept" : "Delete all branches in this folder"}
title={contextFolder.current ? t("branches.folderKeepsCurrent") : t("branches.folderDeleteHint")}
>
<Trash2 size={14} aria-hidden="true" />
Delete {contextFolder.branches.filter((branch) => !branch.current).length} branches
{t("branches.deleteFolder", { count: contextFolder.branches.filter((branch) => !branch.current).length })}
</button>
</div>
{/if}
+224
View File
@@ -0,0 +1,224 @@
<script lang="ts">
/**
* Generic confirmation dialog. Replaces window.confirm so confirmations use
* the app's own styling, translation and focus handling instead of a native,
* untranslated, event-blocking browser dialog.
*/
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
import { t } from "../i18n.svelte";
export interface ConfirmRequest {
/** Small label above the title. */
eyebrow?: string;
title: string;
/** Leading sentence explaining what happens. */
message: string;
/** Items the action applies to, rendered as a scrollable list. */
items?: string[];
/** Extra warning below the list. */
note?: string;
confirmLabel?: string;
cancelLabel?: string;
/** Optional opt-in the user must tick before confirming, e.g. "delete anyway". */
checkbox?: { label: string; note?: string; required?: boolean };
/** Destructive actions get the red confirm button and warning icon. */
danger?: boolean;
}
interface Props {
request: ConfirmRequest;
isBusy?: boolean;
/** `checked` is the state of the optional checkbox. */
onConfirm: (checked: boolean) => void;
onCancel: () => void;
}
let { request, isBusy = false, onConfirm, onCancel }: Props = $props();
const MAX_VISIBLE_ITEMS = 8;
let dialogElement = $state<HTMLElement | null>(null);
let confirmButton = $state<HTMLButtonElement | null>(null);
let danger = $derived(request.danger !== false);
let items = $derived(request.items ?? []);
let checked = $state(false);
let blocked = $derived(Boolean(request.checkbox?.required) && !checked);
$effect(() => {
// Reset the opt-in whenever a different confirmation is shown.
request.title;
checked = false;
});
$effect(() => {
confirmButton?.focus();
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === "Escape") {
event.stopPropagation();
if (!isBusy) onCancel();
return;
}
if (event.key !== "Tab" || !dialogElement) return;
const focusable = [...dialogElement.querySelectorAll<HTMLElement>("button:not(:disabled)")];
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="dialog-backdrop" role="presentation">
<div bind:this={dialogElement} class:danger class="dialog confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true">
{#if danger}<Trash2 size={18} />{:else}<Check size={18} />{/if}
</span>
<div class="unified-dialog-text">
<span class="eyebrow">{request.eyebrow ?? t("confirm.eyebrow")}</span>
<p class="dialog-title" id="confirm-dialog-title">{request.title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onCancel} disabled={isBusy} aria-label={t("common.close")}>
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="discard-confirm-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p class="confirm-lead">{request.message}</p>
{#if items.length > 0}
<ul class="discard-target-list">
{#each items.slice(0, MAX_VISIBLE_ITEMS) as item (item)}
<li><code class="discard-target" title={item}>{item}</code></li>
{/each}
{#if items.length > MAX_VISIBLE_ITEMS}
<li class="discard-target-more">{items.length - MAX_VISIBLE_ITEMS === 1 ? t("confirm.moreOne") : t("confirm.more", { count: items.length - MAX_VISIBLE_ITEMS })}</li>
{/if}
</ul>
{/if}
{#if request.checkbox}
<label class="confirm-check">
<input type="checkbox" bind:checked disabled={isBusy} />
<span>
<strong>{request.checkbox.label}</strong>
{#if request.checkbox.note}<small>{request.checkbox.note}</small>{/if}
</span>
</label>
{/if}
{#if request.note}
<p class="discard-warning-text">{request.note}</p>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onCancel} disabled={isBusy}>
{request.cancelLabel ?? t("common.cancel")}
</button>
<button
bind:this={confirmButton}
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
type="button"
onclick={() => onConfirm(checked)}
disabled={isBusy || blocked}
>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else if danger}
<Trash2 size={15} aria-hidden="true" />
{:else}
<Check size={15} aria-hidden="true" />
{/if}
{request.confirmLabel ?? (danger ? t("common.delete") : t("common.confirm"))}
</button>
</footer>
</div>
</div>
<style>
/* Matches .discard-confirm-dialog / .branch-delete-dialog so every confirmation
in the app has the same size, chrome and rhythm. */
.confirm-dialog {
display: grid;
grid-template-rows: auto auto auto;
width: min(500px, calc(100vw - 32px));
height: auto;
max-height: calc(100vh - 32px);
overflow: auto;
}
.confirm-dialog.danger {
border-color: rgba(255, 90, 103, 0.22);
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
}
.confirm-dialog.danger .dialog-header {
background:
linear-gradient(90deg, rgba(255, 90, 103, 0.08), transparent 42%),
var(--app-dialog-chrome);
}
.confirm-dialog .discard-confirm-body { padding: 20px 18px 18px; }
.confirm-dialog .confirm-lead {
color: var(--color-ink);
font-weight: 600;
}
.confirm-dialog.danger .unified-dialog-icon {
border-color: rgba(255, 90, 103, 0.28);
color: #ff9aa4;
background: rgba(255, 90, 103, 0.09);
}
.confirm-dialog .discard-target-list { max-height: 148px; }
.confirm-dialog .discard-warning-text {
padding: 9px 10px;
border-left: 2px solid rgba(255, 90, 103, 0.55);
color: #f2aeb5;
background: rgba(255, 90, 103, 0.055);
font-size: 11.5px;
font-weight: 600;
}
.confirm-dialog:not(.danger) .discard-warning-text {
border-left-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
color: var(--color-ink-muted);
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
}
.confirm-dialog:not(.danger) .discard-warning-icon {
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
color: var(--color-accent);
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
}
.confirm-dialog .confirm-action { min-width: 116px; }
.confirm-dialog .confirm-check {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 9px;
padding: 9px 10px;
border: 1px solid rgba(255, 90, 103, 0.28);
border-radius: 8px;
background: rgba(255, 90, 103, 0.05);
cursor: pointer;
}
.confirm-dialog .confirm-check input { width: 15px; height: 15px; margin-top: 1px; accent-color: #e86060; }
.confirm-dialog .confirm-check span { display: grid; gap: 2px; min-width: 0; }
.confirm-dialog .confirm-check strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
.confirm-dialog .confirm-check small { color: var(--color-ink-dim); font-size: 11.5px; }
</style>
@@ -1,88 +0,0 @@
<script lang="ts">
import {Trash2, AlertTriangle, LoaderCircle, RotateCcw, X } from "@lucide/svelte";
import type { GitFileStatus } from "../types";
interface Props {
files: GitFileStatus[];
staged: boolean | null;
scope: "file" | "hunk" | "lines";
isBusy: boolean;
onConfirm: () => void | Promise<void>;
onClose: () => void;
}
let {
files,
staged = false,
scope = "file",
isBusy = false,
onConfirm = () => {},
onClose = () => {},
}: Props = $props();
function targetPath(file: GitFileStatus): string {
return file.old_path ? `${file.old_path} -> ${file.path}` : file.path;
}
let count = $derived(files.length);
let title = $derived(
scope === "hunk" ? "Discard hunk?" : scope === "lines" ? "Discard selected lines?" : count > 1 ? `Discard changes in ${count} files?` : "Discard file changes?"
);
let scopeLabel = $derived(scope === "hunk" ? "selected hunk" : scope === "lines" ? "selected lines" : count > 1 ? `${count} files` : "file");
let sourceLabel = $derived(staged === null ? "staged and unstaged changes" : staged ? "staged changes" : "unstaged changes");
</script>
<div class="dialog-backdrop" role="presentation">
<div class="dialog discard-confirm-dialog" role="dialog" aria-modal="true" aria-label={title}>
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><Trash2 size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Confirm discard</span>
<p class="dialog-title">{title}</p>
</div>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="discard-confirm-body">
<div class="discard-warning-icon" aria-hidden="true">
<AlertTriangle size={22} />
</div>
<div class="discard-confirm-copy">
<p>
This will reset the {sourceLabel} for the {scopeLabel} below.
</p>
{#if count > 1}
<ul class="discard-target-list">
{#each files.slice(0, 8) as file (`${file.old_path ?? ""}:${file.path}`)}
<li><code class="discard-target" title={targetPath(file)}>{targetPath(file)}</code></li>
{/each}
{#if files.length > 8}
<li class="discard-target-more">+{files.length - 8} more</li>
{/if}
</ul>
{:else if count === 1}
<code class="discard-target" title={targetPath(files[0])}>{targetPath(files[0])}</code>
{/if}
<p class="discard-warning-text">
This cannot be undone. If a file only exists in your working tree, it can be deleted entirely.
</p>
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={onConfirm} disabled={isBusy}>
{#if isBusy}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
{:else}
<RotateCcw size={15} aria-hidden="true" />
{/if}
Discard
</button>
</footer>
</div>
</div>
+21 -6
View File
@@ -17,6 +17,8 @@
X,
} from "@lucide/svelte";
import type { AppLanguage, GitLfsPattern, GitLfsStatus } from "../types";
import ConfirmDialog from "./ConfirmDialog.svelte";
import { t } from "../i18n.svelte";
interface Props {
status: GitLfsStatus | null;
@@ -81,11 +83,11 @@
}
}
async function confirmPrune() {
const confirmed = window.confirm(isGerman
? "Nicht mehr benötigte lokale LFS-Objekte sicher bereinigen? Nicht gepushte und aktuell verwendete Objekte bleiben erhalten."
: "Safely prune unused local LFS objects? Unpushed and currently used objects are retained.");
if (confirmed) await onPrune();
let pruneConfirmOpen = $state(false);
async function runPrune() {
pruneConfirmOpen = false;
await onPrune();
}
</script>
@@ -199,7 +201,7 @@
<footer class="lfs-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>{isGerman ? ".gitattributes bleibt als normale Änderung sichtbar und muss committed werden." : ".gitattributes remains a normal change and must be committed."}</span></div>
<div>
<button class="btn-secondary" type="button" onclick={confirmPrune} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
<button class="btn-secondary" type="button" onclick={() => { pruneConfirmOpen = true; }} disabled={isBusy || !status?.available}><Trash2 size={14} aria-hidden="true" />{isGerman ? "Cache bereinigen" : "Prune cache"}</button>
<button class="btn-primary" type="button" onclick={onPull} disabled={isBusy || !setupReady}><HardDriveDownload size={15} aria-hidden="true" />{isGerman ? "Objekte laden" : "Pull objects"}</button>
</div>
</footer>
@@ -293,3 +295,16 @@
.lfs-footer button { flex: 1; }
}
</style>
{#if pruneConfirmOpen}
<ConfirmDialog
request={{
title: t("confirm.lfsPrune.title"),
message: t("confirm.lfsPrune.message"),
note: t("confirm.lfsPrune.note"),
confirmLabel: t("confirm.lfsPrune.action"),
}}
onConfirm={runPrune}
onCancel={() => { pruneConfirmOpen = false; }}
/>
{/if}
+59 -58
View File
@@ -2,6 +2,7 @@
import { Cherry, ChevronDown, ChevronRight, CloudOff, EllipsisVertical, GitBranch, GitMerge, LoaderCircle, RotateCcw, StickyNote, Tag, X } from "@lucide/svelte";
import { visibleParentResolver } from "../graphParents";
import type { FileStatusKind, GitCommit, GitCommitFile } from "../types";
import { t } from "../i18n.svelte";
interface GraphSegment {
fromCol: number;
@@ -310,11 +311,11 @@
function commitHoverTitle(commit: GitCommit, row: GraphRow | undefined): string {
const directBranches = graphBranchRefs(commit).filter(branchIsVisible);
if (directBranches.length > 0) return `Branches: ${directBranches.join(", ")}`;
if (directBranches.length > 0) return t("history.hoverBranches", { list: directBranches.join(", ") });
const containingBranches = (row?.branchLabels ?? []).filter(branchIsVisible);
if (containingBranches.length === 0) return commit.short_hash;
return `Branches containing this commit: ${containingBranches.join(", ")}`;
return t("history.hoverContaining", { list: containingBranches.join(", ") });
}
function segmentIsVisible(segment: GraphSegment): boolean {
@@ -516,7 +517,7 @@
const note = await onLoadCommitNote(commit);
notePreviews = {
...notePreviews,
[commit.hash]: note?.trim() || "This Git note is empty.",
[commit.hash]: note?.trim() || t("history.noteEmpty"),
};
} catch {
const nextErrors = new Set(notePreviewErrors);
@@ -684,14 +685,14 @@
function branchDecorationTitle(branch: CommitBranchDecoration): string {
if (branch.localOnly) {
const status = branchStatusLabel(branch);
return `${branch.label} · Local only — not published yet${status ? ` · ${status}` : ""}`;
return `${t("history.branchLocalOnlyTitle", { name: branch.label })}${status ? ` · ${status}` : ""}`;
}
const status = branchStatusLabel(branch);
if (branch.trackedRemote) {
return `${branch.label} · Tracks ${branch.trackedRemote}${status ? ` · ${status}` : ""}`;
return `${t("history.branchTracksTitle", { name: branch.label, upstream: branch.trackedRemote })}${status ? ` · ${status}` : ""}`;
}
if (status) return `${branch.label} · ${status}`;
return branch.kind === "remote" ? `Remote branch ${branch.label}` : `Local branch ${branch.label}`;
return branch.kind === "remote" ? t("history.branchRemoteTitle", { name: branch.label }) : t("history.branchLocalTitle", { name: branch.label });
}
function formatCommitDate(value: string): string {
@@ -778,11 +779,11 @@
<svelte:window onclick={closeCommitContextMenu} onkeydown={handleWindowKeydown} on:contextmenu|capture={closeCommitContextMenu} />
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Commit history">
<section bind:this={panelElement} class="panel history-panel grid grid-rows-[auto_1fr] overflow-hidden" aria-label={t("history.panelLabel")}>
<div class="section-head">
<div>
<span class="eyebrow">History</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Commits</h2>
<span class="eyebrow">{t("history.eyebrow")}</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("history.title")}</h2>
</div>
{#if graphBranchNames.length > 0}
<div class="section-head-actions">
@@ -790,8 +791,8 @@
class="graph-branch-dialog-button"
type="button"
onclick={openBranchDialog}
title="Customize visible branches"
aria-label={`${visibleBranchCount} of ${graphBranchNames.length} branches visible. Customize branches.`}
title={t("history.customizeBranches")}
aria-label={t("history.visibleBranches", { visible: visibleBranchCount, total: graphBranchNames.length })}
>
<GitBranch size={13} aria-hidden="true" />
Branches
@@ -802,13 +803,13 @@
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
<div class="blank-state">{t("history.noRepo")}</div>
{:else if commits.length === 0}
<div class="blank-state">No commits returned.</div>
<div class="blank-state">{t("history.noCommits")}</div>
{:else}
<div class="history-list graph-list overflow-auto">
{#if visibleCommits.length === 0}
<div class="blank-state">No loaded commits match the selected branches.</div>
<div class="blank-state">{t("history.noMatchingCommits")}</div>
{/if}
{#each visibleCommitEntries as entry, rowIndex (entry.commit.hash)}
{@const item = entry.commit}
@@ -884,7 +885,7 @@
{/if}
{#if refSummary.primaryBranch || refSummary.primaryTag || refSummary.overflowCount > 0}
<div class="commit-ref-area">
<div class="commit-ref-strip" aria-label="Commit references">
<div class="commit-ref-strip" aria-label={t("history.refs")}>
{#if refSummary.primaryBranch}
<span class="branch-ref-cluster" class:local-only={refSummary.primaryBranch.localOnly}>
<span
@@ -900,14 +901,14 @@
{/if}
</span>
{#if refSummary.primaryBranch.localOnly}
<span class="compact-ref-local-marker" title="This branch exists only locally and has not been published yet">
LOCAL
<span class="compact-ref-local-marker" title={t("history.localOnlyHint")}>
{t("history.localOnlyBadge")}
</span>
{/if}
</span>
{/if}
{#if refSummary.primaryTag}
<span class="compact-ref-chip tag" title={`Tag ${refSummary.primaryTag}`}>
<span class="compact-ref-chip tag" title={t("history.tagTitle", { name: refSummary.primaryTag })}>
<Tag size={10} aria-hidden="true" />
<span>{refSummary.primaryTag}</span>
</span>
@@ -919,7 +920,7 @@
onclick={() => toggleCommitRefs(item)}
aria-expanded={expandedRefsCommitHash === item.hash}
aria-controls={`commit-refs-${item.hash}`}
title={`Show ${refSummary.overflowCount} more ${refSummary.overflowCount === 1 ? "reference" : "references"}`}
title={refSummary.overflowCount === 1 ? t("history.showMoreRefsOne") : t("history.showMoreRefs", { count: refSummary.overflowCount })}
>
+{refSummary.overflowCount}
</button>
@@ -928,17 +929,17 @@
{#if refSummary.overflowCount > 0 && expandedRefsCommitHash === item.hash}
<div class="commit-ref-details" id={`commit-refs-${item.hash}`}>
<strong>References on this commit</strong>
<strong>{t("history.refsOnCommit")}</strong>
{#if refSummary.branches.some((branch) => branch.kind !== "remote")}
<section>
<span>Local</span>
<span>{t("common.local")}</span>
<div>
{#each refSummary.branches.filter((branch) => branch.kind !== "remote") as branch}
<span class="commit-ref-detail-item local" title={branchDecorationTitle(branch)}>
<i aria-hidden="true"></i>{branch.label}
{#if branch.current}<small>Current</small>{/if}
{#if branch.current}<small>{t("history.current")}</small>{/if}
{#if branch.trackedRemote}<small>{branch.trackedRemote}</small>{/if}
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />Local only</small>{/if}
{#if branch.localOnly}<small class="local-only"><CloudOff size={9} aria-hidden="true" />{t("history.localOnly")}</small>{/if}
</span>
{/each}
</div>
@@ -946,7 +947,7 @@
{/if}
{#if refSummary.branches.some((branch) => branch.kind === "remote")}
<section>
<span>Remote</span>
<span>{t("common.remote")}</span>
<div>
{#each refSummary.branches.filter((branch) => branch.kind === "remote") as branch}
<span class="commit-ref-detail-item remote"><i aria-hidden="true"></i>{branch.label}</span>
@@ -956,7 +957,7 @@
{/if}
{#if refSummary.tags.length > 0}
<section>
<span>Tags</span>
<span>{t("history.tags")}</span>
<div>
{#each refSummary.tags as tag}
<span class="commit-ref-detail-item tag"><Tag size={10} aria-hidden="true" />{tag}</span>
@@ -966,7 +967,7 @@
{/if}
{#if refSummary.other.length > 0}
<section>
<span>Other</span>
<span>{t("history.other")}</span>
<div>
{#each refSummary.other as ref}
<span class="commit-ref-detail-item">{ref}</span>
@@ -1005,11 +1006,11 @@
onfocus={() => void loadCommitNotePreview(item)}
onclick={() => openCommitNote(item)}
disabled={isBusy}
aria-label={`Open Git note for ${item.short_hash}`}
aria-label={t("history.openNote", { hash: item.short_hash })}
aria-describedby={`commit-note-preview-${item.hash}`}
>
<StickyNote size={11} aria-hidden="true" />
<span>Note</span>
<span>{t("history.note")}</span>
</button>
<span
class="commit-note-tooltip"
@@ -1018,8 +1019,8 @@
>
<span class="commit-note-tooltip-head">
<StickyNote size={12} aria-hidden="true" />
Git Note
<small>Click to open</small>
{t("history.gitNote")}
<small>{t("history.clickToOpen")}</small>
</span>
<span class="commit-note-tooltip-body">
{#if notePreviewLoading.has(item.hash)}
@@ -1054,14 +1055,14 @@
</button>
{#if expandedCommitHashes.has(item.hash)}
<div class="commit-file-list" aria-label="Changed files">
<div class="commit-file-list" aria-label={t("history.changedFiles")}>
{#each item.files as file (`${item.hash}:${file.old_path ?? ""}:${file.path}`)}
<button
class="commit-file-button"
type="button"
onclick={() => onPreviewCommitFile(item, file)}
disabled={isBusy}
title={`Show differences before restoring - ${displayCommitFile(file)}`}
title={t("history.diffBeforeRestore", { file: displayCommitFile(file) })}
>
<span class={`status-badge ${file.status}`}>{statusLabel(file.status)}</span>
<strong>{commitFileName(file)}</strong>
@@ -1081,8 +1082,8 @@
type="button"
onclick={() => openCommitNote(item)}
disabled={isBusy}
title={`Add a Git note to ${item.short_hash}`}
aria-label={`Add a Git note to ${item.short_hash}`}
title={t("history.addNote", { hash: item.short_hash })}
aria-label={t("history.addNote", { hash: item.short_hash })}
>
<StickyNote size={14} aria-hidden="true" />
</button>
@@ -1092,8 +1093,8 @@
type="button"
onclick={(event) => openCommitActionMenu(event, item)}
disabled={isBusy}
title="Commit actions"
aria-label={`Actions for ${item.short_hash}`}
title={t("history.commitActions")}
aria-label={t("history.actionsFor", { hash: item.short_hash })}
aria-haspopup="menu"
aria-expanded={contextCommit?.hash === item.hash}
>
@@ -1108,13 +1109,13 @@
<div class="history-load-more" use:observeHistoryEnd aria-live="polite">
{#if isLoadingMore}
<LoaderCircle class="spin" size={15} aria-hidden="true" />
<span>Loading older commits…</span>
<span>{t("history.loadingOlder")}</span>
{:else if loadMoreError}
<span title={loadMoreError}>Older commits could not be loaded.</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>Retry</button>
<span title={loadMoreError}>{t("history.loadOlderFailed")}</span>
<button type="button" class="btn-sm" onclick={() => { void onLoadMore(); }} disabled={isBusy}>{t("history.retry")}</button>
{:else}
<button type="button" class="history-load-more-button" onclick={() => { void onLoadMore(); }} disabled={isBusy}>
Load older commits
{t("history.loadOlder")}
</button>
{/if}
</div>
@@ -1128,32 +1129,32 @@
style={`left: ${contextMenuX}px; top: ${contextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextCommit.short_hash}`}
aria-label={t("history.actionsFor", { hash: contextCommit.short_hash })}
>
<button type="button" role="menuitem" onclick={createBranchFromContextCommit} disabled={isBusy}>
<GitBranch size={14} aria-hidden="true" />
Branch
{t("history.menuBranch")}
</button>
<button type="button" role="menuitem" onclick={openContextCommitNote} disabled={isBusy}>
<StickyNote size={14} aria-hidden="true" />
Note
{t("history.note")}
</button>
<button type="button" role="menuitem" onclick={restoreContextCommit} disabled={isBusy}>
<RotateCcw size={14} aria-hidden="true" />
Restore
{t("history.menuRestore")}
</button>
<button
type="button"
role="menuitem"
onclick={cherryPickContextCommit}
disabled={isBusy}
title="Apply this commit's changes on top of the current branch"
title={t("history.menuCherryPickHint")}
>
<Cherry size={14} aria-hidden="true" />
Cherry-pick
{t("history.menuCherryPick")}
</button>
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title="Create a new commit that reverses this commit">
<RotateCcw size={14} aria-hidden="true" /> Revert
<button type="button" role="menuitem" onclick={revertContextCommit} disabled={isBusy} title={t("history.menuRevertHint")}>
<RotateCcw size={14} aria-hidden="true" /> {t("history.menuRevert")}
</button>
</div>
{/if}
@@ -1165,25 +1166,25 @@
class="branch-filter-dialog"
role="dialog"
aria-modal="true"
aria-label="Select visible branches"
aria-label={t("history.branchDialogLabel")}
>
<header class="branch-filter-dialog-head unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><GitBranch size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Git graph</span>
<h3>Visible branches</h3>
<span class="eyebrow">{t("history.graphEyebrow")}</span>
<h3>{t("history.graphTitle")}</h3>
</div>
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label="Close branch selection">
<button class="dialog-icon-button" type="button" onclick={closeBranchDialog} aria-label={t("history.closeBranchDialog")}>
<X size={16} aria-hidden="true" />
</button>
</header>
<div class="branch-filter-summary">
<span>{visibleBranchCount} of {graphBranchNames.length} branches selected</span>
<span>{t("history.branchesSelected", { visible: visibleBranchCount, total: graphBranchNames.length })}</span>
<div class="branch-filter-actions">
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>Focus</button>
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>Show all</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>Hide all</button>
<button type="button" onclick={showFocusGraphBranches} disabled={branchVisibilityMode === "focus"}>{t("history.focus")}</button>
<button type="button" onclick={showAllGraphBranches} disabled={visibleBranchCount === graphBranchNames.length}>{t("history.showAll")}</button>
<button type="button" onclick={hideAllGraphBranches} disabled={visibleBranchCount === 0}>{t("history.hideAll")}</button>
</div>
</div>
@@ -1197,7 +1198,7 @@
onclick={() => { localBranchGroupOpen = !localBranchGroupOpen; }}
>
{#if localBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<span>Local</span>
<span>{t("common.local")}</span>
<em>{localBranchNames.filter(branchIsVisible).length}/{localBranchNames.length}</em>
</button>
{#if localBranchGroupOpen}
@@ -1226,7 +1227,7 @@
onclick={() => { remoteBranchGroupOpen = !remoteBranchGroupOpen; }}
>
{#if remoteBranchGroupOpen}<ChevronDown size={14} aria-hidden="true" />{:else}<ChevronRight size={14} aria-hidden="true" />{/if}
<span>Remote</span>
<span>{t("common.remote")}</span>
<em>{remoteBranchNames.filter(branchIsVisible).length}/{remoteBranchNames.length}</em>
</button>
{#if remoteBranchGroupOpen}
@@ -1,6 +1,7 @@
<script lang="ts">
import {GitMerge, AlertTriangle, ArrowDown, ArrowUp, GitBranch, LoaderCircle, Play, X } from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, RebaseAction, RebaseCommit, RebasePlanItem } from "../types";
import { t } from "../i18n.svelte";
import SelectMenu from "./SelectMenu.svelte";
interface PlanRow extends RebaseCommit {
@@ -71,23 +72,23 @@
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label="Interactive rebase" tabindex="-1">
<div class="dialog interactive-rebase-dialog" role="dialog" aria-modal="true" aria-label={t("rebase.dialogLabel")} tabindex="-1">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><GitMerge size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Rewrite local history</span>
<h2 class="dialog-title">Interactive rebase</h2>
<span class="eyebrow">{t("rebase.eyebrow")}</span>
<h2 class="dialog-title">{t("rebase.dialogLabel")}</h2>
</div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={t("common.close")}><X size={18} aria-hidden="true" /></button>
</header>
<div class="interactive-rebase-body">
<section class="rebase-base-bar">
<label>
<span>Rebase <strong>{currentBranch || "current branch"}</strong> onto</span>
<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} />
<span>{t("rebase.rebaseOnto")} <strong>{currentBranch || t("rebase.currentBranch")}</strong> {t("rebase.onto")}</span>
<SelectMenu value={base} options={availableBases.map((branch) => ({ value: branch.name, label: branch.remote ? t("rebase.baseRemote", { name: branch.name }) : t("rebase.baseLocal", { name: branch.name }) }))} placeholder={t("rebase.selectBase")} disabled={isBusy || isLoading} onChange={onBaseChange} />
</label>
<p>Oldest commit first. Reorder commits, then choose how each one should be replayed.</p>
<p>{t("rebase.hint")}</p>
</section>
{#if error}
@@ -95,24 +96,24 @@
{/if}
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading rebase range…</div>
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> {t("rebase.loading")}</div>
{:else if !base}
<div class="blank-state">Select the branch or commit that should become the new base.</div>
<div class="blank-state">{t("rebase.selectBaseHint")}</div>
{:else if rows.length === 0}
<div class="blank-state">No linear commits are available above this base.</div>
<div class="blank-state">{t("rebase.noCommits")}</div>
{:else}
<div class="rebase-plan" role="list" aria-label="Interactive rebase plan">
<div class="rebase-plan" role="list" aria-label={t("rebase.planLabel")}>
{#each rows as row, index (row.hash)}
<article class:drop={row.action === "drop"} class="rebase-plan-row" role="listitem">
<div class="rebase-order-actions">
<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 === 0} title={t("rebase.moveUp")}><ArrowUp size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => move(index, 1)} disabled={isBusy || index === rows.length - 1} title={t("rebase.moveDown")}><ArrowDown size={14} aria-hidden="true" /></button>
</div>
<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)} />
<SelectMenu class={`rebase-action ${row.action}`} value={row.action} options={rebaseActions.map((action) => ({ value: action, label: action }))} disabled={isBusy} ariaLabel={t("rebase.actionFor", { hash: row.short_hash })} onChange={(value) => updateAction(index, value as RebaseAction)} />
<code>{row.short_hash}</code>
<div class="rebase-commit-copy">
{#if row.action === "reword"}
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={`New message for ${row.short_hash}`} maxlength="240" />
<input value={row.message} oninput={(event) => updateMessage(index, (event.target as HTMLInputElement).value)} disabled={isBusy} aria-label={t("rebase.newMessageFor", { hash: row.short_hash })} maxlength="240" />
{:else}
<strong>{row.summary}</strong>
{/if}
@@ -124,19 +125,19 @@
{/if}
{#if invalidSquash}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Squash and fixup need an earlier commit that is not dropped.</div>
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> {t("rebase.invalidSquash")}</div>
{:else if invalidReword}
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> Reword messages cannot be empty.</div>
<div class="rebase-warning"><AlertTriangle size={15} aria-hidden="true" /> {t("rebase.invalidReword")}</div>
{/if}
</div>
<footer class="dialog-footer">
<span class="dialog-footer-info">{keptCount} of {rows.length} commits kept</span>
<span class="dialog-footer-info">{t("rebase.keptCount", { kept: keptCount, total: rows.length })}</span>
<div class="rebase-footer-actions">
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>Cancel</button>
<button class="btn-secondary" type="button" onclick={onClose} disabled={isBusy}>{t("common.cancel")}</button>
<button class="btn-primary" type="button" onclick={start} disabled={!canStart}>
{#if operation === "Starting interactive rebase"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<Play size={16} aria-hidden="true" />{/if}
Start rebase
{t("rebase.start")}
</button>
</div>
</footer>
+14 -13
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { GitBranch, History, LoaderCircle, Search, ShieldCheck, X } from "@lucide/svelte";
import type { ReflogEntry } from "../types";
import { t } from "../i18n.svelte";
interface Props {
entries: ReflogEntry[];
@@ -32,21 +33,21 @@
</script>
<div class="dialog-backdrop app-chrome-backdrop" role="presentation">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label="Reflog" tabindex="-1">
<div class="dialog reflog-dialog" role="dialog" aria-modal="true" aria-label={t("reflog.title")} tabindex="-1">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><History size={18} /></span>
<div class="unified-dialog-text"><span class="eyebrow">Recovery history</span><h2 class="dialog-title">Reflog</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title="Close"><X size={18} aria-hidden="true" /></button>
<div class="unified-dialog-text"><span class="eyebrow">{t("reflog.eyebrow")}</span><h2 class="dialog-title">{t("reflog.title")}</h2></div>
<button class="dialog-close" type="button" onclick={onClose} disabled={isBusy} title={t("common.close")}><X size={18} aria-hidden="true" /></button>
</header>
<div class="reflog-body">
<aside class="reflog-list-pane">
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder="Search actions, hashes or authors" aria-label="Search reflog" /></label>
<label class="reflog-search"><Search size={15} aria-hidden="true" /><input bind:value={query} placeholder={t("reflog.searchPlaceholder")} aria-label={t("reflog.searchLabel")} /></label>
{#if isLoading}
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> Loading reflog…</div>
<div class="blank-state"><LoaderCircle class="spin" size={18} aria-hidden="true" /> {t("reflog.loading")}</div>
{:else if filteredEntries.length === 0}
<div class="blank-state">No reflog entries match this search.</div>
<div class="blank-state">{t("reflog.noMatch")}</div>
{:else}
<div class="reflog-list" role="listbox" aria-label="Reflog entries">
<div class="reflog-list" role="listbox" aria-label={t("reflog.listLabel")}>
{#each filteredEntries as entry (`${entry.selector}:${entry.hash}`)}
<button class:active={selected?.selector === entry.selector} type="button" role="option" aria-selected={selected?.selector === entry.selector} onclick={() => select(entry)}>
<span class="reflog-row-top"><code>{entry.selector}</code><span>{new Date(entry.date).toLocaleString()}</span></span>
@@ -62,18 +63,18 @@
{#if error}<div class="rebase-warning error">{error}</div>{/if}
{#if selected}
<div class="reflog-detail-head"><History size={20} aria-hidden="true" /><div><span class="eyebrow">{selected.selector}</span><h3>{selected.action}</h3></div></div>
<dl><div><dt>Commit</dt><dd><code>{selected.hash}</code></dd></div><div><dt>Author</dt><dd>{selected.author_name}</dd></div><div><dt>Date</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> Preview changes to current HEAD</button>
<dl><div><dt>{t("common.commit")}</dt><dd><code>{selected.hash}</code></dd></div><div><dt>{t("reflog.author")}</dt><dd>{selected.author_name}</dd></div><div><dt>{t("reflog.date")}</dt><dd>{new Date(selected.date).toLocaleString()}</dd></div></dl>
<button class="btn-secondary reflog-preview" type="button" onclick={() => onPreview(selected)} disabled={isBusy || selected.hash === currentHash}><History size={15} aria-hidden="true" /> {t("reflog.preview")}</button>
<div class="reflog-recovery-card">
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>Safe recovery</strong><span>Create a new branch here. The current branch is not reset or deleted.</span></div></div>
<label><span>Recovery branch</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<div class="reflog-recovery-title"><ShieldCheck size={18} aria-hidden="true" /><div><strong>{t("reflog.safeRecovery")}</strong><span>{t("reflog.safeRecoveryNote")}</span></div></div>
<label><span>{t("reflog.recoveryBranch")}</span><div><GitBranch size={15} aria-hidden="true" /><input bind:value={recoveryBranch} disabled={isBusy} spellcheck="false" /></div></label>
<button class="btn-primary" type="button" onclick={() => onRestore(selected, recoveryBranch.trim())} disabled={isBusy || !recoveryBranch.trim()}>
{#if operation === "Restoring reflog entry"}<LoaderCircle class="spin" size={16} aria-hidden="true" />{:else}<ShieldCheck size={16} aria-hidden="true" />{/if}
Create and checkout recovery branch
{t("reflog.createBranch")}
</button>
</div>
{:else}
<div class="blank-state">Select a reflog entry to inspect or recover it.</div>
<div class="blank-state">{t("reflog.selectEntry")}</div>
{/if}
</section>
</div>
+35 -6
View File
@@ -2,6 +2,8 @@
import { onMount } from "svelte";
import type { AiSettings } from "../types";
import CreateReviewDialog from "./CreateReviewDialog.svelte";
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
import { t } from "../i18n.svelte";
import CommentEditor from "./CommentEditor.svelte";
import SelectMenu from "./SelectMenu.svelte";
import { cubicOut } from "svelte/easing";
@@ -340,16 +342,35 @@
return de ? "Request wieder öffnen" : "Reopen request";
}
let reviewConfirmRequest = $state<ConfirmRequest | null>(null);
let reviewConfirmResolve: ((confirmed: boolean) => void) | null = null;
function askReviewConfirmation(request: IntegrationReviewRequest, action: IntegrationReviewAction): Promise<boolean> {
const values = { number: request.number };
reviewConfirmRequest = action === "merge"
? { title: t("confirm.review.mergeTitle", values), message: t("confirm.review.mergeMessage"), items: [request.title], confirmLabel: t("confirm.review.mergeAction"), danger: false }
: action === "close"
? { title: t("confirm.review.closeTitle", values), message: t("confirm.review.closeMessage"), items: [request.title], confirmLabel: t("confirm.review.closeAction") }
: { title: t("confirm.review.reopenTitle", values), message: t("confirm.review.reopenMessage"), items: [request.title], confirmLabel: t("confirm.review.reopenAction"), danger: false };
return new Promise<boolean>((resolve) => {
reviewConfirmResolve = resolve;
});
}
function answerReviewConfirmation(confirmed: boolean) {
const resolve = reviewConfirmResolve;
reviewConfirmRequest = null;
reviewConfirmResolve = null;
resolve?.(confirmed);
}
async function performReviewAction(request: IntegrationReviewRequest, action: IntegrationReviewAction) {
const source = activeSource;
if (!source || actionBusyId) return;
if (action !== "approve") {
const prompt = action === "merge"
? (de ? `Request #${request.number} wirklich zusammenführen?` : `Merge request #${request.number}?`)
: action === "close"
? (de ? `Request #${request.number} wirklich schließen?` : `Close request #${request.number}?`)
: (de ? `Request #${request.number} wieder öffnen?` : `Reopen request #${request.number}?`);
if (!window.confirm(prompt)) return;
const confirmed = await askReviewConfirmation(request, action);
if (!confirmed) return;
}
actionMenuId = "";
actionNotice = "";
@@ -636,3 +657,11 @@
.review-center .action-menu button:hover:not(:disabled){background:var(--color-surface-hover)}
.review-comment-editor{flex:0 0 auto;min-width:0;padding:14px 0 18px;border-top:1px solid var(--color-border-subtle)}
</style>
{#if reviewConfirmRequest}
<ConfirmDialog
request={reviewConfirmRequest}
onConfirm={() => answerReviewConfirmation(true)}
onCancel={() => answerReviewConfirmation(false)}
/>
{/if}
+20 -19
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Plus, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types";
import { t } from "../i18n.svelte";
interface Props {
stashes: GitStash[];
@@ -42,11 +43,11 @@
}
</script>
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label={t("stashes.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />Stashes</h2>
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />{t("stashes.title")}</h2>
<div class="stash-head-actions">
<button class="stash-toggle" type="button" title="Create stash" aria-label="Create stash"
<button class="stash-toggle" type="button" title={t("stashes.create")} aria-label={t("stashes.create")}
disabled={!hasRepository || isBusy || changedCount === 0}
onclick={() => { createOpen = !createOpen; if (collapsed) { createOpen = true; onToggleCollapsed(); } }}>
<Plus size={14} aria-hidden="true" />
@@ -57,8 +58,8 @@
type="button"
onclick={onToggleCollapsed}
aria-expanded={!collapsed}
title={collapsed ? "Expand stash panel" : "Collapse stash panel"}
aria-label={collapsed ? "Expand stash panel" : "Collapse stash panel"}
title={collapsed ? t("stashes.expand") : t("stashes.collapse")}
aria-label={collapsed ? t("stashes.expand") : t("stashes.collapse")}
>
{#if collapsed}
<ChevronRight size={14} aria-hidden="true" />
@@ -72,7 +73,7 @@
{#if collapsed}
<!-- collapsed -->
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
<div class="blank-state">{t("stashes.noRepo")}</div>
{:else}
{#if createOpen}
<div class="stash-create">
@@ -80,8 +81,8 @@
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
aria-label="Stash message"
placeholder={t("stashes.messagePlaceholder")}
aria-label={t("stashes.messageLabel")}
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
@@ -89,24 +90,24 @@
/>
<label class="stash-check">
<input type="checkbox" bind:checked={includeUntracked} disabled={isBusy || changedCount === 0} />
Untracked
{t("stashes.untracked")}
</label>
<button
class="btn-sm stash-save-button"
type="button"
onclick={submitPush}
disabled={isBusy || changedCount === 0}
title="Save current working tree changes to a stash"
title={t("stashes.saveHint")}
>
<Archive size={14} aria-hidden="true" />
Stash
{t("stashes.save")}
</button>
</div>
{/if}
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
<div class="blank-state stash-empty">{t("stashes.empty")}</div>
{:else}
<div class="stash-list">
{#each stashes as stash (stash.selector)}
@@ -116,7 +117,7 @@
<span>
{stash.selector}
{#if stash.branch}
on {stash.branch}
{t("stashes.on", { branch: stash.branch })}
{/if}
{#if stash.date}
- {stash.date}
@@ -124,17 +125,17 @@
</span>
</div>
<div class="stash-actions">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title="Apply stash and keep it">
<button class="btn-sm" type="button" onclick={() => onApply(stash)} disabled={isBusy} title={t("stashes.applyHint")}>
<Download size={13} aria-hidden="true" />
Apply
{t("stashes.apply")}
</button>
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title="Apply stash and remove it if successful">
<button class="btn-sm" type="button" onclick={() => onPop(stash)} disabled={isBusy} title={t("stashes.popHint")}>
<Upload size={13} aria-hidden="true" />
Pop
{t("stashes.pop")}
</button>
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title="Delete stash">
<button class="btn-sm danger" type="button" onclick={() => onDrop(stash)} disabled={isBusy} title={t("stashes.dropHint")}>
<Trash2 size={13} aria-hidden="true" />
Drop
{t("stashes.drop")}
</button>
</div>
</article>
+61 -60
View File
@@ -16,6 +16,7 @@
} from "@lucide/svelte";
import iconUrl from "../../../src-tauri/icons/icon.png";
import type { FileStatusKind, GitFileStatus, GitIgnoreKind, GitStatus } from "../types";
import { t } from "../i18n.svelte";
interface Props {
changedFiles: GitFileStatus[];
@@ -246,7 +247,7 @@
function statusContextParent(label: string): string {
const normalized = label.replace(/\\/g, "/").replace(/\/+$/, "");
const separator = normalized.lastIndexOf("/");
return separator > 0 ? normalized.slice(0, separator) : "Repository root";
return separator > 0 ? normalized.slice(0, separator) : t("status.repositoryRoot");
}
function closeStatusContextMenu() {
@@ -410,58 +411,58 @@
<svelte:window onclick={closeStatusContextMenu} onkeydown={handleStatusWindowKeydown} />
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label="Working tree status">
<section class="panel status-panel relative grid grid-rows-[auto_1fr] overflow-hidden" aria-label={t("status.panelLabel")}>
<div class="section-head">
<div>
<span class="eyebrow">Workspace</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Changes</h2>
<span class="eyebrow">{t("status.eyebrow")}</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">{t("status.title")}</h2>
</div>
<div class="status-view-switch" role="group" aria-label="Changes view">
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title="List view" aria-label="List view" aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>List</span>
<div class="status-view-switch" role="group" aria-label={t("status.viewGroup")}>
<button class:active={statusView === "list"} type="button" onclick={() => statusView = "list"} title={t("status.viewList")} aria-label={t("status.viewList")} aria-pressed={statusView === "list"}>
<i class="status-view-icon list-icon" aria-hidden="true"></i><span>{t("status.viewListShort")}</span>
</button>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title="Tree view" aria-label="Tree view" aria-pressed={statusView === "tree"}>
<FolderTree size={13} aria-hidden="true" /><span>Tree</span>
<button class:active={statusView === "tree"} type="button" onclick={() => statusView = "tree"} title={t("status.viewTree")} aria-label={t("status.viewTree")} aria-pressed={statusView === "tree"}>
<FolderTree size={13} aria-hidden="true" /><span>{t("status.viewTreeShort")}</span>
</button>
</div>
<div class="status-head-actions">
<span class="pill pill-count">{stagedCount} staged</span>
<span class="pill pill-count">{unstagedCount} unstaged</span>
<span class="pill pill-count">{t("status.staged", { count: stagedCount })}</span>
<span class="pill pill-count">{t("status.unstaged", { count: unstagedCount })}</span>
{#if hasRepository && changedFiles.length > 0}
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title="Discard all staged and unstaged changes">
<RotateCcw size={13} aria-hidden="true" /> Discard all
<button class="btn-sm status-discard-all" type="button" onclick={() => onDiscardMany(changedFiles)} disabled={isBusy} title={t("status.discardAllHint")}>
<RotateCcw size={13} aria-hidden="true" /> {t("status.discardAll")}
</button>
{/if}
</div>
</div>
{#if !hasRepository}
<div class="blank-state">No repository loaded.</div>
<div class="blank-state">{t("status.noRepo")}</div>
{:else if status?.clean}
<div class="blank-state">Working tree is clean.</div>
<div class="blank-state">{t("status.clean")}</div>
{:else if changedFiles.length === 0}
<div class="blank-state">No file changes returned.</div>
<div class="blank-state">{t("status.noChanges")}</div>
{:else}
<div class="status-lanes">
<section class="status-lane unstaged-lane" aria-label="Unstaged changes">
<section class="status-lane unstaged-lane" aria-label={t("status.laneUnstaged")}>
<header class="status-lane-head">
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Unstaged</strong><small>Working tree</small></div>
<div class="status-lane-copy"><strong>{t("status.unstagedTitle")}</strong><small>{t("status.workingTree")}</small></div>
<span class="status-lane-count">{unstagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedUnstagedCount > 1}
<span class="status-selection-count">{selectedUnstagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={`Stage ${selectedUnstagedCount} selected files`}>
<button class="btn-sm" type="button" onclick={() => onStage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null))} disabled={isBusy} title={t("status.stageSelected", { count: selectedUnstagedCount })}>
<ArrowRight size={13} aria-hidden="true" /> Stage {selectedUnstagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title="Stage all unstaged files">
<ArrowRight size={13} aria-hidden="true" /> Stage all
<button class="btn-sm" type="button" onclick={onStageAll} disabled={isBusy || !hasUnstaged} title={t("status.stageAllHint")}>
<ArrowRight size={13} aria-hidden="true" /> {t("status.stageAll")}
</button>
{#if selectedUnstagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={`Discard unstaged changes in ${selectedUnstagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedUnstagedCount}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.unstaged !== null), false)} disabled={isBusy} title={t("status.discardUnstagedSelected", { count: selectedUnstagedCount })}>
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedUnstagedCount}
</button>
{/if}
</div>
@@ -479,20 +480,20 @@
{@const file = row.file}
{@const stageTargets = selectedStageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "unstaged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={t("status.selectInExplorer", { path: displayPath(file) })}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.unstaged ?? "none"}`}>{statusLabel(file.unstaged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Stage file"><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title="Show unstaged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title="Discard unstaged changes"><RotateCcw size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => stageFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={t("status.stageFile")}><ArrowRight size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, false)} disabled={isBusy || !canPatch(file.unstaged)} title={t("status.showUnstagedDetails")}><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardUnstagedFromFile(file)} disabled={isBusy || stageTargets.length === 0} title={t("status.discardUnstaged")}><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if unstagedCount === 0}<p class="status-lane-empty">No unstaged changes.</p>{/if}
{#if unstagedCount === 0}<p class="status-lane-empty">{t("status.emptyUnstaged")}</p>{/if}
</div>
</section>
@@ -500,25 +501,25 @@
<span><ArrowRight size={12} /></span>
</div>
<section class="status-lane staged-lane" aria-label="Staged changes">
<section class="status-lane staged-lane" aria-label={t("status.laneStaged")}>
<header class="status-lane-head">
<div class="status-lane-title">
<div class="status-lane-copy"><strong>Staged</strong><small>Next commit</small></div>
<div class="status-lane-copy"><strong>{t("status.stagedTitle")}</strong><small>{t("status.nextCommit")}</small></div>
<span class="status-lane-count">{stagedCount}</span>
</div>
<div class="status-lane-actions">
{#if selectedStagedCount > 1}
<span class="status-selection-count">{selectedStagedCount} selected</span>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={`Unstage ${selectedStagedCount} selected files`}>
<button class="btn-sm" type="button" onclick={() => onUnstage(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null))} disabled={isBusy} title={t("status.unstageSelected", { count: selectedStagedCount })}>
<ArrowLeft size={13} aria-hidden="true" /> Unstage {selectedStagedCount}
</button>
{/if}
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title="Unstage all staged files">
<ArrowLeft size={13} aria-hidden="true" /> Unstage all
<button class="btn-sm" type="button" onclick={onUnstageAll} disabled={isBusy || !hasStaged} title={t("status.unstageAllHint")}>
<ArrowLeft size={13} aria-hidden="true" /> {t("status.unstageAll")}
</button>
{#if selectedStagedCount > 1}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={`Discard staged changes in ${selectedStagedCount} selected files`}>
<RotateCcw size={13} aria-hidden="true" /> Discard {selectedStagedCount}
<button class="btn-sm danger" type="button" onclick={() => onDiscard(changedFiles.filter((file) => selectedStatusPaths.has(fileKey(file)) && file.staged !== null), true)} disabled={isBusy} title={t("status.discardStagedSelected", { count: selectedStagedCount })}>
<RotateCcw size={13} aria-hidden="true" /> {t("status.discard")} {selectedStagedCount}
</button>
{/if}
</div>
@@ -536,20 +537,20 @@
{@const file = row.file}
{@const unstageTargets = selectedUnstageTargets(file)}
<article class={`status-file-row${statusView === "tree" ? " status-tree-file" : ""}`} style={`--tree-indent: ${row.depth * 15}px`} class:selected={isStatusSelected(file)} class:active={selectedFilePath === file.path} oncontextmenu={(event) => openStatusContextMenu(event, "staged", "file", file.path, [file])}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={`Select ${displayPath(file)} in Explorer`}>
<button class="status-file-main" type="button" onclick={(event) => handleFileSelect(event, file)} title={t("status.selectInExplorer", { path: displayPath(file) })}>
<strong>{fileName(file)}</strong>
<span>{displayPath(file)}</span>
</button>
<span class={`status-badge ${file.staged ?? "none"}`}>{statusLabel(file.staged)}</span>
<div class="status-file-actions">
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Unstage file"><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title="Show staged details"><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title="Discard staged changes"><RotateCcw size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => unstageFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={t("status.unstageFile")}><ArrowLeft size={14} aria-hidden="true" /></button>
<button type="button" onclick={() => onPatch(file, true)} disabled={isBusy || !canPatch(file.staged)} title={t("status.showStagedDetails")}><FileDiff size={14} aria-hidden="true" /></button>
<button class="danger" type="button" onclick={() => discardStagedFromFile(file)} disabled={isBusy || unstageTargets.length === 0} title={t("status.discardStaged")}><RotateCcw size={14} aria-hidden="true" /></button>
</div>
</article>
{/if}
{/each}
{#if stagedCount === 0}<p class="status-lane-empty">Stage files to include them in the next commit.</p>{/if}
{#if stagedCount === 0}<p class="status-lane-empty">{t("status.emptyStaged")}</p>{/if}
</div>
</section>
</div>
@@ -568,7 +569,7 @@
</svg>
<img src={iconUrl} alt="" class="status-panel-overlay-icon" />
</div>
<span class="status-panel-overlay-label">{operation || "Working"}</span>
<span class="status-panel-overlay-label">{operation || t("status.working")}</span>
<div class="status-panel-overlay-bar"><span></span></div>
</div>
</div>
@@ -576,17 +577,17 @@
</section>
{#if statusContextTarget}
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={`Actions for ${statusContextTarget.label}`} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div bind:this={statusContextMenuElement} class="status-context-menu" style={`left: ${statusContextMenuX}px; top: ${statusContextMenuY}px;`} role="menu" tabindex="-1" aria-label={t("status.menuActionsFor", { name: statusContextTarget.label })} onclick={(event) => event.stopPropagation()} onkeydown={(event) => { if (event.key === "Escape") closeStatusContextMenu(); event.stopPropagation(); }}>
<div class="status-context-label">
<span class="status-context-object-icon" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderOpen size={16} />{:else}<FileDiff size={16} />{/if}
</span>
<span class="status-context-object-copy">
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? "Unstaged" : "Staged"} {statusContextTarget.kind}</span>
<span class="status-context-kind">{statusContextTarget.lane === "unstaged" ? (statusContextTarget.kind === "folder" ? t("status.menuKindUnstagedFolder") : t("status.menuKindUnstagedFile")) : (statusContextTarget.kind === "folder" ? t("status.menuKindStagedFolder") : t("status.menuKindStagedFile"))}</span>
<strong title={statusContextTarget.label}>{statusContextName(statusContextTarget.label)}</strong>
<span class="status-context-path" title={statusContextTarget.label}><Folder size={10} aria-hidden="true" />{statusContextParent(statusContextTarget.label)}</span>
</span>
<span class="status-context-count" title={`${statusContextTarget.files.length} ${statusContextTarget.files.length === 1 ? "file" : "files"}`}>
<span class="status-context-count" title={statusContextTarget.files.length === 1 ? t("status.menuFileCountOne") : t("status.menuFileCount", { count: statusContextTarget.files.length })}>
{statusContextTarget.files.length}
</span>
</div>
@@ -595,56 +596,56 @@
{#if statusContextTarget.lane === "unstaged"}<ArrowRight size={15} />{:else}<ArrowLeft size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>{statusContextTarget.lane === "unstaged" ? "Stage" : "Unstage"} {statusContextTarget.kind}</strong>
<span>{statusContextTarget.lane === "unstaged" ? "Add to the next commit" : "Move back to working changes"}</span>
<strong>{statusContextTarget.lane === "unstaged" ? (statusContextTarget.kind === "folder" ? t("status.menuStageFolder") : t("status.menuStageFile")) : (statusContextTarget.kind === "folder" ? t("status.menuUnstageFolder") : t("status.menuUnstageFile"))}</strong>
<span>{statusContextTarget.lane === "unstaged" ? t("status.menuStageHint") : t("status.menuUnstageHint")}</span>
</span>
</button>
<button type="button" role="menuitem" onclick={runStatusContextStashAction} disabled={isBusy}>
<span class="status-context-action-icon" aria-hidden="true"><Archive size={15} /></span>
<span class="status-context-action-copy">
<strong>Stash {statusContextTarget.kind}</strong>
<span>Save {statusContextTarget.files.length === 1 ? "this file" : `${statusContextTarget.files.length} files`} for later</span>
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStashFolder") : t("status.menuStashFile")}</strong>
<span>{statusContextTarget.files.length === 1 ? t("status.menuStashHintOne") : t("status.menuStashHint", { count: statusContextTarget.files.length })}</span>
</span>
</button>
{#if statusContextCanIgnore || statusContextCanStopTracking}
<div class="menu-separator" role="separator"></div>
{/if}
{#if statusContextCanStopTracking}
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title="Keep the working-tree content and remove it from the Git index">
<button type="button" role="menuitem" onclick={runStatusContextStopTracking} disabled={isBusy} title={t("status.menuStopTrackingHint")}>
<span class="status-context-action-icon untrack" aria-hidden="true">
{#if statusContextTarget.kind === "folder"}<FolderMinus size={15} />{:else}<FileMinus2 size={15} />{/if}
</span>
<span class="status-context-action-copy">
<strong>Stop tracking {statusContextTarget.kind}</strong>
<span>Keep it on disk and remove it from Git</span>
<strong>{statusContextTarget.kind === "folder" ? t("status.menuStopTrackingFolder") : t("status.menuStopTrackingFile")}</strong>
<span>{t("status.menuStopTrackingNote")}</span>
</span>
</button>
{/if}
{#if statusContextCanIgnore}
{#if statusContextTarget.kind === "file"}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={`Add /${statusContextTarget.label.replace(/\\/g, "/")} to .gitignore`}>
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("file")} disabled={isBusy} title={t("status.menuIgnoreFileHint", { path: statusContextTarget.label.replace(/\\/g, "/") })}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore file</strong>
<span>Add only this file to .gitignore</span>
<strong>{t("status.menuIgnoreFile")}</strong>
<span>{t("status.menuIgnoreFileNote")}</span>
</span>
</button>
{/if}
{#if statusContextIgnoreExtension}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={`Add *.${statusContextIgnoreExtension} to .gitignore`}>
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("extension")} disabled={isBusy} title={t("status.menuIgnoreExtHint", { ext: statusContextIgnoreExtension })}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FileType size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore all *.{statusContextIgnoreExtension} files</strong>
<span>Match this file type repository-wide</span>
<strong>{t("status.menuIgnoreExt", { ext: statusContextIgnoreExtension })}</strong>
<span>{t("status.menuIgnoreExtNote")}</span>
</span>
</button>
{/if}
{#if statusContextTarget.kind === "folder" && statusContextIgnoreFolder}
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={`Add /${statusContextIgnoreFolder}/ to .gitignore`}>
<button type="button" role="menuitem" onclick={() => runStatusContextIgnoreAction("folder")} disabled={isBusy} title={t("status.menuIgnoreFolderHint", { path: statusContextIgnoreFolder })}>
<span class="status-context-action-icon ignore" aria-hidden="true"><FolderX size={15} /></span>
<span class="status-context-action-copy">
<strong>Ignore folder</strong>
<span>Add this folder and its contents to .gitignore</span>
<strong>{t("status.menuIgnoreFolder")}</strong>
<span>{t("status.menuIgnoreFolderNote")}</span>
</span>
</button>
{/if}
+16 -15
View File
@@ -2,6 +2,7 @@
import { Check, ChevronDown, ChevronRight, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitTag } from "../types";
import { t } from "../i18n.svelte";
interface Props {
tags: GitTag[];
hasRepository: boolean;
@@ -93,21 +94,21 @@
</script>
<svelte:window on:click={closeTagContextMenu} on:keydown={(event) => { if (event.key === "Escape") closeTagContextMenu(); }} on:contextmenu|capture={closeTagContextMenu} />
<section class="panel tags-panel" class:collapsed aria-label="Tags">
<section class="panel tags-panel" class:collapsed aria-label={t("tags.title")}>
<div class="section-head">
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />Tags</h2>
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />{t("tags.title")}</h2>
<div class="branch-head-actions">
<button class="branch-create-toggle" type="button" onclick={openTagCreateForm} disabled={!hasRepository || isBusy} title="Create new tag" aria-label="Create new tag"><Plus size={14} aria-hidden="true" /></button>
<button class="branch-create-toggle" type="button" onclick={openTagCreateForm} disabled={!hasRepository || isBusy} title={t("tags.create")} aria-label={t("tags.create")}><Plus size={14} aria-hidden="true" /></button>
<span class="pill pill-count">{tags.length}</span>
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
aria-expanded={!collapsed} title={collapsed ? "Expand tags" : "Collapse tags"} aria-label={collapsed ? "Expand tags" : "Collapse tags"}>
aria-expanded={!collapsed} title={collapsed ? t("tags.expand") : t("tags.collapse")} aria-label={collapsed ? t("tags.expand") : t("tags.collapse")}>
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{#if !collapsed}
<div class="sidebar-tags-list">
{#if !hasRepository}<p class="branch-empty">Open a repository to list tags.</p>
{#if !hasRepository}<p class="branch-empty">{t("tags.openRepo")}</p>
{:else}
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
@@ -119,27 +120,27 @@
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
aria-label={t("tags.nameLabel")}
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
placeholder={t("tags.messagePlaceholder")}
aria-label={t("tags.messageLabel")}
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title={t("tags.createAction")}>
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title={t("common.cancel")}>
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
<div class="branch-empty">{t("tags.empty")}</div>
{:else}
{#each tags as tag (tag.name)}
<article
@@ -167,16 +168,16 @@
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
aria-label={t("tags.actionsFor", { name: contextTag.name })}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
{t("tags.push")}
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title={t("tags.deleteLocal")}>
<Trash2 size={14} aria-hidden="true" />
Delete
{t("common.delete")}
</button>
</div>
{/if}
+85 -89
View File
@@ -21,6 +21,8 @@
X,
} from "@lucide/svelte";
import type { GitBranch as GitBranchInfo, GitWorktree } from "../types";
import { t } from "../i18n.svelte";
import ConfirmDialog, { type ConfirmRequest } from "./ConfirmDialog.svelte";
import SelectMenu from "./SelectMenu.svelte";
type CreateMode = "existing" | "new" | "detached";
@@ -99,7 +101,7 @@
let checkedOutBranches = $derived(new Set(worktrees.map((worktree) => worktree.branch).filter((branch): branch is string => Boolean(branch))));
function displayName(worktree: GitWorktree): string {
return worktree.branch || (worktree.detached ? `Detached at ${worktree.short_head || "HEAD"}` : "Bare worktree");
return worktree.branch || (worktree.detached ? t("worktreeDialog.detachedAt", { head: worktree.short_head || "HEAD" }) : t("worktreeDialog.bare"));
}
function pathName(path: string): string {
@@ -117,7 +119,7 @@
async function chooseDestination(current = "") {
const selected = await openDialog({
title: current ? "Choose new worktree location" : "Choose worktree folder",
title: current ? t("worktreeDialog.chooseNewLocation") : t("worktreeDialog.chooseFolder"),
directory: true,
multiple: false,
defaultPath: current || undefined,
@@ -175,14 +177,33 @@
forceRemoval = false;
}
async function confirmRemoval() {
async function confirmRemoval(force: boolean) {
if (!pendingRemoval) return;
if (await onRemove(pendingRemoval, forceRemoval)) {
forceRemoval = force;
if (await onRemove(pendingRemoval, force)) {
pendingRemoval = null;
forceRemoval = false;
}
}
/** Same shape as every other delete confirmation in the app. */
function removalConfirmRequest(worktree: GitWorktree): ConfirmRequest {
return {
eyebrow: t("worktreeDialog.removeEyebrow"),
title: t("confirm.worktreeRemove.title", { name: displayName(worktree) }),
message: t("confirm.worktreeRemove.message"),
items: [worktree.path],
checkbox: worktree.clean
? undefined
: {
label: t("worktreeDialog.removeForce"),
note: t("worktreeDialog.removeForceNote", { count: worktree.changed_files }),
required: true,
},
confirmLabel: t("confirm.worktreeRemove.action"),
};
}
function requestLock(worktree: GitWorktree) {
pendingLock = worktree;
lockReason = "";
@@ -203,28 +224,28 @@
<div class="worktree-dialog-heading unified-dialog-heading">
<span class="worktree-dialog-mark unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Parallel workspaces</span>
<p class="dialog-title" id="worktree-dialog-title">Worktrees</p>
<span class="eyebrow">{t("worktreeDialog.eyebrow")}</span>
<p class="dialog-title" id="worktree-dialog-title">{t("worktrees.title")}</p>
</div>
</div>
<div class="dialog-header-actions">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title="Refresh worktrees">
<button class="btn-sm" type="button" onclick={onRefresh} disabled={isBusy || isLoading} title={t("worktreeDialog.refresh")}>
<RefreshCw class={isLoading ? "spin" : undefined} size={15} aria-hidden="true" />
Refresh
{t("worktreeDialog.refreshShort")}
</button>
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label="Close">
<button class="btn-sm dialog-close" type="button" onclick={onClose} disabled={isBusy} aria-label={t("common.close")}>
<X size={16} aria-hidden="true" />
</button>
</div>
</header>
<div class="worktree-summary">
<div><strong>{linkedCount}</strong><span>linked worktrees</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>with changes</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>stale entries</span></div>
<div><strong>{linkedCount}</strong><span>{t("worktreeDialog.linked")}</span></div>
<div><strong>{worktrees.filter((worktree) => !worktree.clean).length}</strong><span>{t("worktreeDialog.withChanges")}</span></div>
<div class:attention={prunableCount > 0}><strong>{prunableCount}</strong><span>{t("worktreeDialog.stale")}</span></div>
<button class="btn-primary" type="button" onclick={() => { createOpen = !createOpen; }} disabled={isBusy}>
<Plus size={15} aria-hidden="true" />
New worktree
{t("worktreeDialog.new")}
</button>
</div>
@@ -237,33 +258,33 @@
<form class="worktree-create-card" onsubmit={submitCreate}>
<header>
<div>
<span class="eyebrow">Create</span>
<h3>Choose what this workspace should track</h3>
<span class="eyebrow">{t("worktreeDialog.createEyebrow")}</span>
<h3>{t("worktreeDialog.createTitle")}</h3>
</div>
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label="Close create form">
<button class="btn-sm dialog-close" type="button" onclick={() => { createOpen = false; }} disabled={isBusy} aria-label={t("worktreeDialog.closeCreate")}>
<X size={15} aria-hidden="true" />
</button>
</header>
<div class="worktree-mode-tabs" role="tablist" aria-label="Worktree type">
<div class="worktree-mode-tabs" role="tablist" aria-label={t("worktreeDialog.typeLabel")}>
<button class:active={createMode === "existing"} type="button" role="tab" aria-selected={createMode === "existing"} onclick={() => { createMode = "existing"; }}>
<GitBranch size={14} aria-hidden="true" />Existing branch
<GitBranch size={14} aria-hidden="true" />{t("worktreeDialog.existingBranch")}
</button>
<button class:active={createMode === "new"} type="button" role="tab" aria-selected={createMode === "new"} onclick={() => { createMode = "new"; }}>
<Plus size={14} aria-hidden="true" />New branch
<Plus size={14} aria-hidden="true" />{t("worktreeDialog.newBranch")}
</button>
<button class:active={createMode === "detached"} type="button" role="tab" aria-selected={createMode === "detached"} onclick={() => { createMode = "detached"; }}>
<CircleDot size={14} aria-hidden="true" />Detached
<CircleDot size={14} aria-hidden="true" />{t("worktreeDialog.detached")}
</button>
</div>
<div class="worktree-create-fields">
{#if createMode === "existing"}
<label>
<span>Branch</span>
<span>{t("common.branch")}</span>
<SelectMenu
value={selectedBranch}
placeholder="Select a local branch"
placeholder={t("worktreeDialog.selectBranch")}
options={localBranches.map((branch) => ({
value: branch.name,
label: `${branch.name}${!branchAvailable(branch.name) ? " (already checked out)" : ""}`,
@@ -275,26 +296,26 @@
</label>
{:else if createMode === "new"}
<label>
<span>New branch name</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="feature/my-change" />
<span>{t("worktreeDialog.newBranchName")}</span>
<input bind:value={newBranch} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.newBranchPlaceholder")} />
</label>
<label>
<span>Start point</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD, branch or commit" />
<span>{t("worktreeDialog.startPoint")}</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.startPointPlaceholder")} />
</label>
{:else}
<label>
<span>Commit or ref</span>
<span>{t("worktreeDialog.commitOrRef")}</span>
<input bind:value={startPoint} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="HEAD" />
</label>
{/if}
<label class="worktree-path-field">
<span>Folder</span>
<span>{t("worktreeDialog.folder")}</span>
<div>
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder="Choose an empty folder" />
<input bind:value={destination} disabled={isBusy} autocomplete="off" spellcheck="false" placeholder={t("worktreeDialog.folderPlaceholder")} />
<button class="btn-secondary" type="button" onclick={() => chooseDestination()} disabled={isBusy}>
<FolderOpen size={15} aria-hidden="true" />Browse
<FolderOpen size={15} aria-hidden="true" />{t("worktreeDialog.browse")}
</button>
</div>
</label>
@@ -303,7 +324,7 @@
<footer>
<label class="worktree-check">
<input type="checkbox" bind:checked={lockAfterCreate} disabled={isBusy} />
<span><strong>Lock after creation</strong><small>Protects removable or temporary locations from pruning.</small></span>
<span><strong>{t("worktreeDialog.lockAfterCreate")}</strong><small>{t("worktreeDialog.lockAfterCreateNote")}</small></span>
</label>
<button
class="btn-primary"
@@ -311,16 +332,16 @@
disabled={isBusy || !destination.trim() || (createMode === "existing" && (!selectedBranch || !branchAvailable(selectedBranch))) || (createMode === "new" && !newBranch.trim())}
>
{#if isBusy}<LoaderCircle class="spin" size={15} aria-hidden="true" />{:else}<Plus size={15} aria-hidden="true" />{/if}
Create worktree
{t("worktreeDialog.createAction")}
</button>
</footer>
</form>
{/if}
{#if isLoading && worktrees.length === 0}
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>Reading worktrees…</span></div>
<div class="worktree-loading"><LoaderCircle class="spin" size={22} aria-hidden="true" /><span>{t("worktreeDialog.loading")}</span></div>
{:else if worktrees.length === 0}
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>No worktrees found</strong><span>Create one to work on another branch without switching this workspace.</span></div>
<div class="worktree-empty"><HardDrive size={24} aria-hidden="true" /><strong>{t("worktreeDialog.emptyTitle")}</strong><span>{t("worktreeDialog.emptyNote")}</span></div>
{:else}
<div class="worktree-list">
{#each worktrees as worktree (worktree.path)}
@@ -339,11 +360,11 @@
</div>
</div>
<div class="worktree-badges">
{#if worktree.is_main}<span>Main</span>{/if}
{#if worktree.is_current}<span class="active">Open</span>{/if}
{#if worktree.detached}<span>Detached</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />Locked</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">Stale</span>{/if}
{#if worktree.is_main}<span>{t("worktreeDialog.main")}</span>{/if}
{#if worktree.is_current}<span class="active">{t("worktreeDialog.open")}</span>{/if}
{#if worktree.detached}<span>{t("worktreeDialog.detached")}</span>{/if}
{#if worktree.locked}<span class="locked"><Lock size={10} aria-hidden="true" />{t("worktreeDialog.locked")}</span>{/if}
{#if worktree.prunable || worktree.missing}<span class="danger">{t("worktreeDialog.staleBadge")}</span>{/if}
</div>
</header>
@@ -360,31 +381,31 @@
<footer>
<button class="btn-secondary" type="button" onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}>
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? "Refresh tab" : "Open tab"}
<ExternalLink size={14} aria-hidden="true" />{worktree.is_current ? t("worktreeDialog.refreshTab") : t("worktreeDialog.openTab")}
</button>
<div class="worktree-card-actions">
{#if worktree.prunable}
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title="Locate and repair worktree">
<Wrench size={14} aria-hidden="true" />Repair
<button class="btn-sm" type="button" onclick={() => chooseRepairLocation(worktree)} disabled={isBusy} title={t("worktreeDialog.repairHint")}>
<Wrench size={14} aria-hidden="true" />{t("worktreeDialog.repair")}
</button>
{/if}
{#if !worktree.is_main && !worktree.missing}
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title="Move worktree">
<FolderInput size={14} aria-hidden="true" />Move
<button class="btn-sm" type="button" onclick={() => chooseMoveDestination(worktree)} disabled={isBusy || worktree.locked || worktree.is_current} title={t("worktreeDialog.moveHint")}>
<FolderInput size={14} aria-hidden="true" />{t("worktreeDialog.move")}
</button>
{/if}
{#if worktree.locked}
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title="Unlock worktree">
<Unlock size={14} aria-hidden="true" />Unlock
<button class="btn-sm" type="button" onclick={() => onUnlock(worktree)} disabled={isBusy} title={t("worktreeDialog.unlockHint")}>
<Unlock size={14} aria-hidden="true" />{t("worktreeDialog.unlock")}
</button>
{:else if !worktree.is_main}
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title="Lock worktree">
<Lock size={14} aria-hidden="true" />Lock
<button class="btn-sm" type="button" onclick={() => requestLock(worktree)} disabled={isBusy} title={t("worktreeDialog.lockHint")}>
<Lock size={14} aria-hidden="true" />{t("worktreeDialog.lock")}
</button>
{/if}
{#if !worktree.is_main}
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? "Use Prune to remove stale metadata" : "Remove worktree"}>
<Trash2 size={14} aria-hidden="true" />Remove
<button class="btn-sm danger" type="button" onclick={() => requestRemoval(worktree)} disabled={isBusy || worktree.is_current || worktree.locked || worktree.missing} title={worktree.missing ? t("worktreeDialog.removePruneHint") : t("worktreeDialog.removeHint")}>
<Trash2 size={14} aria-hidden="true" />{t("worktreeDialog.remove")}
</button>
{/if}
</div>
@@ -397,7 +418,7 @@
</div>
<footer class="worktree-dialog-footer">
<div><ShieldCheck size={14} aria-hidden="true" /><span>Dirty, active and locked worktrees are protected.</span></div>
<div><ShieldCheck size={14} aria-hidden="true" /><span>{t("worktreeDialog.protectedNote")}</span></div>
<button class="btn-secondary" type="button" onclick={onPrune} disabled={isBusy || prunableCount === 0}>
<Wrench size={14} aria-hidden="true" />Prune {prunableCount || ""}
</button>
@@ -406,37 +427,12 @@
</div>
{#if pendingRemoval}
<div class="dialog-backdrop worktree-nested-backdrop" role="presentation">
<div class="dialog worktree-confirm-dialog" role="alertdialog" aria-modal="true" aria-labelledby="worktree-remove-title">
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Remove worktree</span>
<p class="dialog-title" id="worktree-remove-title">Remove {displayName(pendingRemoval)}?</p>
</div>
<button class="dialog-close" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy} aria-label="Cancel removal"><X size={18} /></button>
</header>
<div class="worktree-confirm-body">
<span class="discard-warning-icon" aria-hidden="true"><AlertTriangle size={21} /></span>
<div>
<p>This removes the worktree folder and its Git registration. The branch itself is kept.</p>
<code>{pendingRemoval.path}</code>
{#if !pendingRemoval.clean}
<label class="worktree-check danger">
<input type="checkbox" bind:checked={forceRemoval} disabled={isBusy} />
<span><strong>Remove despite local changes</strong><small>{pendingRemoval.changed_files} changed files may be permanently deleted.</small></span>
</label>
{/if}
</div>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingRemoval = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-danger" type="button" onclick={confirmRemoval} disabled={isBusy || (!pendingRemoval.clean && !forceRemoval)}>
<Trash2 size={15} aria-hidden="true" />Remove worktree
</button>
</footer>
</div>
</div>
<ConfirmDialog
request={removalConfirmRequest(pendingRemoval)}
{isBusy}
onConfirm={(force) => { void confirmRemoval(force); }}
onCancel={() => { pendingRemoval = null; forceRemoval = false; }}
/>
{/if}
{#if pendingLock}
@@ -445,20 +441,20 @@
<header class="dialog-header unified-dialog-header">
<span class="unified-dialog-icon" aria-hidden="true"><HardDrive size={18} /></span>
<div class="unified-dialog-text">
<span class="eyebrow">Protect worktree</span>
<p class="dialog-title" id="worktree-lock-title">Lock {displayName(pendingLock)}</p>
<span class="eyebrow">{t("worktreeDialog.lockEyebrow")}</span>
<p class="dialog-title" id="worktree-lock-title">{t("worktreeDialog.lockTitle", { name: displayName(pendingLock) })}</p>
</div>
<button class="dialog-close" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy} aria-label="Cancel locking"><X size={18} /></button>
<button class="dialog-close" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy} aria-label={t("worktreeDialog.cancelLock")}><X size={18} /></button>
</header>
<div class="worktree-lock-body">
<label>
<span>Reason <small>optional</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder="External drive, long-running work…" />
<span>{t("worktreeDialog.reason")} <small>{t("worktreeDialog.optional")}</small></span>
<input bind:value={lockReason} disabled={isBusy} autocomplete="off" placeholder={t("worktreeDialog.reasonPlaceholder")} />
</label>
</div>
<footer class="discard-confirm-actions">
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>Cancel</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />Lock</button>
<button class="btn-secondary" type="button" onclick={() => { pendingLock = null; }} disabled={isBusy}>{t("common.cancel")}</button>
<button class="btn-primary" type="button" onclick={confirmLock} disabled={isBusy}><Lock size={15} aria-hidden="true" />{t("worktreeDialog.lock")}</button>
</footer>
</div>
</div>
+15 -14
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, HardDrive, Lock, Plus, RefreshCw } from "@lucide/svelte";
import type { GitWorktree } from "../types";
import { t } from "../i18n.svelte";
interface Props {
worktrees: GitWorktree[];
@@ -19,18 +20,18 @@
const name = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
</script>
<section class="panel worktree-panel" class:collapsed aria-label="Worktrees" aria-busy={loading}>
<section class="panel worktree-panel" class:collapsed aria-label={t("worktrees.title")} aria-busy={loading}>
<div class="section-head">
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />Worktrees</h2>
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />{t("worktrees.title")}</h2>
<div class="branch-head-actions">
<button class="branch-create-toggle" type="button" onclick={onManage} disabled={!hasRepository || isBusy}
title="Create or manage worktrees" aria-label="Create or manage worktrees" aria-haspopup="dialog">
title={t("worktrees.manage")} aria-label={t("worktrees.manage")} aria-haspopup="dialog">
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{loading && linkedWorktrees.length === 0 ? "…" : linkedWorktrees.length}</span>
<button class="branch-create-toggle panel-collapse-toggle" type="button" onclick={onToggleCollapsed}
aria-expanded={!collapsed} title={collapsed ? "Expand worktrees" : "Collapse worktrees"}
aria-label={collapsed ? "Expand worktrees" : "Collapse worktrees"}>
aria-expanded={!collapsed} title={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}
aria-label={collapsed ? t("worktrees.expand") : t("worktrees.collapse")}>
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
</button>
</div>
@@ -38,30 +39,30 @@
{#if !collapsed}
<div class="sidebar-worktree-list">
{#if !hasRepository}
<p class="branch-empty">Open a repository to list worktrees.</p>
<p class="branch-empty">{t("worktrees.openRepo")}</p>
{:else if error}
<div class="sidebar-worktree-error" role="status">
<span>{error}</span>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={loading || isBusy}><RefreshCw size={13} aria-hidden="true" />Retry</button>
<button class="btn-sm" type="button" onclick={onRefresh} disabled={loading || isBusy}><RefreshCw size={13} aria-hidden="true" />{t("worktrees.retry")}</button>
</div>
{:else if loading && linkedWorktrees.length === 0}
<p class="branch-empty" role="status">Loading worktrees…</p>
<p class="branch-empty" role="status">{t("worktrees.loading")}</p>
{:else if linkedWorktrees.length === 0}
<p class="branch-empty">No linked worktrees.</p>
<p class="branch-empty">{t("worktrees.empty")}</p>
{:else}
{#each linkedWorktrees as worktree (worktree.path)}
<button class="sidebar-worktree-row" class:current={worktree.is_current} type="button"
onclick={() => onOpen(worktree)} disabled={isBusy || worktree.missing || worktree.bare}
aria-current={worktree.is_current ? "location" : undefined}
title={`${worktree.path}${worktree.missing ? " — missing" : worktree.bare ? " — bare repository" : ""}`}>
title={`${worktree.path}${worktree.missing ? t("worktrees.missingSuffix") : worktree.bare ? t("worktrees.bareSuffix") : ""}`}>
<HardDrive size={15} aria-hidden="true" />
<span class="sidebar-worktree-info">
<strong>{name(worktree.path)}</strong>
<span><GitBranch size={12} aria-hidden="true" />{worktree.branch || (worktree.bare ? "Bare repository" : `Detached · ${worktree.short_head || "HEAD"}`)}</span>
<span><GitBranch size={12} aria-hidden="true" />{worktree.branch || (worktree.bare ? t("worktrees.bare") : t("worktrees.detached", { head: worktree.short_head || "HEAD" }))}</span>
</span>
{#if worktree.locked}<Lock size={12} aria-label="Locked" />{/if}
{#if worktree.missing}<span class="pill">Missing</span>
{:else if worktree.is_current}<span class="pill pill-active">Current</span>{/if}
{#if worktree.locked}<Lock size={12} aria-label={t("worktrees.locked")} />{/if}
{#if worktree.missing}<span class="pill">{t("worktrees.missing")}</span>
{:else if worktree.is_current}<span class="pill pill-active">{t("worktrees.current")}</span>{/if}
</button>
{/each}
{/if}