mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 17:36:42 +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,98 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { BellRing, Trash2 } from 'lucide-react';
|
||||
import { useStackMenuItems } from '../useStackMenuItems';
|
||||
import type { StackMenuCtx } from '@/components/sidebar/sidebar-types';
|
||||
|
||||
function makeCtx(overrides: Partial<StackMenuCtx> = {}): StackMenuCtx {
|
||||
return {
|
||||
stackStatus: 'running',
|
||||
hasPort: true,
|
||||
isBusy: false,
|
||||
isPaid: true,
|
||||
canDelete: true,
|
||||
isPinned: false,
|
||||
labels: [],
|
||||
assignedLabelIds: [],
|
||||
menuVisibility: { showDeploy: false, showStop: true, showRestart: true, showUpdate: false },
|
||||
openAlertSheet: vi.fn(),
|
||||
openAutoHeal: vi.fn(),
|
||||
checkUpdates: vi.fn(),
|
||||
openStackApp: vi.fn(),
|
||||
deploy: vi.fn(),
|
||||
stop: vi.fn(),
|
||||
restart: vi.fn(),
|
||||
update: vi.fn(),
|
||||
remove: vi.fn(),
|
||||
pin: vi.fn(),
|
||||
unpin: vi.fn(),
|
||||
toggleLabel: vi.fn(),
|
||||
openLabelManager: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('useStackMenuItems', () => {
|
||||
it('returns Inspect / Organize / Lifecycle / Destructive groups in order', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
|
||||
expect(result.current.map(g => g.id)).toEqual(['inspect', 'organize', 'lifecycle', 'destructive']);
|
||||
});
|
||||
|
||||
it('always includes Alerts in Inspect', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.some(i => i.icon === BellRing)).toBe(true);
|
||||
});
|
||||
|
||||
it('hides Auto-Heal when !isPaid', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPaid: false })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'auto-heal')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('hides Open App unless running + hasPort', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ stackStatus: 'exited' })));
|
||||
const inspect = result.current.find(g => g.id === 'inspect')!;
|
||||
expect(inspect.items.find(i => i.id === 'open-app')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('toggles Pin / Unpin label based on isPinned', () => {
|
||||
const pinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: true })));
|
||||
const unpinned = renderHook(() => useStackMenuItems('web.yml', makeCtx({ isPinned: false })));
|
||||
const pinnedOrganize = pinned.result.current.find(g => g.id === 'organize')!;
|
||||
const unpinnedOrganize = unpinned.result.current.find(g => g.id === 'organize')!;
|
||||
expect(pinnedOrganize.items.find(i => i.id === 'pin')!.label).toBe('Unpin');
|
||||
expect(unpinnedOrganize.items.find(i => i.id === 'pin')!.label).toBe('Pin to top');
|
||||
});
|
||||
|
||||
it('omits Destructive group entirely when !canDelete', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({ canDelete: false })));
|
||||
expect(result.current.find(g => g.id === 'destructive')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('marks Delete item destructive with Trash2 icon', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx()));
|
||||
const destructive = result.current.find(g => g.id === 'destructive')!;
|
||||
const del = destructive.items.find(i => i.id === 'delete')!;
|
||||
expect(del.destructive).toBe(true);
|
||||
expect(del.icon).toBe(Trash2);
|
||||
});
|
||||
|
||||
it('lifecycle items follow menuVisibility flags', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||
menuVisibility: { showDeploy: true, showStop: false, showRestart: false, showUpdate: true },
|
||||
})));
|
||||
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
|
||||
const ids = lifecycle.items.map(i => i.id);
|
||||
expect(ids).toEqual(['deploy', 'update']);
|
||||
});
|
||||
|
||||
it('disables every lifecycle item when isBusy', () => {
|
||||
const { result } = renderHook(() => useStackMenuItems('web.yml', makeCtx({
|
||||
isBusy: true,
|
||||
menuVisibility: { showDeploy: true, showStop: true, showRestart: true, showUpdate: true },
|
||||
})));
|
||||
const lifecycle = result.current.find(g => g.id === 'lifecycle')!;
|
||||
expect(lifecycle.items.every(i => i.disabled === true)).toBe(true);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user