Add submodule management and collapsible Worktree/Tags panels #45

Merged
Christoph merged 2 commits from enhance-ui into main 2026-09-16 20:59:41 +00:00
7 changed files with 540 additions and 236 deletions
+189 -30
View File
@@ -24,6 +24,8 @@
import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte";
import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.svelte";
import BranchPanel from "./lib/components/BranchPanel.svelte"; import BranchPanel from "./lib/components/BranchPanel.svelte";
import TagsPanel from "./lib/components/TagsPanel.svelte";
import WorktreePanel from "./lib/components/WorktreePanel.svelte";
import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte"; import CloneRepositoryDialog from "./lib/components/CloneRepositoryDialog.svelte";
import CommandPalette from "./lib/components/CommandPalette.svelte"; import CommandPalette from "./lib/components/CommandPalette.svelte";
import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte"; import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte";
@@ -293,6 +295,8 @@
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1"; const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1"; const LEFT_BRANCH_PANEL_HEIGHT_KEY = "gitlite.leftBranchPanelHeight.v1";
const LEFT_TAGS_PANEL_HEIGHT_KEY = "gitlite.leftTagsPanelHeight.v1";
const LEFT_WORKTREE_PANEL_HEIGHT_KEY = "gitlite.leftWorktreePanelHeight.v1";
const LEFT_STASH_PANEL_HEIGHT_KEY = "gitlite.leftStashPanelHeight.v1"; const LEFT_STASH_PANEL_HEIGHT_KEY = "gitlite.leftStashPanelHeight.v1";
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1"; const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2"; const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
@@ -445,6 +449,8 @@
let worktreeDialogOpen = false; let worktreeDialogOpen = false;
let worktreeInitialBranch = ""; let worktreeInitialBranch = "";
let worktrees: GitWorktree[] = []; let worktrees: GitWorktree[] = [];
let worktreeLoadId = 0;
let worktreePanelCollapsed = loadStoredBoolean("gitlite.worktreePanelCollapsed.v1", false);
let worktreesLoading = false; let worktreesLoading = false;
let worktreeError = ""; let worktreeError = "";
let gitLfsDialogOpen = false; let gitLfsDialogOpen = false;
@@ -544,12 +550,21 @@
let resizingLeftBranchPanel = false; let resizingLeftBranchPanel = false;
let leftBranchResizeStartY = 0; let leftBranchResizeStartY = 0;
let leftBranchResizeStartHeight = 0; let leftBranchResizeStartHeight = 0;
let leftWorktreePanelHeight = loadLeftWorktreePanelHeight();
let resizingLeftWorktreePanel = false;
let leftWorktreeResizeStartY = 0;
let leftWorktreeResizeStartHeight = 0;
let leftTagsPanelHeight = loadLeftTagsPanelHeight();
let resizingLeftTagsPanel = false;
let leftTagsResizeStartY = 0;
let leftTagsResizeStartHeight = 0;
let leftStashPanelHeight = loadLeftStashPanelHeight(); let leftStashPanelHeight = loadLeftStashPanelHeight();
let resizingLeftStashPanel = false; let resizingLeftStashPanel = false;
let leftStashResizeStartY = 0; let leftStashResizeStartY = 0;
let leftStashResizeStartHeight = 0; let leftStashResizeStartHeight = 0;
let tagsPanelCollapsed = loadStoredBoolean("gitlite.tagsPanelCollapsed.v1", true);
let branchPanelCollapsed = loadStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, false); let branchPanelCollapsed = loadStoredBoolean(BRANCH_PANEL_COLLAPSED_KEY, false);
let stashPanelCollapsed = loadStoredBoolean(STASH_PANEL_COLLAPSED_KEY, false); let stashPanelCollapsed = loadStoredBoolean(STASH_PANEL_COLLAPSED_KEY, true);
let explorerPanelCollapsed = loadStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, false); let explorerPanelCollapsed = loadStoredBoolean(EXPLORER_PANEL_COLLAPSED_KEY, false);
let historyAsideWidth = loadHistoryAsideWidth(); let historyAsideWidth = loadHistoryAsideWidth();
let resizingHistoryAside = false; let resizingHistoryAside = false;
@@ -602,8 +617,8 @@
) as Record<string, string>; ) as Record<string, string>;
$: remoteBranches = branches.filter((b) => b.remote); $: remoteBranches = branches.filter((b) => b.remote);
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream; $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed); $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed);
$: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; $: allLeftPanelsCollapsed = branchPanelCollapsed && worktreePanelCollapsed && tagsPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools); $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
$: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools); $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools);
$: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools); $: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools);
@@ -2001,18 +2016,108 @@
persistLeftSidebarWidth(leftSidebarWidth); persistLeftSidebarWidth(leftSidebarWidth);
} }
function buildLeftSidebarRows(branchCollapsed: boolean, stashCollapsed: boolean, explorerCollapsed: boolean): string { function buildLeftSidebarRows(branchCollapsed: boolean, stashCollapsed: boolean, explorerCollapsed: boolean, worktreeCollapsed: boolean, tagsCollapsed: boolean): string {
const branchRow = branchCollapsed const branchRow = branchCollapsed
? "auto" ? "auto"
: `minmax(${LEFT_BRANCH_PANEL_MIN_HEIGHT}px, var(--branch-panel-height, ${LEFT_BRANCH_PANEL_DEFAULT_HEIGHT}px))`; : `minmax(${LEFT_BRANCH_PANEL_MIN_HEIGHT}px, var(--branch-panel-height, ${LEFT_BRANCH_PANEL_DEFAULT_HEIGHT}px))`;
const branchHandleRow = branchCollapsed ? "0" : "14px"; const branchHandleRow = branchCollapsed ? "0" : "6px";
const stashRow = stashCollapsed const stashRow = stashCollapsed
? "auto" ? "auto"
: `minmax(${LEFT_STASH_PANEL_MIN_HEIGHT}px, var(--stash-panel-height, ${LEFT_STASH_PANEL_DEFAULT_HEIGHT}px))`; : `minmax(${LEFT_STASH_PANEL_MIN_HEIGHT}px, var(--stash-panel-height, ${LEFT_STASH_PANEL_DEFAULT_HEIGHT}px))`;
const stashHandleRow = explorerCollapsed || (stashCollapsed && branchCollapsed) ? "0" : "14px"; const stashHandleRow = explorerCollapsed || stashCollapsed ? "0" : "6px";
const explorerRow = explorerCollapsed ? "auto" : `minmax(${LEFT_EXPLORER_PANEL_MIN_HEIGHT}px, 1fr)`; const explorerRow = explorerCollapsed ? "auto" : `minmax(${LEFT_EXPLORER_PANEL_MIN_HEIGHT}px, 1fr)`;
return `${branchRow} ${branchHandleRow} ${stashRow} ${stashHandleRow} ${explorerRow}`; return `${branchRow} ${branchHandleRow} ${worktreeCollapsed ? "42px 0" : "var(--worktree-panel-height, 140px) 6px"} ${tagsCollapsed ? "42px 0" : "var(--tags-panel-height, 140px) 6px"} ${stashRow} ${stashHandleRow} ${explorerRow}`;
}
function clampLeftWorktreePanelHeight(value: number) {
return Math.min(420, Math.max(80, Math.round(value)));
}
function loadLeftWorktreePanelHeight() {
try {
const stored = Number(localStorage.getItem(LEFT_WORKTREE_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampLeftWorktreePanelHeight(stored);
} catch { /* Preferences are optional. */ }
return 140;
}
function persistLeftWorktreePanelHeight() {
try { localStorage.setItem(LEFT_WORKTREE_PANEL_HEIGHT_KEY, String(leftWorktreePanelHeight)); }
catch { /* Preferences are optional. */ }
}
function startLeftWorktreePanelResize(event: PointerEvent) {
event.preventDefault();
resizingLeftWorktreePanel = true;
leftWorktreeResizeStartY = event.clientY;
leftWorktreeResizeStartHeight = leftWorktreePanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onLeftWorktreePanelResizeMove(event: PointerEvent) {
if (!resizingLeftWorktreePanel) return;
leftWorktreePanelHeight = clampLeftWorktreePanelHeight(leftWorktreeResizeStartHeight + event.clientY - leftWorktreeResizeStartY);
}
function endLeftWorktreePanelResize(event: PointerEvent) {
if (!resizingLeftWorktreePanel) return;
resizingLeftWorktreePanel = false;
persistLeftWorktreePanelHeight();
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onLeftWorktreePanelResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
leftWorktreePanelHeight = clampLeftWorktreePanelHeight(leftWorktreePanelHeight + (event.key === "ArrowDown" ? 20 : -20));
persistLeftWorktreePanelHeight();
}
function clampLeftTagsPanelHeight(value: number) {
return Math.min(420, Math.max(80, Math.round(value)));
}
function loadLeftTagsPanelHeight() {
try {
const stored = Number(localStorage.getItem(LEFT_TAGS_PANEL_HEIGHT_KEY));
if (Number.isFinite(stored) && stored > 0) return clampLeftTagsPanelHeight(stored);
} catch { /* Preferences are optional. */ }
return 140;
}
function persistLeftTagsPanelHeight() {
try { localStorage.setItem(LEFT_TAGS_PANEL_HEIGHT_KEY, String(leftTagsPanelHeight)); }
catch { /* Preferences are optional. */ }
}
function startLeftTagsPanelResize(event: PointerEvent) {
event.preventDefault();
resizingLeftTagsPanel = true;
leftTagsResizeStartY = event.clientY;
leftTagsResizeStartHeight = leftTagsPanelHeight;
(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId);
}
function onLeftTagsPanelResizeMove(event: PointerEvent) {
if (!resizingLeftTagsPanel) return;
leftTagsPanelHeight = clampLeftTagsPanelHeight(leftTagsResizeStartHeight + event.clientY - leftTagsResizeStartY);
}
function endLeftTagsPanelResize(event: PointerEvent) {
if (!resizingLeftTagsPanel) return;
resizingLeftTagsPanel = false;
persistLeftTagsPanelHeight();
const target = event.currentTarget as HTMLElement;
if (target.hasPointerCapture(event.pointerId)) target.releasePointerCapture(event.pointerId);
}
function onLeftTagsPanelResizeKeydown(event: KeyboardEvent) {
if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return;
event.preventDefault();
leftTagsPanelHeight = clampLeftTagsPanelHeight(leftTagsPanelHeight + (event.key === "ArrowDown" ? 20 : -20));
persistLeftTagsPanelHeight();
} }
function startLeftBranchPanelResize(event: PointerEvent) { function startLeftBranchPanelResize(event: PointerEvent) {
@@ -2248,6 +2353,8 @@
worktreeDialogOpen = false; worktreeDialogOpen = false;
worktreeInitialBranch = ""; worktreeInitialBranch = "";
worktrees = []; worktrees = [];
worktreeLoadId++;
worktreesLoading = false;
submoduleDialogOpen = false; submoduleDialogOpen = false;
submodules = []; submodules = [];
uninitializedSubmoduleCount = 0; uninitializedSubmoduleCount = 0;
@@ -2454,6 +2561,7 @@
refreshStashes(resolvedPath, bundle.stashes), refreshStashes(resolvedPath, bundle.stashes),
refreshCommitHistory(resolvedPath, bundle.commits), refreshCommitHistory(resolvedPath, bundle.commits),
refreshExplorerFiles(resolvedPath, bundle.files), refreshExplorerFiles(resolvedPath, bundle.files),
refreshWorktrees(resolvedPath),
]); ]);
if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) { if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) {
@@ -3373,16 +3481,8 @@
if (!activeRepoPath || isBusy) return; if (!activeRepoPath || isBusy) return;
worktreeInitialBranch = branch; worktreeInitialBranch = branch;
worktreeDialogOpen = true; worktreeDialogOpen = true;
worktreeError = ""; await refreshWorktrees();
worktreesLoading = true;
try {
worktrees = await listWorktrees(activeRepoPath);
trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) }); trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) });
} catch (error) {
worktreeError = errorToMessage(error);
} finally {
worktreesLoading = false;
}
} }
function openBranchInWorktree(branch: GitBranchInfo) { function openBranchInWorktree(branch: GitBranchInfo) {
@@ -3390,16 +3490,18 @@
void openWorktreeDialog(branch.name); void openWorktreeDialog(branch.name);
} }
async function refreshWorktrees() { async function refreshWorktrees(path = activeRepoPath) {
if (!activeRepoPath || worktreesLoading) return; if (!path) return;
const request = ++worktreeLoadId;
worktreesLoading = true; worktreesLoading = true;
worktreeError = ""; worktreeError = "";
try { try {
worktrees = await listWorktrees(activeRepoPath); const loaded = await listWorktrees(path);
if (request === worktreeLoadId && sameRepoPath(path, activeRepoPath)) worktrees = loaded;
} catch (error) { } catch (error) {
worktreeError = errorToMessage(error); if (request === worktreeLoadId && sameRepoPath(path, activeRepoPath)) worktreeError = errorToMessage(error);
} finally { } finally {
worktreesLoading = false; if (request === worktreeLoadId) worktreesLoading = false;
} }
} }
@@ -5838,7 +5940,7 @@
style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-min-width: {HISTORY_ASIDE_MIN_WIDTH}px; --history-aside-width: {historyAsideWidth}px;" style="--left-sidebar-width: {leftSidebarWidth}px; --history-aside-min-width: {HISTORY_ASIDE_MIN_WIDTH}px; --history-aside-width: {historyAsideWidth}px;"
> >
<!-- Left sidebar: branches + explorer --> <!-- Compact repository navigation: branches, worktrees, tags, stashes, files -->
<aside <aside
class="left-sidebar" class="left-sidebar"
class:branch-collapsed={branchPanelCollapsed} class:branch-collapsed={branchPanelCollapsed}
@@ -5846,13 +5948,12 @@
class:explorer-collapsed={explorerPanelCollapsed} class:explorer-collapsed={explorerPanelCollapsed}
class:all-collapsed={allLeftPanelsCollapsed} class:all-collapsed={allLeftPanelsCollapsed}
aria-label="Repository navigation" aria-label="Repository navigation"
style="--branch-panel-height: {leftBranchPanelHeight}px; --stash-panel-height: {leftStashPanelHeight}px; grid-template-rows: {leftSidebarRows};" style="--tags-panel-height: {leftTagsPanelHeight}px; --worktree-panel-height: {leftWorktreePanelHeight}px; --branch-panel-height: {leftBranchPanelHeight}px; --stash-panel-height: {leftStashPanelHeight}px; grid-template-rows: {leftSidebarRows};"
> >
<BranchPanel <BranchPanel
{branches} {branches}
{localBranches} {localBranches}
{remoteBranches} {remoteBranches}
{tags}
{hasRepository} {hasRepository}
{isBusy} {isBusy}
onCheckout={checkout} onCheckout={checkout}
@@ -5864,10 +5965,6 @@
onDeleteBranch={deleteLocalBranch} onDeleteBranch={deleteLocalBranch}
onDeleteRemoteBranch={deleteTrackedRemoteBranch} onDeleteRemoteBranch={deleteTrackedRemoteBranch}
onDeleteBranchFolder={deleteBranchFolder} onDeleteBranchFolder={deleteBranchFolder}
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
onManageWorktrees={() => { void openWorktreeDialog(); }}
onCreateWorktree={openBranchInWorktree} onCreateWorktree={openBranchInWorktree}
collapsed={branchPanelCollapsed} collapsed={branchPanelCollapsed}
onToggleCollapsed={toggleBranchPanelCollapsed} onToggleCollapsed={toggleBranchPanelCollapsed}
@@ -5894,6 +5991,68 @@
{:else} {:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span> <span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if} {/if}
<WorktreePanel
{worktrees}
{hasRepository}
{isBusy}
loading={worktreesLoading}
error={worktreeError}
collapsed={worktreePanelCollapsed}
onToggleCollapsed={() => {
worktreePanelCollapsed = !worktreePanelCollapsed;
persistStoredBoolean("gitlite.worktreePanelCollapsed.v1", worktreePanelCollapsed);
}}
onOpen={openWorktreeTab}
onManage={() => { void openWorktreeDialog(); }}
onRefresh={() => { void refreshWorktrees(); }}
/>
{#if !worktreePanelCollapsed}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle left-panel-resize-handle worktree-panel-resize-handle"
class:resizing={resizingLeftWorktreePanel}
role="separator" aria-orientation="horizontal" aria-label="Resize worktrees panel height"
aria-valuenow={leftWorktreePanelHeight} aria-valuemin={80} aria-valuemax={420}
tabindex="0"
onpointerdown={startLeftWorktreePanelResize}
onpointermove={onLeftWorktreePanelResizeMove}
onpointerup={endLeftWorktreePanelResize}
onpointercancel={endLeftWorktreePanelResize}
onkeydown={onLeftWorktreePanelResizeKeydown}
></div>
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
<TagsPanel
{tags} {hasRepository} {isBusy}
collapsed={tagsPanelCollapsed}
onToggleCollapsed={() => {
tagsPanelCollapsed = !tagsPanelCollapsed;
persistStoredBoolean("gitlite.tagsPanelCollapsed.v1", tagsPanelCollapsed);
}}
onCreateTag={createNewTag}
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
/>
{#if !tagsPanelCollapsed}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="panel-resize-handle left-panel-resize-handle tags-panel-resize-handle"
class:resizing={resizingLeftTagsPanel}
role="separator" aria-orientation="horizontal" aria-label="Resize tags panel height"
aria-valuenow={leftTagsPanelHeight} aria-valuemin={80} aria-valuemax={420}
tabindex="0"
onpointerdown={startLeftTagsPanelResize}
onpointermove={onLeftTagsPanelResizeMove}
onpointerup={endLeftTagsPanelResize}
onpointercancel={endLeftTagsPanelResize}
onkeydown={onLeftTagsPanelResizeKeydown}
></div>
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
<StashPanel <StashPanel
{stashes} {stashes}
changedCount={changedFiles.length} changedCount={changedFiles.length}
@@ -5906,7 +6065,7 @@
collapsed={stashPanelCollapsed} collapsed={stashPanelCollapsed}
onToggleCollapsed={toggleStashPanelCollapsed} onToggleCollapsed={toggleStashPanelCollapsed}
/> />
{#if !explorerPanelCollapsed && (!stashPanelCollapsed || !branchPanelCollapsed)} {#if !explorerPanelCollapsed && !stashPanelCollapsed}
<!-- svelte-ignore a11y_no_noninteractive_tabindex --> <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions --> <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div <div
@@ -6328,7 +6487,7 @@
isLoading={worktreesLoading} isLoading={worktreesLoading}
{isBusy} {isBusy}
error={worktreeError} error={worktreeError}
onRefresh={refreshWorktrees} onRefresh={() => refreshWorktrees()}
onOpen={openWorktreeTab} onOpen={openWorktreeTab}
onAdd={createWorktree} onAdd={createWorktree}
onRemove={removeSelectedWorktree} onRemove={removeSelectedWorktree}
+80
View File
@@ -9173,3 +9173,83 @@ section > header.page-header.page-header {
border-color: var(--dialog-close-hover-ink); border-color: var(--dialog-close-hover-ink);
background: color-mix(in srgb, var(--dialog-close-hover-ink) 12%, var(--app-dialog-chrome)); background: color-mix(in srgb, var(--dialog-close-hover-ink) 12%, var(--app-dialog-chrome));
} }
/* Compact accordion navigation (layout A). */
.left-sidebar { overflow-y: auto; overflow-x: hidden; }
.left-sidebar > .panel { min-width: 0; }
.left-sidebar .section-head {
display: flex; align-items: center; justify-content: space-between; gap: 6px;
min-height: 42px; padding: 6px 10px;
background: var(--color-surface-solid);
}
.left-sidebar .sidebar-section-title {
display: flex; align-items: center; gap: 9px; min-width: 0; margin: 0;
color: var(--color-ink-muted); font-size: 11px; font-weight: 750;
line-height: 20px; letter-spacing: .055em; text-transform: uppercase;
}
.left-sidebar .sidebar-section-title svg { flex-shrink: 0; }
.left-sidebar .sidebar-section-title svg { width: 16px; height: 16px; stroke-width: 1.75; color: var(--color-ink-muted); }
.left-sidebar :is(.branch-head-actions, .stash-head-actions, .explorer-head-actions) { gap: 3px; }
.left-sidebar :is(.branch-create-toggle, .stash-toggle, .explorer-bulk-button) {
width: 24px; min-width: 24px; height: 24px; min-height: 24px; flex-basis: 24px;
border-color: transparent; background: transparent; border-radius: 3px;
}
.left-sidebar :is(.branch-create-toggle, .stash-toggle, .explorer-bulk-button):hover:not(:disabled) {
background: var(--color-surface-hover); color: var(--color-ink);
}
.left-sidebar .left-panel-resize-handle { min-height: 6px; }
.left-sidebar .branch-list, .left-sidebar .explorer-list { padding: 4px 0; }
.left-sidebar .branch-group-toggle {
min-height: 30px; padding: 5px 10px; border-radius: 0;
font-size: 12px; font-weight: 500; letter-spacing: normal; text-transform: none;
}
.left-sidebar .branch-row {
min-height: 34px; padding: 5px 10px; padding-left: calc(14px + var(--branch-indent, 0px));
border-radius: 0; margin-top: 0;
}
.left-sidebar .branch-row.current { border-color: transparent; box-shadow: inset 3px 0 0 var(--color-primary); }
.left-sidebar .branch-info strong { font-size: 12px; font-weight: 550; }
.left-sidebar .branch-row.current strong { font-weight: 700; }
.left-sidebar .branch-folder-row { min-height: 32px; border-radius: 0; margin-top: 0; }
.left-sidebar .stash-panel { display: flex; flex-direction: column; min-height: 0; }
.left-sidebar .stash-panel .section-head, .left-sidebar .stash-create { flex-shrink: 0; }
.left-sidebar .stash-list { flex: 1; min-height: 0; overflow: auto; }
.left-sidebar .stash-empty { flex: 1; padding: 16px 10px; }
.left-sidebar .stash-create { grid-template-columns: minmax(0, 1fr) auto; }
.left-sidebar .stash-input { grid-column: 1 / -1; }
.left-sidebar .explorer-tool-action, .left-sidebar .explorer-action-divider { display: none; }
/* File actions remain available through the file context menu. */
.left-sidebar .worktree-panel { display: grid; grid-template-rows: 42px minmax(0, 1fr); min-height: 0; overflow: hidden; }
.left-sidebar .worktree-panel.collapsed { grid-template-rows: 42px; }
.sidebar-worktree-list { min-height: 0; overflow: auto; padding: 4px 0; }
.sidebar-worktree-row {
display: flex; align-items: center; gap: 8px; width: 100%; min-height: 46px;
padding: 6px 10px; border: 0; border-radius: 0; background: transparent;
color: var(--color-ink-muted); text-align: left;
}
.sidebar-worktree-row.current {
background: color-mix(in srgb, var(--color-primary) 11%, var(--color-surface-solid));
box-shadow: inset 3px 0 0 var(--color-primary);
}
.sidebar-worktree-row:hover:not(:disabled) { background: var(--color-surface-hover); }
.sidebar-worktree-row > svg { flex-shrink: 0; }
.sidebar-worktree-info { display: grid; gap: 2px; flex: 1; min-width: 0; }
.sidebar-worktree-info strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; color: var(--color-ink); }
.sidebar-worktree-info > span { display: flex; align-items: center; gap: 4px; overflow: hidden; white-space: nowrap; font-size: 10px; color: var(--color-ink-dim); }
.sidebar-worktree-info > span svg { flex-shrink: 0; }
.sidebar-worktree-error { display: grid; gap: 8px; padding: 10px; font-size: 11px; overflow-wrap: anywhere; color: var(--color-ink-muted); }
.sidebar-worktree-error button { justify-self: start; }
/* All collapsed sections share exactly the same header and icon geometry. */
.left-sidebar > .panel { border: 0; }
.left-sidebar > .panel + .panel { border-top: 0; }
.left-sidebar .section-head { height: 42px; min-height: 42px; box-sizing: border-box; }
.left-sidebar > .panel.collapsed { height: 42px; min-height: 42px; }
.left-sidebar .section-head button svg { width: 14px; height: 14px; stroke-width: 1.75; }
.left-sidebar .section-head .pill-count { min-width: 24px; justify-content: center; }
.left-sidebar .tags-panel { display: grid; grid-template-rows: 42px minmax(0, 1fr); min-height: 0; overflow: hidden; }
.left-sidebar .tags-panel.collapsed { grid-template-rows: 42px; }
.sidebar-tags-list { min-height: 0; overflow: auto; padding: 4px 0; }
.left-sidebar .tags-panel .tag-create-form { grid-template-columns: auto minmax(0, 1fr) auto auto; margin: 4px 8px; }
.left-sidebar .tags-panel .tag-create-form input[aria-label="Tag message"] { grid-column: 2 / -1; grid-row: 2; }
+4 -196
View File
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte"; import { Check, ChevronDown, ChevronRight, Folder, FolderOpen, GitBranch, GitCompare, GitMerge, HardDrive, Pencil, Plus, Trash2, X } from "@lucide/svelte";
import { tick } from "svelte"; import { tick } from "svelte";
import type { GitBranch as GitBranchInfo, GitTag } from "../types"; import type { GitBranch as GitBranchInfo } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode; type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -48,7 +48,6 @@
branches: GitBranchInfo[]; branches: GitBranchInfo[];
localBranches: GitBranchInfo[]; localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[]; remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean; hasRepository: boolean;
isBusy: boolean; isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void; onCheckout: (branch: GitBranchInfo) => void;
@@ -60,10 +59,6 @@
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>; onDeleteRemoteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>; onDeleteBranchFolder: (folderName: string, branches: GitBranchInfo[], depth: number) => void | Promise<void>;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
onManageWorktrees: () => void;
onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>; onCreateWorktree: (branch: GitBranchInfo) => void | Promise<void>;
collapsed?: boolean; collapsed?: boolean;
onToggleCollapsed?: () => void; onToggleCollapsed?: () => void;
@@ -73,7 +68,6 @@
branches = [], branches = [],
localBranches = [], localBranches = [],
remoteBranches = [], remoteBranches = [],
tags = [],
hasRepository = false, hasRepository = false,
isBusy = false, isBusy = false,
onCheckout = () => {}, onCheckout = () => {},
@@ -85,10 +79,6 @@
onDeleteBranch = () => {}, onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {}, onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {}, onDeleteBranchFolder = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
onManageWorktrees = () => {},
onCreateWorktree = () => {}, onCreateWorktree = () => {},
collapsed = false, collapsed = false,
onToggleCollapsed = () => {}, onToggleCollapsed = () => {},
@@ -96,23 +86,14 @@
let localOpen = $state(true); let localOpen = $state(true);
let remoteOpen = $state(false); let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false); let createOpen = $state(false);
let newBranchName = $state(""); let newBranchName = $state("");
let createInput = $state<HTMLInputElement | null>(null); let createInput = $state<HTMLInputElement | null>(null);
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let contextBranch = $state<GitBranchInfo | null>(null); let contextBranch = $state<GitBranchInfo | null>(null);
let contextFolder = $state<BranchFolderRow | null>(null); let contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null); let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $state(0); let contextMenuX = $state(0);
let contextMenuY = $state(0); let contextMenuY = $state(0);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
let collapsedBranchFolders = $state<Set<string>>(new Set()); let collapsedBranchFolders = $state<Set<string>>(new Set());
let localBranchRows = $derived(buildBranchRows("local", localBranches, "local")); let localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -227,6 +208,7 @@
function openCreateForm() { function openCreateForm() {
if (!hasRepository || isBusy) return; if (!hasRepository || isBusy) return;
if (collapsed) onToggleCollapsed();
createOpen = true; createOpen = true;
queueMicrotask(() => createInput?.focus()); queueMicrotask(() => createInput?.focus());
} }
@@ -357,66 +339,8 @@
await onRebase(branch); await onRebase(branch);
} }
function openTagCreateForm() {
if (!hasRepository || isBusy) return;
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
tagsOpen = true;
}
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
contextTag = tag;
tagContextMenuX = event.clientX + 2;
tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
function closeAllContextMenus() { function closeAllContextMenus() {
closeBranchContextMenu(); closeBranchContextMenu();
closeTagContextMenu();
} }
function handleWindowKeydown(event: KeyboardEvent) { function handleWindowKeydown(event: KeyboardEvent) {
@@ -428,10 +352,7 @@
<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="Branches">
<div class="section-head"> <div class="section-head">
<div> <h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />Branches</h2>
<span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div>
<div class="branch-head-actions"> <div class="branch-head-actions">
<button <button
class="branch-create-toggle" class="branch-create-toggle"
@@ -543,7 +464,6 @@
<GitBranch size={16} aria-hidden="true" /> <GitBranch size={16} aria-hidden="true" />
<div> <div>
<strong>{row.displayName}</strong> <strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div> </div>
</div> </div>
{#if row.branch.current} {#if row.branch.current}
@@ -611,7 +531,6 @@
<GitBranch size={16} aria-hidden="true" /> <GitBranch size={16} aria-hidden="true" />
<div> <div>
<strong>{row.displayName}</strong> <strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div> </div>
</div> </div>
{#if row.branch.current} {#if row.branch.current}
@@ -624,100 +543,9 @@
{/if} {/if}
</div> </div>
<div class="branch-group">
<div class="tag-group-head">
<button
class="branch-group-toggle"
type="button"
onclick={() => { tagsOpen = !tagsOpen; }}
aria-expanded={tagsOpen}
>
{#if tagsOpen}
<ChevronDown size={14} aria-hidden="true" />
{:else}
<ChevronRight size={14} aria-hidden="true" />
{/if}
<span>Tags</span>
<span class="branch-group-count">{tags.length}</span>
</button>
<button
class="branch-create-toggle"
type="button"
onclick={openTagCreateForm}
disabled={!hasRepository || isBusy}
title="Create new tag"
aria-label="Create new tag"
>
<Plus size={13} aria-hidden="true" />
</button>
</div>
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tagsOpen}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
<div class="branch-group">
<button
class="branch-group-toggle worktree-group-toggle"
type="button"
onclick={onManageWorktrees}
disabled={isBusy}
aria-haspopup="dialog"
title="Manage repository worktrees"
>
<HardDrive size={14} aria-hidden="true" />
<span>Worktrees</span>
<span class="branch-group-count">Manage</span>
</button>
</div>
</div> </div>
{/if} {/if}
@@ -792,24 +620,4 @@
</div> </div>
{/if} {/if}
{#if contextTag}
<div
bind:this={tagContextMenuElement}
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section> </section>
+1 -4
View File
@@ -293,10 +293,7 @@
<section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="File explorer"> <section class="panel explorer-panel grid grid-rows-[auto_1fr] overflow-hidden" class:collapsed aria-label="File explorer">
<div class="section-head"> <div class="section-head">
<div> <h2 class="sidebar-section-title"><Folder size={16} aria-hidden="true" />Files</h2>
<span class="eyebrow">Explorer</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
</div>
<div class="explorer-head-actions"> <div class="explorer-head-actions">
<button <button
class="explorer-bulk-button explorer-tool-action" class="explorer-bulk-button explorer-tool-action"
+12 -5
View File
@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { Archive, ChevronDown, ChevronRight, Download, Trash2, Upload } from "@lucide/svelte"; import { Archive, ChevronDown, ChevronRight, Download, Plus, Trash2, Upload } from "@lucide/svelte";
import type { GitStash } from "../types"; import type { GitStash } from "../types";
interface Props { interface Props {
@@ -28,6 +28,7 @@
onToggleCollapsed = () => {}, onToggleCollapsed = () => {},
}: Props = $props(); }: Props = $props();
let createOpen = $state(false);
let message = $state(""); let message = $state("");
let includeUntracked = $state(true); let includeUntracked = $state(true);
@@ -43,11 +44,13 @@
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash"> <section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
<div class="section-head"> <div class="section-head">
<div> <h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />Stashes</h2>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<div class="stash-head-actions"> <div class="stash-head-actions">
<button class="stash-toggle" type="button" title="Create stash" aria-label="Create stash"
disabled={!hasRepository || isBusy || changedCount === 0}
onclick={() => { createOpen = !createOpen; if (collapsed) { createOpen = true; onToggleCollapsed(); } }}>
<Plus size={14} aria-hidden="true" />
</button>
<span class="pill pill-count">{stashes.length}</span> <span class="pill pill-count">{stashes.length}</span>
<button <button
class="stash-toggle panel-collapse-toggle" class="stash-toggle panel-collapse-toggle"
@@ -71,12 +74,14 @@
{:else if !hasRepository} {:else if !hasRepository}
<div class="blank-state">No repository loaded.</div> <div class="blank-state">No repository loaded.</div>
{:else} {:else}
{#if createOpen}
<div class="stash-create"> <div class="stash-create">
<input <input
class="stash-input" class="stash-input"
type="text" type="text"
bind:value={message} bind:value={message}
placeholder="Optional message" placeholder="Optional message"
aria-label="Stash message"
disabled={isBusy || changedCount === 0} disabled={isBusy || changedCount === 0}
onkeydown={(event) => { onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush(); if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
@@ -98,6 +103,8 @@
</button> </button>
</div> </div>
{/if}
{#if stashes.length === 0} {#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div> <div class="blank-state stash-empty">No stashes saved.</div>
{:else} {:else}
+183
View File
@@ -0,0 +1,183 @@
<script lang="ts">
import { Check, ChevronDown, ChevronRight, Plus, Tag as TagIcon, Trash2, Upload, X } from "@lucide/svelte";
import { tick } from "svelte";
import type { GitTag } from "../types";
interface Props {
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
collapsed: boolean;
onToggleCollapsed: () => void;
onCreateTag: (name: string, message: string) => void | Promise<void>;
onDeleteTag: (tag: GitTag) => void | Promise<void>;
onPushTag: (tag: GitTag) => void | Promise<void>;
}
let { tags, hasRepository, isBusy, collapsed, onToggleCollapsed, onCreateTag, onDeleteTag, onPushTag }: Props = $props();
let tagCreateOpen = $state(false);
let newTagName = $state("");
let newTagMessage = $state("");
let tagCreateInput = $state<HTMLInputElement | null>(null);
let contextTag = $state<GitTag | null>(null);
let tagContextMenuElement = $state<HTMLElement | null>(null);
let tagContextMenuX = $state(0);
let tagContextMenuY = $state(0);
function fitContextMenuToViewport(element: HTMLElement | null, x: number, y: number) {
const rect = element?.getBoundingClientRect();
const width = rect?.width ?? 184;
const height = rect?.height ?? 0;
return {
x: Math.max(8, Math.min(x + 2, window.innerWidth - width - 8)),
y: Math.max(8, Math.min(y + 2, window.innerHeight - height - 8)),
};
}
function openTagCreateForm() {
if (!hasRepository || isBusy) return;
if (collapsed) onToggleCollapsed();
tagCreateOpen = true;
queueMicrotask(() => tagCreateInput?.focus());
}
function closeTagCreateForm() {
tagCreateOpen = false;
newTagName = "";
newTagMessage = "";
}
async function submitCreateTag(event: SubmitEvent) {
event.preventDefault();
const name = newTagName.trim();
if (!name || !hasRepository || isBusy) return;
await onCreateTag(name, newTagMessage.trim());
newTagName = "";
newTagMessage = "";
tagCreateOpen = false;
}
async function openTagContextMenu(event: MouseEvent, tag: GitTag) {
event.preventDefault();
event.stopPropagation();
if (isBusy) return;
contextTag = tag;
tagContextMenuX = event.clientX + 2;
tagContextMenuY = event.clientY + 2;
await tick();
if (contextTag !== tag) return;
const position = fitContextMenuToViewport(tagContextMenuElement, event.clientX, event.clientY);
tagContextMenuX = position.x;
tagContextMenuY = position.y;
}
function closeTagContextMenu() {
contextTag = null;
}
async function pushContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onPushTag(tag);
}
async function deleteContextTag() {
const tag = contextTag;
if (!tag || isBusy) return;
closeTagContextMenu();
await onDeleteTag(tag);
}
</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">
<div class="section-head">
<h2 class="sidebar-section-title"><TagIcon size={16} aria-hidden="true" />Tags</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>
<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"}>
{#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>
{:else}
{#if tagCreateOpen}
<form class="branch-create-form tag-create-form" onsubmit={submitCreateTag}>
<TagIcon size={15} aria-hidden="true" />
<input
bind:this={tagCreateInput}
bind:value={newTagName}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="v1.0.0"
aria-label="New tag name"
/>
<input
bind:value={newTagMessage}
disabled={isBusy}
autocomplete="off"
spellcheck="false"
placeholder="Message (optional)"
aria-label="Tag message"
/>
<button class="branch-create-action confirm" type="submit" disabled={isBusy || newTagName.trim().length === 0} title="Create tag">
<Check size={14} aria-hidden="true" />
</button>
<button class="branch-create-action" type="button" onclick={closeTagCreateForm} disabled={isBusy} title="Cancel">
<X size={14} aria-hidden="true" />
</button>
</form>
{/if}
{#if tags.length === 0}
<div class="branch-empty">No tags.</div>
{:else}
{#each tags as tag (tag.name)}
<article
class="branch-row"
oncontextmenu={(event) => openTagContextMenu(event, tag)}
title={tag.message ?? tag.name}
>
<div class="branch-info">
<TagIcon size={16} aria-hidden="true" />
<div>
<strong>{tag.name}</strong>
<span>{tag.short_hash}</span>
</div>
</div>
</article>
{/each}
{/if}
{/if}
</div>
{/if}
{#if contextTag}
<div
bind:this={tagContextMenuElement}
class="branch-context-menu"
style={`left: ${tagContextMenuX}px; top: ${tagContextMenuY}px;`}
role="menu"
tabindex="-1"
aria-label={`Actions for ${contextTag.name}`}
>
<button type="button" role="menuitem" onclick={pushContextTag} disabled={isBusy}>
<Upload size={14} aria-hidden="true" />
Push to remote
</button>
<div class="menu-separator" role="separator"></div>
<button class="danger" type="button" role="menuitem" onclick={deleteContextTag} disabled={isBusy} title="Delete local tag">
<Trash2 size={14} aria-hidden="true" />
Delete
</button>
</div>
{/if}
</section>
+70
View File
@@ -0,0 +1,70 @@
<script lang="ts">
import { ChevronDown, ChevronRight, GitBranch, HardDrive, Lock, Plus, RefreshCw } from "@lucide/svelte";
import type { GitWorktree } from "../types";
interface Props {
worktrees: GitWorktree[];
hasRepository: boolean;
isBusy: boolean;
loading: boolean;
error: string;
collapsed: boolean;
onToggleCollapsed: () => void;
onOpen: (worktree: GitWorktree) => void;
onManage: () => void;
onRefresh: () => void;
}
let { worktrees, hasRepository, isBusy, loading, error, collapsed, onToggleCollapsed, onOpen, onManage, onRefresh }: Props = $props();
let linkedWorktrees = $derived(worktrees.filter(worktree => !worktree.is_main));
const name = (path: string) => path.replace(/[\\/]+$/, "").split(/[\\/]/).pop() || path;
</script>
<section class="panel worktree-panel" class:collapsed aria-label="Worktrees" aria-busy={loading}>
<div class="section-head">
<h2 class="sidebar-section-title"><HardDrive size={16} aria-hidden="true" />Worktrees</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">
<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"}>
{#if collapsed}<ChevronRight size={14} aria-hidden="true" />{:else}<ChevronDown size={14} aria-hidden="true" />{/if}
</button>
</div>
</div>
{#if !collapsed}
<div class="sidebar-worktree-list">
{#if !hasRepository}
<p class="branch-empty">Open a repository to list worktrees.</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>
</div>
{:else if loading && linkedWorktrees.length === 0}
<p class="branch-empty" role="status">Loading worktrees…</p>
{:else if linkedWorktrees.length === 0}
<p class="branch-empty">No linked worktrees.</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" : ""}`}>
<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>
{#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}
</button>
{/each}
{/if}
</div>
{/if}
</section>