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:
+71
-9
@@ -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>
|
||||
Reference in New Issue
Block a user