From db4e58e039e00712194de039c378f53bb8cb3796 Mon Sep 17 00:00:00 2001 From: Christoph Brandau Date: Wed, 16 Sep 2026 22:58:48 +0200 Subject: [PATCH] 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. --- src/App.svelte | 221 ++++++++++++++++++++---- src/app.css | 80 +++++++++ src/lib/components/BranchPanel.svelte | 200 +-------------------- src/lib/components/ExplorerPanel.svelte | 5 +- src/lib/components/StashPanel.svelte | 17 +- src/lib/components/TagsPanel.svelte | 183 ++++++++++++++++++++ src/lib/components/WorktreePanel.svelte | 70 ++++++++ 7 files changed, 540 insertions(+), 236 deletions(-) create mode 100644 src/lib/components/TagsPanel.svelte create mode 100644 src/lib/components/WorktreePanel.svelte diff --git a/src/App.svelte b/src/App.svelte index 500dce2..588058e 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -24,6 +24,8 @@ import AppSettingsDialog from "./lib/components/AppSettingsDialog.svelte"; import BranchDeleteConfirmDialog from "./lib/components/BranchDeleteConfirmDialog.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 CommandPalette from "./lib/components/CommandPalette.svelte"; import CommitNoteDialog from "./lib/components/CommitNoteDialog.svelte"; @@ -293,6 +295,8 @@ const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1"; const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.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 BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1"; const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2"; @@ -445,6 +449,8 @@ let worktreeDialogOpen = false; let worktreeInitialBranch = ""; let worktrees: GitWorktree[] = []; + let worktreeLoadId = 0; + let worktreePanelCollapsed = loadStoredBoolean("gitlite.worktreePanelCollapsed.v1", false); let worktreesLoading = false; let worktreeError = ""; let gitLfsDialogOpen = false; @@ -544,12 +550,21 @@ let resizingLeftBranchPanel = false; let leftBranchResizeStartY = 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 resizingLeftStashPanel = false; let leftStashResizeStartY = 0; let leftStashResizeStartHeight = 0; + let tagsPanelCollapsed = loadStoredBoolean("gitlite.tagsPanelCollapsed.v1", true); 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 historyAsideWidth = loadHistoryAsideWidth(); let resizingHistoryAside = false; @@ -602,8 +617,8 @@ ) as Record; $: remoteBranches = branches.filter((b) => b.remote); $: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream; - $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed); - $: allLeftPanelsCollapsed = branchPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; + $: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed); + $: allLeftPanelsCollapsed = branchPanelCollapsed && worktreePanelCollapsed && tagsPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed; $: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools); $: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools); $: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools); @@ -2001,18 +2016,108 @@ 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 ? "auto" : `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 ? "auto" : `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)`; - 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) { @@ -2248,6 +2353,8 @@ worktreeDialogOpen = false; worktreeInitialBranch = ""; worktrees = []; + worktreeLoadId++; + worktreesLoading = false; submoduleDialogOpen = false; submodules = []; uninitializedSubmoduleCount = 0; @@ -2454,6 +2561,7 @@ refreshStashes(resolvedPath, bundle.stashes), refreshCommitHistory(resolvedPath, bundle.commits), refreshExplorerFiles(resolvedPath, bundle.files), + refreshWorktrees(resolvedPath), ]); if (forceFileHistory || lastFileHistoryHeadHash !== previousHeadHash) { @@ -3373,16 +3481,8 @@ if (!activeRepoPath || isBusy) return; worktreeInitialBranch = branch; worktreeDialogOpen = true; - worktreeError = ""; - worktreesLoading = true; - try { - worktrees = await listWorktrees(activeRepoPath); - trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) }); - } catch (error) { - worktreeError = errorToMessage(error); - } finally { - worktreesLoading = false; - } + await refreshWorktrees(); + trackEvent("worktree_dialog_opened", { linked_worktrees: Math.max(0, worktrees.length - 1) }); } function openBranchInWorktree(branch: GitBranchInfo) { @@ -3390,16 +3490,18 @@ void openWorktreeDialog(branch.name); } - async function refreshWorktrees() { - if (!activeRepoPath || worktreesLoading) return; + async function refreshWorktrees(path = activeRepoPath) { + if (!path) return; + const request = ++worktreeLoadId; worktreesLoading = true; worktreeError = ""; try { - worktrees = await listWorktrees(activeRepoPath); + const loaded = await listWorktrees(path); + if (request === worktreeLoadId && sameRepoPath(path, activeRepoPath)) worktrees = loaded; } catch (error) { - worktreeError = errorToMessage(error); + if (request === worktreeLoadId && sameRepoPath(path, activeRepoPath)) worktreeError = errorToMessage(error); } 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;" > - +