mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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,102 @@
|
||||
import { MoreVertical, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
||||
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { LabelDot } from '@/components/LabelPill';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { MenuGroup, MenuItem, StackMenuCtx } from './sidebar-types';
|
||||
import { useStackMenuItems } from '@/hooks/useStackMenuItems';
|
||||
|
||||
interface StackKebabMenuProps {
|
||||
file: string;
|
||||
ctx: StackMenuCtx;
|
||||
}
|
||||
|
||||
function GroupHeader({ id }: { id: string }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'px-2 pt-2 pb-1 font-mono text-[9px] tracking-[0.22em] uppercase',
|
||||
id === 'destructive' ? 'text-destructive/60' : 'text-stat-subtitle',
|
||||
)}>
|
||||
{id}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LabelsSub({ item, ctx }: { item: MenuItem; ctx: StackMenuCtx }) {
|
||||
return (
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
{item.label}
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent className="min-w-[180px]">
|
||||
{ctx.labels.length === 0 && (
|
||||
<DropdownMenuItem disabled>
|
||||
<span className="text-xs text-muted-foreground">No labels yet</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{ctx.labels.map(label => {
|
||||
const assigned = ctx.assignedLabelIds.includes(label.id);
|
||||
return (
|
||||
<DropdownMenuItem key={label.id} onSelect={(e) => { e.preventDefault(); ctx.toggleLabel(label.id); }}>
|
||||
<LabelDot color={label.color} />
|
||||
<span className="flex-1 font-mono text-[12px] ml-2">{label.name}</span>
|
||||
{assigned && <Check className="w-3.5 h-3.5 text-success ml-auto shrink-0" strokeWidth={1.5} />}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={ctx.openLabelManager}>
|
||||
<span className="text-xs">Manage labels...</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
);
|
||||
}
|
||||
|
||||
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
|
||||
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => item.onSelect()}
|
||||
disabled={item.disabled}
|
||||
className={item.destructive ? 'text-destructive focus:text-destructive' : undefined}
|
||||
>
|
||||
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
|
||||
<span className="flex-1">{item.label}</span>
|
||||
{item.shortcut && (
|
||||
<span className="ml-3 font-mono text-[10px] tracking-wider text-stat-subtitle">{item.shortcut}</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
function renderGroup(group: MenuGroup, ctx: StackMenuCtx, showSep: boolean) {
|
||||
return (
|
||||
<div key={group.id}>
|
||||
{showSep && <DropdownMenuSeparator />}
|
||||
<GroupHeader id={group.id} />
|
||||
{group.items.map(item => renderItem(item, ctx))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StackKebabMenu({ file, ctx }: StackKebabMenuProps) {
|
||||
const groups = useStackMenuItems(file, ctx);
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{groups.map((g, i) => renderGroup(g, ctx, i > 0))}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user