mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-08 01:43:55 +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
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { renderHook, act } from '@testing-library/react';
|
|
import { usePinnedStacks } from '../usePinnedStacks';
|
|
|
|
const KEY = 'sencho:sidebar:pinned';
|
|
|
|
describe('usePinnedStacks', () => {
|
|
beforeEach(() => {
|
|
window.localStorage.clear();
|
|
});
|
|
|
|
it('returns empty pinned list when no storage', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
expect(result.current.pinned).toEqual([]);
|
|
});
|
|
|
|
it('pin adds stack and persists to localStorage', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
act(() => result.current.pin('web.yml'));
|
|
expect(result.current.pinned).toEqual(['web.yml']);
|
|
expect(JSON.parse(window.localStorage.getItem(KEY)!)).toEqual({ '1': ['web.yml'] });
|
|
});
|
|
|
|
it('unpin removes stack', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
act(() => { result.current.pin('web.yml'); result.current.pin('db.yml'); });
|
|
act(() => result.current.unpin('web.yml'));
|
|
expect(result.current.pinned).toEqual(['db.yml']);
|
|
});
|
|
|
|
it('isPinned reports membership', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
act(() => result.current.pin('web.yml'));
|
|
expect(result.current.isPinned('web.yml')).toBe(true);
|
|
expect(result.current.isPinned('db.yml')).toBe(false);
|
|
});
|
|
|
|
it('evicts oldest when exceeding max of 10', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
act(() => {
|
|
for (let i = 0; i < 10; i++) result.current.pin(`s${i}.yml`);
|
|
});
|
|
expect(result.current.pinned).toHaveLength(10);
|
|
expect(result.current.evictedOldest).toBeNull();
|
|
act(() => result.current.pin('s10.yml'));
|
|
expect(result.current.pinned).toHaveLength(10);
|
|
expect(result.current.pinned[0]).toBe('s1.yml');
|
|
expect(result.current.pinned[9]).toBe('s10.yml');
|
|
expect(result.current.evictedOldest).toEqual({ file: 's0.yml', seq: 1 });
|
|
});
|
|
|
|
it('evictedOldest seq increments on each eviction', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
act(() => {
|
|
for (let i = 0; i < 10; i++) result.current.pin(`s${i}.yml`);
|
|
});
|
|
act(() => result.current.pin('s10.yml'));
|
|
expect(result.current.evictedOldest).toEqual({ file: 's0.yml', seq: 1 });
|
|
act(() => result.current.pin('s11.yml'));
|
|
expect(result.current.evictedOldest).toEqual({ file: 's1.yml', seq: 2 });
|
|
});
|
|
|
|
it('isolates state per node', () => {
|
|
const hookA = renderHook(() => usePinnedStacks(1));
|
|
act(() => hookA.result.current.pin('web.yml'));
|
|
const hookB = renderHook(() => usePinnedStacks(2));
|
|
expect(hookB.result.current.pinned).toEqual([]);
|
|
});
|
|
|
|
it('pin is a no-op when already pinned', () => {
|
|
const { result } = renderHook(() => usePinnedStacks(1));
|
|
act(() => { result.current.pin('web.yml'); result.current.pin('web.yml'); });
|
|
expect(result.current.pinned).toEqual(['web.yml']);
|
|
});
|
|
});
|