feat(ui): add collapsible Worktree and Tags panels to left sidebar

Add two new sidebar components (WorktreePanel, TagsPanel) and integrate them
into the compact left navigation layout:

- Wire up imports and rendering in App.svelte, including collapse toggles and
  resize handles for both panels. Persisted heights (localStorage) and keyboard
  resizing are supported; heights are clamped between 80 and 420px with a 140px
  default.
- Extend buildLeftSidebarRows and left-sidebar grid/template styles to include
  the new panels and reduce panel-handle thickness. Add extensive CSS for the
  compact accordion navigation, worktree and tags lists.
- Move tag and worktree management UI out of BranchPanel (remove tag props),
  and add dedicated handlers in App.svelte for the new panels.
- Refactor worktree loading: introduce a worktreeLoadId to guard async
  refreshWorktrees() calls and avoid race conditions. refreshWorktrees(path?)
  now takes an optional path and updates worktree state only when the request
  is still relevant.
- Small behavioral tweaks: stash panel default collapsed state changed to true
  and the explorer resize-handle visibility condition adjusted.

This commit only adds the UI/UX integration and local persistence for the new
panels and their resizing/refresh behavior.
This commit is contained in:
2026-09-16 22:58:48 +02:00
parent 66c85321ea
commit db4e58e039
7 changed files with 540 additions and 236 deletions
+4 -196
View File
@@ -1,7 +1,7 @@
<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 type { GitBranch as GitBranchInfo, GitTag } from "../types";
import type { GitBranch as GitBranchInfo } from "../types";
type BranchTreeNode = BranchFolderNode | BranchLeafNode;
@@ -48,7 +48,6 @@
branches: GitBranchInfo[];
localBranches: GitBranchInfo[];
remoteBranches: GitBranchInfo[];
tags: GitTag[];
hasRepository: boolean;
isBusy: boolean;
onCheckout: (branch: GitBranchInfo) => void;
@@ -60,10 +59,6 @@
onDeleteBranch: (branch: GitBranchInfo) => void | Promise<void>;
onDeleteRemoteBranch: (branch: GitBranchInfo) => 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>;
collapsed?: boolean;
onToggleCollapsed?: () => void;
@@ -73,7 +68,6 @@
branches = [],
localBranches = [],
remoteBranches = [],
tags = [],
hasRepository = false,
isBusy = false,
onCheckout = () => {},
@@ -85,10 +79,6 @@
onDeleteBranch = () => {},
onDeleteRemoteBranch = () => {},
onDeleteBranchFolder = () => {},
onCreateTag = () => {},
onDeleteTag = () => {},
onPushTag = () => {},
onManageWorktrees = () => {},
onCreateWorktree = () => {},
collapsed = false,
onToggleCollapsed = () => {},
@@ -96,23 +86,14 @@
let localOpen = $state(true);
let remoteOpen = $state(false);
let tagsOpen = $state(false);
let createOpen = $state(false);
let newBranchName = $state("");
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 contextFolder = $state<BranchFolderRow | null>(null);
let branchContextMenuElement = $state<HTMLElement | null>(null);
let contextMenuX = $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 localBranchRows = $derived(buildBranchRows("local", localBranches, "local"));
@@ -227,6 +208,7 @@
function openCreateForm() {
if (!hasRepository || isBusy) return;
if (collapsed) onToggleCollapsed();
createOpen = true;
queueMicrotask(() => createInput?.focus());
}
@@ -357,66 +339,8 @@
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() {
closeBranchContextMenu();
closeTagContextMenu();
}
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">
<div class="section-head">
<div>
<span class="eyebrow">Branches</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Refs</h2>
</div>
<h2 class="sidebar-section-title"><GitBranch size={16} aria-hidden="true" />Branches</h2>
<div class="branch-head-actions">
<button
class="branch-create-toggle"
@@ -543,7 +464,6 @@
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div>
{#if row.branch.current}
@@ -611,7 +531,6 @@
<GitBranch size={16} aria-hidden="true" />
<div>
<strong>{row.displayName}</strong>
<span>{row.scopeLabel}</span>
</div>
</div>
{#if row.branch.current}
@@ -624,100 +543,9 @@
{/if}
</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>
{/if}
@@ -792,24 +620,4 @@
</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>
+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">
<div class="section-head">
<div>
<span class="eyebrow">Explorer</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Files</h2>
</div>
<h2 class="sidebar-section-title"><Folder size={16} aria-hidden="true" />Files</h2>
<div class="explorer-head-actions">
<button
class="explorer-bulk-button explorer-tool-action"
+12 -5
View File
@@ -1,5 +1,5 @@
<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";
interface Props {
@@ -28,6 +28,7 @@
onToggleCollapsed = () => {},
}: Props = $props();
let createOpen = $state(false);
let message = $state("");
let includeUntracked = $state(true);
@@ -43,11 +44,13 @@
<section class="panel stash-panel overflow-hidden" class:collapsed aria-label="Git stash">
<div class="section-head">
<div>
<span class="eyebrow">Stash</span>
<h2 class="mt-0.5 text-ink text-base font-bold leading-tight">Shelved changes</h2>
</div>
<h2 class="sidebar-section-title"><Archive size={16} aria-hidden="true" />Stashes</h2>
<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>
<button
class="stash-toggle panel-collapse-toggle"
@@ -71,12 +74,14 @@
{:else if !hasRepository}
<div class="blank-state">No repository loaded.</div>
{:else}
{#if createOpen}
<div class="stash-create">
<input
class="stash-input"
type="text"
bind:value={message}
placeholder="Optional message"
aria-label="Stash message"
disabled={isBusy || changedCount === 0}
onkeydown={(event) => {
if (event.key === "Enter" && changedCount > 0 && !isBusy) submitPush();
@@ -98,6 +103,8 @@
</button>
</div>
{/if}
{#if stashes.length === 0}
<div class="blank-state stash-empty">No stashes saved.</div>
{: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>