mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
370b67d7ec
* 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
70 lines
2.0 KiB
TypeScript
70 lines
2.0 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
|
|
const STORAGE_KEY = 'sencho:sidebar:pinned';
|
|
const MAX_PINS = 10;
|
|
|
|
type PinnedMap = Record<string, string[]>;
|
|
|
|
function readMap(): PinnedMap {
|
|
try {
|
|
const raw = window.localStorage.getItem(STORAGE_KEY);
|
|
if (!raw) return {};
|
|
const parsed = JSON.parse(raw);
|
|
return parsed && typeof parsed === 'object' ? parsed as PinnedMap : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function writeMap(map: PinnedMap) {
|
|
try {
|
|
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
|
} catch {
|
|
// Quota exhaustion is non-fatal for this feature.
|
|
}
|
|
}
|
|
|
|
export interface UsePinnedStacksResult {
|
|
pinned: string[];
|
|
pin: (file: string) => void;
|
|
unpin: (file: string) => void;
|
|
isPinned: (file: string) => boolean;
|
|
evictedOldest: { file: string; seq: number } | null;
|
|
}
|
|
|
|
export function usePinnedStacks(nodeId: number | undefined): UsePinnedStacksResult {
|
|
const key = nodeId !== undefined ? String(nodeId) : '__none__';
|
|
const [map, setMap] = useState<PinnedMap>(() => readMap());
|
|
const [evictedOldest, setEvictedOldest] = useState<{ file: string; seq: number } | null>(null);
|
|
|
|
useEffect(() => { writeMap(map); }, [map]);
|
|
|
|
const pinned = map[key] ?? [];
|
|
|
|
const pin = useCallback((file: string) => {
|
|
setMap(prev => {
|
|
const current = prev[key] ?? [];
|
|
if (current.includes(file)) return prev;
|
|
const next = [...current, file];
|
|
if (next.length > MAX_PINS) {
|
|
const removed = next.shift()!;
|
|
setEvictedOldest(prev => ({ file: removed, seq: (prev?.seq ?? 0) + 1 }));
|
|
}
|
|
return { ...prev, [key]: next };
|
|
});
|
|
}, [key]);
|
|
|
|
const unpin = useCallback((file: string) => {
|
|
setMap(prev => {
|
|
const current = prev[key] ?? [];
|
|
const next = current.filter(f => f !== file);
|
|
if (next.length === current.length) return prev;
|
|
return { ...prev, [key]: next };
|
|
});
|
|
}, [key]);
|
|
|
|
const isPinned = useCallback((file: string) => pinned.includes(file), [pinned]);
|
|
|
|
return { pinned, pin, unpin, isPinned, evictedOldest };
|
|
}
|