mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
feat(sidebar): cockpit redesign with grouped stacks and activity footer (#702)
* chore: ignore .superpowers/ brainstorm scratch dir
* feat(sidebar): add useStackMenuItems hook with grouped menu model
Pure transform hook that converts StackMenuCtx into four ordered MenuGroup
arrays (inspect, organize, lifecycle, destructive). Shared type contract in
sidebar-types.ts gives both the ContextMenu and DropdownMenu a single source
of truth so they cannot drift. Covered by 8 unit tests.
* refactor(sidebar): stabilize useStackMenuItems memoization deps
Destructure menuVisibility flags into primitive deps so inline object
literals from callers do not defeat memoization. Add a test confirming
isBusy disables all lifecycle items.
* feat(sidebar): add usePinnedStacks hook with per-node localStorage
* refactor(sidebar): stabilize usePinnedStacks eviction signal and isPinned dep
Change evictedOldest shape to { file, seq } so consumer effects re-fire on
repeated evictions. Narrow isPinned's useCallback dep to the current node's
pinned list so it only rebinds on local changes. Add test for eviction
side-effect and a second test for the seq counter.
* feat(sidebar): add useSidebarGroupCollapse hook with per-node keys
* refactor(sidebar): tighten useSidebarGroupCollapse effect ordering
Collapse the two write/read effects into a single skip-next-write ref
pattern so switching nodes no longer writes the previous node's map under
the new key before hydration. Also skip the no-op mount write. Add test
for setCollapsed.
* feat(sidebar): add row + group-header style helpers
* feat(sidebar): add SidebarBrand with mono kicker + serif hero
* feat(sidebar): add SidebarActions wrapper for create + scan
* feat(sidebar): extract SidebarSearch with kbd pill
* feat(sidebar): add StackRow with cyan-rail active state
* refactor(sidebar): dedupe tooltip markup in StackRow, widen test coverage
Extract a local RowTooltip helper so the update and git-pending branches
share the CursorProvider scaffolding. Add four behavioral tests covering
click, keyboard activation, kebab stop-propagation, and the busy loader
branch.
* feat(sidebar): unify context + kebab menus via useStackMenuItems
* feat(sidebar): add StackGroup with collapse and pinned variant
* feat(sidebar): add StackList with pinned + label groups
* feat(sidebar): add SidebarActivityTicker with idle fallback
* feat(sidebar): add StackSidebar container composing the regions
* feat(sidebar): replace sidebar block with StackSidebar composition
* docs(sidebar): add stack sidebar feature page with screenshots
* fix(sidebar): satisfy react-hooks purity and memoization rules
* fix(sidebar): restore "Sencho Logo" alt text for E2E selector
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
type CollapseMap = Record<string, boolean>;
|
||||
|
||||
interface CollapseState {
|
||||
key: string;
|
||||
map: CollapseMap;
|
||||
}
|
||||
|
||||
function storageKey(nodeId: number | undefined): string {
|
||||
return `sencho:sidebar:groups:${nodeId ?? '__none__'}`;
|
||||
}
|
||||
|
||||
function readMap(key: string): CollapseMap {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw);
|
||||
return parsed && typeof parsed === 'object' ? parsed as CollapseMap : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export interface UseSidebarGroupCollapseResult {
|
||||
isCollapsed: (groupKey: string) => boolean;
|
||||
toggle: (groupKey: string) => void;
|
||||
setCollapsed: (groupKey: string, collapsed: boolean) => void;
|
||||
}
|
||||
|
||||
export function useSidebarGroupCollapse(nodeId: number | undefined): UseSidebarGroupCollapseResult {
|
||||
const key = storageKey(nodeId);
|
||||
const [state, setState] = useState<CollapseState>(() => ({ key, map: readMap(key) }));
|
||||
|
||||
if (state.key !== key) {
|
||||
// Node changed: re-hydrate during render (derived state pattern).
|
||||
setState({ key, map: readMap(key) });
|
||||
}
|
||||
|
||||
const map = state.map;
|
||||
const lastWrittenKey = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastWrittenKey.current !== key) {
|
||||
// First sighting of this key (mount or node change): state was hydrated from storage; no write needed.
|
||||
lastWrittenKey.current = key;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(map));
|
||||
} catch {
|
||||
// Ignore quota errors.
|
||||
}
|
||||
}, [key, map]);
|
||||
|
||||
const isCollapsed = useCallback((groupKey: string) => map[groupKey] === true, [map]);
|
||||
|
||||
const toggle = useCallback((groupKey: string) => {
|
||||
setState(prev => ({ key: prev.key, map: { ...prev.map, [groupKey]: !prev.map[groupKey] } }));
|
||||
}, []);
|
||||
|
||||
const setCollapsed = useCallback((groupKey: string, collapsed: boolean) => {
|
||||
setState(prev => ({ key: prev.key, map: { ...prev.map, [groupKey]: collapsed } }));
|
||||
}, []);
|
||||
|
||||
return { isCollapsed, toggle, setCollapsed };
|
||||
}
|
||||
Reference in New Issue
Block a user