feat(sidebar): add section menu to toggle left-sidebar panels

Add a floating SidebarSectionMenu component and wiring in App.svelte to let users show/hide individual left-sidebar panels (worktrees, tags, stashes, files). Visibility is tracked in new sidebarVisibility state and persisted to localStorage under SIDEBAR_VISIBILITY_KEY. The menu opens via contextmenu on the left sidebar and returns focus to the invoking element when closed.

- Panels and resize handles now respect visibility (buildLeftSidebarRows, sidebarHandleVisible, expandedSidebarPanels).
- Branch panel remains always visible and cannot be toggled; stored preferences only apply to the other panels.
- Safe fallbacks: localStorage errors are ignored so the feature still works without persistence; menu includes keyboard navigation and appropriate ARIA roles.
This commit is contained in:
2026-09-18 20:05:19 +02:00
parent 985e812209
commit e6797bdb81
2 changed files with 150 additions and 9 deletions
+71 -9
View File
@@ -18,6 +18,7 @@
import IssueCenter from "./lib/components/IssueCenter.svelte";
import { readWorkspaces, WORKSPACES_KEY, type Workspace, type WorkspaceState } from "./lib/workspaces";
import RepositoryDashboard from "./lib/components/RepositoryDashboard.svelte";
import SidebarSectionMenu from "./lib/components/SidebarSectionMenu.svelte";
import ReviewCenter from "./lib/components/ReviewCenter.svelte";
import RepoTabs from "./lib/RepoTabs.svelte";
import AiReviewDialog from "./lib/components/AiReviewDialog.svelte";
@@ -296,6 +297,7 @@
const AUTO_REFRESH_ENABLED_KEY = "gitlite.autoRefreshEnabled.v1";
const COMMIT_PANEL_HEIGHT_KEY = "gitlite.commitPanelHeight.v1";
const LEFT_SIDEBAR_WIDTH_KEY = "gitlite.leftSidebarWidth.v1";
const SIDEBAR_VISIBILITY_KEY = "gitlite.sidebarVisibility.v1";
const SIDEBAR_PANEL_HEIGHTS_KEY = "gitlite.sidebarPanelHeights.v2";
const BRANCH_PANEL_COLLAPSED_KEY = "gitlite.branchPanelCollapsed.v1";
const STASH_PANEL_COLLAPSED_KEY = "gitlite.stashPanelCollapsed.v2";
@@ -564,6 +566,9 @@
let resizingLeftSidebar = false;
let leftSidebarResizeStartX = 0;
let leftSidebarResizeStartWidth = 0;
let sidebarVisibility = loadSidebarVisibility();
let sidebarSectionMenu: { x: number; y: number } | null = null;
let sidebarMenuReturnFocus: HTMLElement | null = null;
let sidebarPanelHeights: Record<SidebarPanelId, number> = loadSidebarPanelHeights();
let resizingSidebarPanel: SidebarPanelId | null = null;
let sidebarResizeStartY = 0;
@@ -585,6 +590,7 @@
$: isBusy = operation.length > 0;
$: hasRepository = activeRepoPath.length > 0 && status !== null;
$: if (activeView !== "repository") sidebarSectionMenu = null;
$: workspaceActive = activeView === "repository" && hasRepository;
$: openingRepo = operation === "Opening repository";
$: cloningRepo = operation === "Cloning repository";
@@ -625,8 +631,8 @@
) as Record<string, string>;
$: remoteBranches = branches.filter((b) => b.remote);
$: currentBranchIsLocalOnly = Boolean(status?.current_branch) && !status?.upstream;
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights);
$: allLeftPanelsCollapsed = branchPanelCollapsed && worktreePanelCollapsed && tagsPanelCollapsed && stashPanelCollapsed && explorerPanelCollapsed;
$: leftSidebarRows = buildLeftSidebarRows(branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarPanelHeights, sidebarVisibility);
$: allLeftPanelsCollapsed = branchPanelCollapsed && (!sidebarVisibility.worktree || worktreePanelCollapsed) && (!sidebarVisibility.tags || tagsPanelCollapsed) && (!sidebarVisibility.stash || stashPanelCollapsed) && (!sidebarVisibility.explorer || explorerPanelCollapsed);
$: editorToolName = externalToolDisplayName("editor", externalToolsSettings.editor, detectedExternalTools);
$: diffToolName = externalToolDisplayName("diff", externalToolsSettings.diff, detectedExternalTools);
$: mergeToolName = externalToolDisplayName("merge", externalToolsSettings.merge, detectedExternalTools);
@@ -2062,6 +2068,38 @@
// ── Sidebar panel sizing ───────────────────────────────────────────────────
function loadSidebarVisibility(): Record<SidebarPanelId, boolean> {
const visible = { branch: true, worktree: true, tags: true, stash: true, explorer: true };
try {
const stored = JSON.parse(localStorage.getItem(SIDEBAR_VISIBILITY_KEY) ?? "{}");
for (const panel of SIDEBAR_PANEL_ORDER) {
if (panel !== "branch" && typeof stored?.[panel] === "boolean") visible[panel] = stored[panel];
}
} catch { /* Keep all sections visible if stored preferences are unavailable. */ }
return visible;
}
function toggleSidebarVisibility(id: string) {
if (id === "branch" || !SIDEBAR_PANEL_ORDER.includes(id as SidebarPanelId)) return;
const panel = id as SidebarPanelId;
sidebarVisibility = { ...sidebarVisibility, [panel]: !sidebarVisibility[panel] };
try { localStorage.setItem(SIDEBAR_VISIBILITY_KEY, JSON.stringify(sidebarVisibility)); }
catch { /* Visibility changes still work without persistent storage. */ }
}
function closeSidebarSectionMenu() {
sidebarSectionMenu = null;
if (sidebarMenuReturnFocus?.isConnected) sidebarMenuReturnFocus.focus({ preventScroll: true });
}
function openSidebarSectionMenu(event: MouseEvent) {
event.preventDefault();
event.stopPropagation();
closeRepoTabContextMenu();
sidebarMenuReturnFocus = event.currentTarget as HTMLElement;
sidebarSectionMenu = { x: event.clientX, y: event.clientY };
}
function clampSidebarPanelHeight(panel: SidebarPanelId, value: number): number {
return Math.min(SIDEBAR_PANEL_MAX_HEIGHT, Math.max(SIDEBAR_PANEL_MIN_HEIGHT[panel], Math.round(value)));
}
@@ -2100,7 +2138,7 @@
/** Expanded panels, top to bottom. The last one always fills the leftover space. */
function expandedSidebarPanels(): SidebarPanelId[] {
return SIDEBAR_PANEL_ORDER.filter((panel) => !sidebarPanelIsCollapsed(panel));
return SIDEBAR_PANEL_ORDER.filter((panel) => sidebarVisibility[panel] && !sidebarPanelIsCollapsed(panel));
}
/** The first expanded panel below `panel` — the one that gives way while dragging. */
@@ -2111,9 +2149,9 @@
}
/** A handle only makes sense between two expanded panels. */
function sidebarHandleVisible(panel: SidebarPanelId, ...markers: boolean[]): boolean {
function sidebarHandleVisible(panel: SidebarPanelId, ...markers: unknown[]): boolean {
void markers;
return !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null;
return sidebarVisibility[panel] && !sidebarPanelIsCollapsed(panel) && nextExpandedSidebarPanel(panel) !== null;
}
function buildLeftSidebarRows(
@@ -2123,6 +2161,7 @@
stashCollapsed: boolean,
explorerCollapsed: boolean,
heights: Record<SidebarPanelId, number>,
visibility: Record<SidebarPanelId, boolean>,
): string {
void branchCollapsed; void worktreeCollapsed; void tagsCollapsed; void stashCollapsed; void explorerCollapsed;
@@ -2131,6 +2170,7 @@
const rows: string[] = [];
for (const panel of SIDEBAR_PANEL_ORDER) {
if (!visibility[panel]) continue;
if (sidebarPanelIsCollapsed(panel)) rows.push("auto");
else if (panel === flexible) rows.push(`minmax(${SIDEBAR_PANEL_MIN_HEIGHT[panel]}px, 1fr)`);
else rows.push(`${heights[panel]}px`);
@@ -5902,6 +5942,17 @@
<svelte:window on:click={handleWindowClick} on:keydown={handleWindowKeydown} on:contextmenu|capture={handleWindowContextMenu} />
{#if sidebarSectionMenu && activeView === "repository"}
<SidebarSectionMenu x={sidebarSectionMenu.x} y={sidebarSectionMenu.y} language={appLanguage}
sections={[
{ id: "worktree", label: "Worktrees", visible: sidebarVisibility.worktree },
{ id: "tags", label: "Tags", visible: sidebarVisibility.tags },
{ id: "stash", label: "Stashes", visible: sidebarVisibility.stash },
{ id: "explorer", label: appLanguage === "de" ? "Dateien" : "Files", visible: sidebarVisibility.explorer },
]}
onToggle={toggleSidebarVisibility} onClose={closeSidebarSectionMenu} />
{/if}
<main class="shell">
<TitleBar
onOpenSettings={openAppSettings}
@@ -6140,8 +6191,11 @@
>
<!-- Compact repository navigation: branches, worktrees, tags, stashes, files -->
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<aside
class="left-sidebar"
tabindex="0"
oncontextmenu={openSidebarSectionMenu}
class:branch-collapsed={branchPanelCollapsed}
class:stash-collapsed={stashPanelCollapsed}
class:explorer-collapsed={explorerPanelCollapsed}
@@ -6169,7 +6223,7 @@
collapsed={branchPanelCollapsed}
onToggleCollapsed={toggleBranchPanelCollapsed}
/>
{#if sidebarHandleVisible("branch", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)}
{#if sidebarHandleVisible("branch", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
@@ -6193,6 +6247,7 @@
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
{#if sidebarVisibility.worktree}
<WorktreePanel
{worktrees}
{hasRepository}
@@ -6208,7 +6263,7 @@
onManage={() => { void openWorktreeDialog(); }}
onRefresh={() => { void refreshWorktrees(); }}
/>
{#if sidebarHandleVisible("worktree", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)}
{#if sidebarHandleVisible("worktree", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
@@ -6232,6 +6287,8 @@
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
{/if}
{#if sidebarVisibility.tags}
<TagsPanel
{tags} {hasRepository} {isBusy}
collapsed={tagsPanelCollapsed}
@@ -6243,7 +6300,7 @@
onDeleteTag={deleteLocalTag}
onPushTag={pushLocalTag}
/>
{#if sidebarHandleVisible("tags", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)}
{#if sidebarHandleVisible("tags", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
@@ -6267,6 +6324,8 @@
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
{/if}
{#if sidebarVisibility.stash}
<StashPanel
{stashes}
changedCount={changedFiles.length}
@@ -6279,7 +6338,7 @@
collapsed={stashPanelCollapsed}
onToggleCollapsed={toggleStashPanelCollapsed}
/>
{#if sidebarHandleVisible("stash", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed)}
{#if sidebarHandleVisible("stash", branchPanelCollapsed, worktreePanelCollapsed, tagsPanelCollapsed, stashPanelCollapsed, explorerPanelCollapsed, sidebarVisibility)}
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
@@ -6303,6 +6362,8 @@
{:else}
<span class="left-panel-resize-placeholder" aria-hidden="true"></span>
{/if}
{/if}
{#if sidebarVisibility.explorer}
<ExplorerPanel
{repoFiles}
{expandedExplorerPaths}
@@ -6327,6 +6388,7 @@
collapsed={explorerPanelCollapsed}
onToggleCollapsed={toggleExplorerPanelCollapsed}
/>
{/if}
</aside>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
@@ -0,0 +1,79 @@
<script lang="ts">
import { onMount } from "svelte";
import { Check, PanelLeft, GitFork, Tags, Archive, FolderTree } from "@lucide/svelte";
let { x, y, language, sections, onToggle, onClose }: {
x: number; y: number; language: "de" | "en";
sections: { id: string; label: string; visible: boolean }[];
onToggle: (id: string) => void;
onClose: () => void;
} = $props();
let menu: HTMLDivElement;
let viewport = $state({ width: window.innerWidth, height: window.innerHeight });
let menuWidth = $state(284);
let menuHeight = $state(272);
const details: Record<string, { icon: typeof Tags; de: string; en: string }> = {
worktree: { icon: GitFork, de: "Parallele Arbeitsverzeichnisse", en: "Parallel working directories" },
tags: { icon: Tags, de: "Markierte Versionen", en: "Tagged versions" },
stash: { icon: Archive, de: "Zwischengespeicherte Änderungen", en: "Saved changes" },
explorer: { icon: FolderTree, de: "Dateien im Repository", en: "Repository files" },
};
const visibleCount = $derived(sections.filter(section => section.visible).length);
const left = $derived(Math.max(8, Math.min(x, viewport.width - menuWidth - 8)));
const top = $derived(Math.max(8, Math.min(y, viewport.height - menuHeight - 8)));
onMount(() => { menu.focus(); });
function keyboard(event: KeyboardEvent) {
const buttons = [...menu.querySelectorAll<HTMLButtonElement>("button")];
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
event.preventDefault();
const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : (index < 0 ? (event.key === "ArrowDown" ? 0 : buttons.length - 1) : (index + (event.key === "ArrowDown" ? 1 : -1) + buttons.length) % buttons.length);
buttons[next]?.focus();
} else if (event.key === "Escape" || event.key === "Tab") {
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); }
onClose();
}
}
</script>
<svelte:window
onpointerdown={(event) => { if (!menu.contains(event.target as Node)) onClose(); }}
onresize={() => { viewport = { width: window.innerWidth, height: window.innerHeight }; }}
/>
<div bind:this={menu} bind:clientWidth={menuWidth} bind:clientHeight={menuHeight} class="sidebar-section-menu" style:left="{left}px" style:top="{top}px" role="menu" tabindex="-1"
aria-label={language === "de" ? "Sidebar-Bereiche" : "Sidebar sections"} onkeydown={keyboard}
oncontextmenu={(event) => { event.preventDefault(); event.stopPropagation(); }}>
<div class="menu-heading">
<span class="heading-icon" aria-hidden="true"><PanelLeft size={18} strokeWidth={1.7} /></span>
<div class="heading-copy"><span class="eyebrow">Sidebar</span><strong>{language === "de" ? "Bereiche anzeigen" : "Show sections"}</strong></div>
<span class="section-count" aria-hidden="true">{visibleCount}<span>/{sections.length}</span></span>
</div>
<div class="menu-items" role="group">
{#each sections as section (section.id)}
{@const detail = details[section.id]}
<button type="button" role="menuitemcheckbox" aria-label={section.label} aria-checked={section.visible} onclick={() => onToggle(section.id)}>
<span class="section-icon" aria-hidden="true">{#if detail}<detail.icon size={17} strokeWidth={1.65} />{/if}</span>
<span class="section-copy"><strong>{section.label}</strong>{#if detail}<small>{language === "de" ? detail.de : detail.en}</small>{/if}</span>
<span class="check" class:checked={section.visible} aria-hidden="true">{#if section.visible}<Check size={12} strokeWidth={2.3} />{/if}</span>
</button>
{/each}
</div>
</div>
<style>
.sidebar-section-menu{position:fixed;z-index:10000;box-sizing:border-box;width:284px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow-y:auto;padding:6px;border:1px solid color-mix(in srgb,var(--color-accent) 18%,var(--color-border));border-radius:10px;background:var(--app-dialog-bg);box-shadow:0 12px 36px #0004,0 2px 8px #0002;color:var(--color-ink);font:12px/1.4 var(--font-sans);outline:none}
.menu-heading{display:flex;align-items:center;gap:10px;padding:10px 9px 13px;margin-bottom:5px;border-bottom:1px solid var(--color-border-subtle)}
.heading-icon{display:grid;place-items:center;flex:0 0 34px;height:34px;border:1px solid color-mix(in srgb,var(--color-accent) 24%,transparent);border-radius:7px;background:color-mix(in srgb,var(--color-accent) 9%,transparent);color:var(--color-accent)}
.heading-copy{display:grid;gap:2px;flex:1;min-width:0}.eyebrow{font-size:9px;font-weight:650;letter-spacing:.09em;text-transform:uppercase;color:var(--color-ink-muted)}.heading-copy strong{font-size:12px;font-weight:650}
.section-count{padding:3px 6px;border:1px solid var(--color-border-subtle);border-radius:5px;background:var(--color-surface);color:var(--color-ink-muted);font-size:10px;font-variant-numeric:tabular-nums}.section-count span{color:var(--color-ink-faint);margin-left:2px}
.menu-items{display:grid;gap:2px}
.sidebar-section-menu button{display:flex;align-items:center;justify-content:flex-start;gap:11px;width:100%;min-height:48px;padding:8px 10px;border:1px solid transparent;border-radius:6px;background:transparent;color:var(--color-ink);font:inherit;text-align:left;cursor:pointer;box-shadow:none;transition:background .12s,border-color .12s}
.sidebar-section-menu button:hover{background:var(--color-surface-hover)}
.sidebar-section-menu button:focus-visible{outline:none;border-color:color-mix(in srgb,var(--color-accent) 55%,transparent);background:color-mix(in srgb,var(--color-accent) 8%,var(--app-dialog-bg))}
.section-icon{display:grid;place-items:center;width:20px;flex-shrink:0;color:var(--color-ink-muted)}
.section-copy{display:grid;gap:2px;min-width:0;flex:1}.section-copy strong{font-size:12px;font-weight:600;line-height:1.3}.section-copy small{font-size:10px;font-weight:400;line-height:1.4;color:var(--color-ink-muted)}
.check{display:grid;place-items:center;width:16px;height:16px;flex-shrink:0;border:1px solid var(--color-border-input);border-radius:4px;background:var(--app-input-bg);color:var(--color-accent)}
.check.checked{border-color:color-mix(in srgb,var(--color-accent) 45%,transparent);background:color-mix(in srgb,var(--color-accent) 12%,transparent)}
@media(prefers-reduced-motion:reduce){.sidebar-section-menu button{transition:none}}
</style>