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:
Anso
2026-04-19 21:45:01 -04:00
committed by GitHub
parent 490c89c049
commit 370b67d7ec
29 changed files with 1793 additions and 646 deletions
+1
View File
@@ -49,6 +49,7 @@ CLAUDE.md
MANUAL_STEPS.md
docs/superpowers/
.worktrees/
.superpowers/
# Design system (local-only; authoritative reference for agents + user)
frontend/DESIGN.md
+1
View File
@@ -95,6 +95,7 @@
"pages": [
"features/overview",
"features/dashboard",
"features/sidebar",
"features/stack-management",
"features/editor",
"features/resources",
+54
View File
@@ -0,0 +1,54 @@
---
title: Stack Sidebar
description: Manage, group, and pin your stacks from the primary sidebar.
---
The stack sidebar is your cockpit for every stack on the active node. Stacks are grouped by label, your most-used stacks can be pinned to the top, and a live activity footer keeps you aware of what just happened.
<Frame>
<img src="/images/sidebar/sidebar-overview.png" alt="Stack sidebar overview" />
</Frame>
## Layout
From top to bottom:
1. **Branding header** shows your Sencho build version.
2. **Node switcher** selects which Sencho instance you are managing.
3. **Create Stack** opens the new-stack dialog. The folder icon scans your compose directory.
4. **Search** filters stacks by name. Press <kbd>⌘K</kbd> (or <kbd>Ctrl+K</kbd>) to focus it.
5. **Stack list** shows your stacks grouped by label.
6. **Activity footer** shows the most recent stack event.
## Groups
Stacks are grouped by label. Stacks with multiple labels appear in each matching group so you always see the full fleet membership per label. Stacks without labels appear in an **Unlabeled** group at the bottom. Click a group header to collapse or expand it; the collapsed state is remembered per node.
## Pinning
Right-click a stack and choose **Pin to top**. Pinned stacks sit in a dedicated **PINNED** group at the top of the list. Up to 10 stacks can be pinned per node; pinning an 11th evicts the oldest.
<Frame>
<img src="/images/sidebar/sidebar-pinned.png" alt="Pinned stacks appear in a PINNED group at top" />
</Frame>
## Context menu
Right-click any stack (or open the kebab that appears on hover) for its context menu, grouped by purpose:
- **Inspect**: view alerts, open the auto-heal sheet, check for image updates, open the running app.
- **Organize**: assign labels, pin or unpin the stack.
- **Lifecycle**: deploy, stop, restart, or update the stack.
- **Destructive**: delete the stack.
<Frame>
<img src="/images/sidebar/sidebar-context-menu.png" alt="Grouped stack context menu" />
</Frame>
## Activity footer
The footer shows the most recent stack lifecycle event within the last hour. Click it to jump to the global activity log. When nothing has happened recently, the footer reads **IDLE**.
<Frame>
<img src="/images/sidebar/sidebar-activity.png" alt="Activity footer ticker" />
</Frame>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
import type { ReactNode } from 'react';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { FolderSearch, Loader2 } from 'lucide-react';
interface SidebarActionsProps {
createStackSlot: ReactNode;
onScan: () => void;
isScanning: boolean;
}
export function SidebarActions({ createStackSlot, onScan, isScanning }: SidebarActionsProps) {
return (
<div className="p-4 flex gap-2">
<div className="flex-1">{createStackSlot}</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="rounded-lg shrink-0 shadow-btn-glow"
onClick={onScan}
disabled={isScanning}
>
{isScanning
? <Loader2 className="w-4 h-4 animate-spin" strokeWidth={1.5} />
: <FolderSearch className="w-4 h-4" strokeWidth={1.5} />}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>Scan stacks folder</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
@@ -0,0 +1,58 @@
import { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import type { NotificationItem } from '@/components/dashboard/types';
const ONE_HOUR_S = 60 * 60;
const NOW_TICK_MS = 30_000;
interface SidebarActivityTickerProps {
notifications: NotificationItem[];
connected: boolean;
onNavigate: () => void;
}
export function SidebarActivityTicker({ notifications, connected, onNavigate }: SidebarActivityTickerProps) {
const [nowS, setNowS] = useState(() => Math.floor(Date.now() / 1000));
useEffect(() => {
const id = setInterval(() => setNowS(Math.floor(Date.now() / 1000)), NOW_TICK_MS);
return () => clearInterval(id);
}, []);
const latest = useMemo(() => {
return notifications
.filter(n => n.stack_name && nowS - n.timestamp <= ONE_HOUR_S)
.sort((a, b) => b.timestamp - a.timestamp)[0] ?? null;
}, [notifications, nowS]);
const idle = latest === null;
const dotClass = connected
? 'bg-success shadow-[0_0_6px_var(--success)] animate-pulse'
: 'bg-warning';
const kicker = idle ? 'IDLE · NO RECENT ACTIVITY' : 'LIVE · VIEW ACTIVITY →';
return (
<button
type="button"
onClick={onNavigate}
className={cn(
'w-full flex flex-col gap-0.5 px-4 py-2 border-t border-glass-border',
'bg-sidebar/80 hover:bg-glass-highlight text-left',
)}
>
<div className="flex items-center gap-2">
<span data-testid="ticker-dot" className={cn('w-1.5 h-1.5 rounded-full', dotClass)} />
{idle ? (
<span className="font-mono text-[11px] text-muted-foreground">No recent activity</span>
) : (
<span className="font-mono text-[11px] truncate">
<span className="text-brand">{latest.stack_name}</span>
<span className="text-muted-foreground"> · {latest.message} · {formatTimeAgo(latest.timestamp * 1000)}</span>
</span>
)}
</div>
<span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle pl-3.5">
{kicker}
</span>
</button>
);
}
@@ -0,0 +1,21 @@
interface SidebarBrandProps {
isDarkMode: boolean;
}
export function SidebarBrand({ isDarkMode }: SidebarBrandProps) {
return (
<div className="flex items-center gap-2.5 px-4 py-3 border-b border-glass-border">
<img
src={isDarkMode ? '/sencho-logo-dark.png' : '/sencho-logo-light.png'}
alt="Sencho Logo"
className="w-10 h-10 shrink-0"
/>
<div className="flex flex-col leading-none">
<span className="font-mono text-[9px] tracking-[0.22em] uppercase text-stat-subtitle">
SENCHO · v{__APP_VERSION__}
</span>
<span className="font-serif italic text-xl text-foreground mt-1">Sencho</span>
</div>
</div>
);
}
@@ -0,0 +1,27 @@
import { CommandInput } from '@/components/ui/command';
interface SidebarSearchProps {
value: string;
onValueChange: (v: string) => void;
}
function kbdHint(): string {
if (typeof navigator === 'undefined') return 'Ctrl+K';
return /Mac|iPhone|iPad/.test(navigator.userAgent) ? '⌘K' : 'Ctrl+K';
}
export function SidebarSearch({ value, onValueChange }: SidebarSearchProps) {
return (
<div className="px-4 py-2 flex-none relative">
<CommandInput
placeholder="Search stacks..."
value={value}
onValueChange={onValueChange}
className="h-9 border-none"
/>
<kbd className="pointer-events-none absolute right-6 top-1/2 -translate-y-1/2 hidden sm:inline-flex h-5 items-center gap-0.5 rounded border border-glass-border bg-glass-highlight px-1.5 font-mono text-[10px] text-muted-foreground">
{kbdHint()}
</kbd>
</div>
);
}
@@ -0,0 +1,99 @@
import type { ReactNode } from 'react';
import { Check } from 'lucide-react';
import {
ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator,
ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, ContextMenuTrigger,
} from '@/components/ui/context-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 StackContextMenuProps {
file: string;
ctx: StackMenuCtx;
children: ReactNode;
}
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 (
<ContextMenuSub>
<ContextMenuSubTrigger>
<item.icon className="h-4 w-4 mr-2" strokeWidth={1.5} />
{item.label}
</ContextMenuSubTrigger>
<ContextMenuSubContent className="min-w-[180px]">
{ctx.labels.length === 0 && (
<ContextMenuItem disabled>
<span className="text-xs text-muted-foreground">No labels yet</span>
</ContextMenuItem>
)}
{ctx.labels.map(label => {
const assigned = ctx.assignedLabelIds.includes(label.id);
return (
<ContextMenuItem key={label.id} onClick={() => 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} />}
</ContextMenuItem>
);
})}
<ContextMenuSeparator />
<ContextMenuItem onClick={ctx.openLabelManager}>
<span className="text-xs">Manage labels...</span>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function renderItem(item: MenuItem, ctx: StackMenuCtx) {
if (item.id === 'labels') return <LabelsSub key={item.id} item={item} ctx={ctx} />;
return (
<ContextMenuItem
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>
)}
</ContextMenuItem>
);
}
function renderGroup(group: MenuGroup, ctx: StackMenuCtx, showSep: boolean) {
return (
<div key={group.id}>
{showSep && <ContextMenuSeparator />}
<GroupHeader id={group.id} />
{group.items.map(item => renderItem(item, ctx))}
</div>
);
}
export function StackContextMenu({ file, ctx, children }: StackContextMenuProps) {
const groups = useStackMenuItems(file, ctx);
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
{groups.map((g, i) => renderGroup(g, ctx, i > 0))}
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,43 @@
import type { ReactNode } from 'react';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
interface StackGroupProps {
id: string;
label: string;
count: number;
collapsed: boolean;
onToggle: () => void;
variant?: 'default' | 'pinned';
children: ReactNode;
}
export function StackGroup({ id, label, count, collapsed, onToggle, variant = 'default', children }: StackGroupProps) {
const labelColor = variant === 'pinned' ? 'text-brand/90' : 'text-stat-subtitle';
return (
<div className="mt-2">
<button
type="button"
onClick={onToggle}
className="flex items-center justify-between w-full px-2 py-1 text-left hover:bg-glass-highlight/30 rounded-md"
aria-expanded={!collapsed}
aria-controls={`group-${id}-body`}
>
<span className={cn('font-mono text-[9px] tracking-[0.22em] uppercase', labelColor)}>
{label}
</span>
<span className="flex items-center gap-1.5">
<span className="font-mono text-[9px] tabular-nums text-stat-icon">{count}</span>
{collapsed
? <ChevronRight className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />
: <ChevronDown className="w-3 h-3 text-stat-icon" strokeWidth={1.5} />}
</span>
</button>
{!collapsed && (
<div id={`group-${id}-body`} className="mt-0.5">
{children}
</div>
)}
</div>
);
}
@@ -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>
);
}
@@ -0,0 +1,197 @@
import { useMemo } from 'react';
import { ArrowUpRight, Loader2 } from 'lucide-react';
import { CommandItem, CommandList } from '@/components/ui/command';
import { Skeleton } from '@/components/ui/skeleton';
import type { Label } from '@/components/label-types';
import type { StackRowStatus } from './StackRow';
import { StackRow } from './StackRow';
import { StackGroup } from './StackGroup';
import { StackContextMenu } from './StackContextMenu';
import { StackKebabMenu } from './StackKebabMenu';
import type { StackMenuCtx } from './sidebar-types';
interface RemoteNodeResult {
nodeId: number;
nodeName: string;
files: { file: string; status: StackRowStatus }[];
}
export interface StackListProps {
files: string[];
isLoading: boolean;
isPaid: boolean;
selectedFile: string | null;
searchQuery: string;
stackLabelMap: Record<string, Label[]>;
stackStatuses: Record<string, StackRowStatus | undefined>;
stackUpdates: Record<string, boolean>;
gitSourcePendingMap: Record<string, boolean>;
labels: Label[];
pinnedFiles: string[];
isCollapsed: (groupKey: string) => boolean;
toggleCollapse: (groupKey: string) => void;
isBusy: (file: string) => boolean;
getDisplayName: (file: string) => string;
onSelectFile: (file: string) => void;
buildMenuCtx: (file: string) => StackMenuCtx;
remoteResults: RemoteNodeResult[];
remoteLoading: boolean;
onSelectRemoteFile: (nodeId: number, file: string) => void;
}
interface BuiltGroup {
kind: 'pinned' | 'labeled' | 'unlabeled';
id: string;
label: string;
count: number;
files: string[];
variant?: 'default' | 'pinned';
}
function buildGroups(
files: string[],
pinnedFiles: string[],
stackLabelMap: Record<string, Label[]>,
labels: Label[],
): BuiltGroup[] {
const result: BuiltGroup[] = [];
const pinnedSet = new Set(pinnedFiles.filter(f => files.includes(f)));
if (pinnedSet.size > 0) {
result.push({
kind: 'pinned', id: 'pinned', label: 'PINNED',
count: pinnedSet.size, files: Array.from(pinnedSet), variant: 'pinned',
});
}
const labelBuckets = new Map<number, { label: Label; files: string[] }>();
const unlabeled: string[] = [];
for (const file of files) {
const assigned = stackLabelMap[file] ?? [];
if (assigned.length === 0) {
unlabeled.push(file);
} else {
for (const l of assigned) {
const bucket = labelBuckets.get(l.id) ?? { label: l, files: [] };
bucket.files.push(file);
labelBuckets.set(l.id, bucket);
}
}
}
const sortedLabels = [...labelBuckets.values()]
.sort((a, b) => b.files.length - a.files.length || a.label.name.localeCompare(b.label.name));
for (const { label, files: bucketFiles } of sortedLabels) {
result.push({
kind: 'labeled', id: `label:${label.id}`,
label: label.name.toUpperCase(), count: bucketFiles.length, files: bucketFiles,
});
}
if (unlabeled.length > 0) {
result.push({ kind: 'unlabeled', id: 'unlabeled', label: 'UNLABELED', count: unlabeled.length, files: unlabeled });
}
void labels;
return result;
}
export function StackList(props: StackListProps) {
const {
files, isLoading, isPaid, selectedFile, searchQuery, stackLabelMap, stackStatuses,
stackUpdates, gitSourcePendingMap, labels, pinnedFiles, isCollapsed, toggleCollapse,
isBusy, getDisplayName, onSelectFile, buildMenuCtx, remoteResults, remoteLoading, onSelectRemoteFile,
} = props;
const groups = useMemo(
() => buildGroups(files, pinnedFiles, stackLabelMap, labels),
[files, pinnedFiles, stackLabelMap, labels],
);
if (isLoading) {
return (
<div className="space-y-2 px-2 mt-2">
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
<Skeleton className="h-8 w-full" />
</div>
);
}
return (
<CommandList className="max-h-none overflow-visible">
{groups.map(g => (
<StackGroup
key={g.id}
id={g.id}
label={g.label}
count={g.count}
collapsed={isCollapsed(g.id)}
onToggle={() => toggleCollapse(g.id)}
variant={g.variant}
>
{g.files.map(file => {
const ctx = buildMenuCtx(file);
return (
<StackContextMenu key={`${g.id}:${file}`} file={file} ctx={ctx}>
<CommandItem
value={file}
onSelect={() => onSelectFile(file)}
className="p-0 data-[selected=true]:bg-transparent"
>
<StackRow
file={file}
displayName={getDisplayName(file)}
status={stackStatuses[file] ?? 'unknown'}
isBusy={isBusy(file)}
isActive={selectedFile === file}
isPaid={isPaid}
labels={stackLabelMap[file] ?? []}
hasUpdate={!!stackUpdates[file]}
hasGitPending={!!gitSourcePendingMap[file]}
onSelect={onSelectFile}
kebabSlot={<StackKebabMenu file={file} ctx={ctx} />}
/>
</CommandItem>
</StackContextMenu>
);
})}
</StackGroup>
))}
{searchQuery.trim() && (remoteLoading || remoteResults.length > 0) && (
<div className="mt-3 pt-3 border-t border-glass-border">
<h3 className="text-[10px] font-medium tracking-[0.08em] uppercase text-stat-subtitle px-4 pb-2 flex items-center gap-2">
Other nodes
{remoteLoading && <Loader2 className="w-3 h-3 animate-spin text-stat-icon" strokeWidth={1.5} />}
</h3>
{remoteResults.map(({ nodeId, nodeName, files: remoteFiles }) => (
<div key={nodeId} className="mb-2">
<div className="px-4 pb-1 flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-[0.06em] text-stat-subtitle">
<span className="w-1.5 h-1.5 rounded-full bg-success" />
<span className="truncate">{nodeName}</span>
</div>
{remoteFiles.map(({ file, status }) => (
<button
key={`${nodeId}:${file}`}
type="button"
onClick={() => onSelectRemoteFile(nodeId, file)}
className="w-full text-left justify-start rounded-lg mb-1 cursor-pointer hover:bg-glass-highlight px-2 py-1.5 flex items-center gap-2"
>
<span className={`font-mono text-[10px] shrink-0 w-5 ${
status === 'running' ? 'text-success' :
status === 'exited' ? 'text-destructive' : 'text-stat-icon'
}`}>
{status === 'running' ? 'UP' : status === 'exited' ? 'DN' : '--'}
</span>
<span className="flex-1 truncate font-mono text-xs">{file}</span>
<ArrowUpRight className="w-3 h-3 text-stat-icon shrink-0" strokeWidth={1.5} />
</button>
))}
</div>
))}
</div>
)}
</CommandList>
);
}
@@ -0,0 +1,93 @@
import type { ReactNode } from 'react';
import { GitBranch, Loader2 } from 'lucide-react';
import { Cursor, CursorContainer, CursorFollow, CursorProvider } from '@/components/animate-ui/primitives/animate/cursor';
import { LabelDot } from '@/components/LabelPill';
import type { Label } from '@/components/label-types';
import { cn } from '@/lib/utils';
import { sidebarRowActive, sidebarRowBase } from './sidebar-styles';
export type StackRowStatus = 'running' | 'exited' | 'unknown';
interface StackRowProps {
file: string;
displayName: string;
status: StackRowStatus;
isBusy: boolean;
isActive: boolean;
isPaid: boolean;
labels: Label[];
hasUpdate: boolean;
hasGitPending: boolean;
onSelect: (file: string) => void;
kebabSlot: ReactNode;
}
function statusText(status: StackRowStatus): string {
if (status === 'running') return 'UP';
if (status === 'exited') return 'DN';
return '--';
}
function statusColor(status: StackRowStatus, isBusy: boolean): string {
if (isBusy) return 'text-muted-foreground';
if (status === 'running') return 'text-success';
if (status === 'exited') return 'text-destructive';
return 'text-stat-icon';
}
function RowTooltip({ trigger, label }: { trigger: ReactNode; label: string }) {
return (
<CursorProvider>
<CursorContainer className="inline-flex items-center shrink-0">{trigger}</CursorContainer>
<Cursor><div className="h-2 w-2 rounded-full bg-brand" /></Cursor>
<CursorFollow side="bottom" sideOffset={4} align="center" transition={{ stiffness: 400, damping: 40, bounce: 0 }}>
<div className="rounded-md border border-card-border bg-popover/95 backdrop-blur-[10px] backdrop-saturate-[1.15] px-2.5 py-1.5 shadow-md">
<span className="font-mono text-xs tabular-nums text-stat-value">{label}</span>
</div>
</CursorFollow>
</CursorProvider>
);
}
export function StackRow(props: StackRowProps) {
const { file, displayName, status, isBusy, isActive, isPaid, labels, hasUpdate, hasGitPending, onSelect, kebabSlot } = props;
return (
<div
data-testid="stack-row"
role="button"
tabIndex={0}
className={cn(sidebarRowBase, isActive && sidebarRowActive)}
onClick={() => onSelect(file)}
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(file); } }}
>
<span className={cn('font-mono text-[10px] shrink-0 w-[22px] flex items-center', statusColor(status, isBusy))}>
{isBusy ? <Loader2 className="w-3 h-3 animate-spin" strokeWidth={2} /> : statusText(status)}
</span>
<span className="flex-1 truncate font-mono text-[13px]">{displayName}</span>
{isPaid && labels.length > 0 && (
<span className="flex items-center gap-0.5 shrink-0">
{labels.map(l => <LabelDot key={l.id} color={l.color} />)}
</span>
)}
{hasUpdate && (
<RowTooltip
trigger={<span className="w-2 h-2 rounded-full bg-info animate-pulse" />}
label="Update available"
/>
)}
{hasGitPending && (
<RowTooltip
trigger={<GitBranch className="w-3 h-3 text-brand" strokeWidth={1.5} />}
label="Git source update pending"
/>
)}
<div
className="opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{kebabSlot}
</div>
</div>
);
}
@@ -0,0 +1,54 @@
import type { ReactNode } from 'react';
import { Command } from '@/components/ui/command';
import { ScrollArea } from '@/components/ui/scroll-area';
import type { NotificationItem } from '@/components/dashboard/types';
import { SidebarActions } from './SidebarActions';
import { SidebarActivityTicker } from './SidebarActivityTicker';
import { SidebarBrand } from './SidebarBrand';
import { SidebarSearch } from './SidebarSearch';
import { StackList, type StackListProps } from './StackList';
export interface StackSidebarProps {
isDarkMode: boolean;
nodeSwitcherSlot: ReactNode;
createStackSlot: ReactNode | null;
onScan: () => void;
isScanning: boolean;
canCreate: boolean;
searchQuery: string;
onSearchChange: (v: string) => void;
list: StackListProps;
notifications: NotificationItem[];
tickerConnected: boolean;
onOpenActivity: () => void;
}
export function StackSidebar(props: StackSidebarProps) {
const {
isDarkMode, nodeSwitcherSlot, createStackSlot, onScan, isScanning, canCreate,
searchQuery, onSearchChange, list, notifications, tickerConnected, onOpenActivity,
} = props;
return (
<div className="w-64 border-r border-glass-border bg-sidebar backdrop-blur-md flex flex-col">
<SidebarBrand isDarkMode={isDarkMode} />
<div className="px-4 pt-2 pb-0">{nodeSwitcherSlot}</div>
{canCreate && createStackSlot !== null && (
<SidebarActions createStackSlot={createStackSlot} onScan={onScan} isScanning={isScanning} />
)}
<Command shouldFilter={false} className="bg-transparent flex-1 flex flex-col overflow-hidden">
<SidebarSearch value={searchQuery} onValueChange={onSearchChange} />
<ScrollArea className="flex-1 px-2 pb-2">
<div data-stacks-loaded={list.isLoading ? 'false' : 'true'}>
<StackList {...list} />
</div>
</ScrollArea>
</Command>
<SidebarActivityTicker
notifications={notifications}
connected={tickerConnected}
onNavigate={onOpenActivity}
/>
</div>
);
}
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { SidebarActivityTicker } from '../SidebarActivityTicker';
import type { NotificationItem } from '@/components/dashboard/types';
function notif(overrides: Partial<NotificationItem> = {}): NotificationItem {
return {
id: 1,
level: 'info',
message: 'web deployed',
timestamp: Math.floor(Date.now() / 1000) - 12,
is_read: 0,
stack_name: 'web',
...overrides,
};
}
describe('SidebarActivityTicker', () => {
it('shows idle fallback when no recent stack events', () => {
render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
});
it('renders stack name + message when a recent event exists', () => {
render(
<SidebarActivityTicker notifications={[notif()]} connected onNavigate={() => {}} />
);
expect(screen.getByText('web')).toBeInTheDocument();
expect(screen.getByText(/deployed/i)).toBeInTheDocument();
});
it('marks connected dot when connected, amber dot when disconnected', () => {
const { rerender } = render(<SidebarActivityTicker notifications={[]} connected onNavigate={() => {}} />);
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-success');
rerender(<SidebarActivityTicker notifications={[]} connected={false} onNavigate={() => {}} />);
expect(screen.getByTestId('ticker-dot')).toHaveClass('bg-warning');
});
it('falls back to idle when events are older than 1 hour', () => {
const old = notif({ timestamp: Math.floor(Date.now() / 1000) - 60 * 60 - 1 });
render(<SidebarActivityTicker notifications={[old]} connected onNavigate={() => {}} />);
expect(screen.getByText(/IDLE/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,46 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StackGroup } from '../StackGroup';
describe('StackGroup', () => {
it('renders label and count', () => {
render(
<StackGroup id="prod" label="PROD" count={4} collapsed={false} onToggle={() => {}}>
<div>child</div>
</StackGroup>
);
expect(screen.getByText('PROD')).toBeInTheDocument();
expect(screen.getByText('4')).toBeInTheDocument();
});
it('hides children when collapsed', () => {
render(
<StackGroup id="prod" label="PROD" count={4} collapsed={true} onToggle={() => {}}>
<div data-testid="child">child</div>
</StackGroup>
);
expect(screen.queryByTestId('child')).toBeNull();
});
it('invokes onToggle when header clicked', async () => {
const user = userEvent.setup();
const onToggle = vi.fn();
render(
<StackGroup id="prod" label="PROD" count={4} collapsed={false} onToggle={onToggle}>
<div>child</div>
</StackGroup>
);
await user.click(screen.getByRole('button', { name: /PROD/i }));
expect(onToggle).toHaveBeenCalledOnce();
});
it('applies pinned variant styling when variant="pinned"', () => {
render(
<StackGroup id="pinned" label="PINNED" count={2} collapsed={false} onToggle={() => {}} variant="pinned">
<div>child</div>
</StackGroup>
);
expect(screen.getByText('PINNED')).toHaveClass('text-brand/90');
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import type { ComponentProps } from 'react';
import { StackRow } from '../StackRow';
import type { Label } from '@/components/label-types';
function base(overrides: Partial<ComponentProps<typeof StackRow>> = {}) {
return {
file: 'web.yml',
displayName: 'web',
status: 'running' as const,
isBusy: false,
isActive: false,
isPaid: true,
labels: [] as Label[],
hasUpdate: false,
hasGitPending: false,
onSelect: vi.fn(),
kebabSlot: null,
...overrides,
};
}
describe('StackRow', () => {
it('renders UP for running', () => {
render(<StackRow {...base()} />);
expect(screen.getByText('UP')).toBeInTheDocument();
});
it('renders DN for exited', () => {
render(<StackRow {...base({ status: 'exited' })} />);
expect(screen.getByText('DN')).toBeInTheDocument();
});
it('renders -- for unknown', () => {
render(<StackRow {...base({ status: 'unknown' })} />);
expect(screen.getByText('--')).toBeInTheDocument();
});
it('renders cyan rail only when active', () => {
const { rerender } = render(<StackRow {...base({ isActive: false })} />);
expect(screen.getByTestId('stack-row')).not.toHaveClass('bg-accent/[0.07]');
rerender(<StackRow {...base({ isActive: true })} />);
expect(screen.getByTestId('stack-row')).toHaveClass('bg-accent/[0.07]');
});
it('fires onSelect on click', () => {
const onSelect = vi.fn();
render(<StackRow {...base({ onSelect })} />);
screen.getByTestId('stack-row').click();
expect(onSelect).toHaveBeenCalledWith('web.yml');
});
it('fires onSelect on Enter and Space', () => {
const onSelect = vi.fn();
render(<StackRow {...base({ onSelect })} />);
const row = screen.getByTestId('stack-row');
row.focus();
fireEvent.keyDown(row, { key: 'Enter' });
fireEvent.keyDown(row, { key: ' ' });
expect(onSelect).toHaveBeenCalledTimes(2);
});
it('kebab click does not trigger onSelect', () => {
const onSelect = vi.fn();
render(<StackRow {...base({ onSelect, kebabSlot: <button data-testid="kebab">k</button> })} />);
screen.getByTestId('kebab').click();
expect(onSelect).not.toHaveBeenCalled();
});
it('renders loader when isBusy', () => {
const { container } = render(<StackRow {...base({ isBusy: true })} />);
expect(container.querySelector('.animate-spin')).not.toBeNull();
expect(screen.queryByText('UP')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { cn } from '@/lib/utils';
export const sidebarRowBase = cn(
'relative flex items-center gap-2 w-full px-2 py-1.5 rounded-md mb-0.5',
'font-mono text-[13px] text-muted-foreground',
'hover:bg-glass-highlight hover:text-foreground',
'transition-colors group cursor-pointer',
);
export const sidebarRowActive = cn(
'bg-accent/[0.07] text-stat-value',
'after:content-[""] after:absolute after:left-[-12px] after:top-1 after:bottom-1',
'after:w-[3px] after:rounded-sm after:bg-brand',
'after:shadow-[0_0_6px_var(--brand)]',
);
export const sidebarGroupHeader = cn(
'flex items-center justify-between w-full px-2 pt-3 pb-1',
'text-[9px] leading-3 tracking-[0.22em] uppercase text-stat-subtitle',
'cursor-pointer select-none',
);
@@ -0,0 +1,49 @@
import type { LucideIcon } from 'lucide-react';
import type { Label } from '../label-types';
export type MenuGroupId = 'inspect' | 'organize' | 'lifecycle' | 'destructive';
export interface MenuItem {
id: string;
label: string;
icon: LucideIcon;
shortcut?: string;
onSelect: () => void;
disabled?: boolean;
destructive?: boolean;
subItems?: MenuItem[];
}
export interface MenuGroup {
id: MenuGroupId;
items: MenuItem[];
}
export type StackLifecycleStatus = 'running' | 'exited' | 'unknown';
export interface StackMenuCtx {
stackStatus: StackLifecycleStatus;
hasPort: boolean;
isBusy: boolean;
isPaid: boolean;
canDelete: boolean;
isPinned: boolean;
labels: Label[];
assignedLabelIds: number[];
menuVisibility: { showDeploy: boolean; showStop: boolean; showRestart: boolean; showUpdate: boolean };
openAlertSheet: () => void;
openAutoHeal: () => void;
checkUpdates: () => void;
openStackApp: () => void;
deploy: () => void;
stop: () => void;
restart: () => void;
update: () => void;
remove: () => void;
pin: () => void;
unpin: () => void;
toggleLabel: (labelId: number) => void;
openLabelManager: () => void;
}
export type StackGroupKind = 'pinned' | 'labeled' | 'unlabeled';
@@ -0,0 +1,75 @@
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']);
});
});
@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useSidebarGroupCollapse } from '../useSidebarGroupCollapse';
describe('useSidebarGroupCollapse', () => {
beforeEach(() => window.localStorage.clear());
it('defaults to expanded (isCollapsed returns false)', () => {
const { result } = renderHook(() => useSidebarGroupCollapse(1));
expect(result.current.isCollapsed('prod')).toBe(false);
});
it('toggle flips state and persists', () => {
const { result } = renderHook(() => useSidebarGroupCollapse(1));
act(() => result.current.toggle('prod'));
expect(result.current.isCollapsed('prod')).toBe(true);
const raw = window.localStorage.getItem('sencho:sidebar:groups:1');
expect(raw).not.toBeNull();
expect(JSON.parse(raw!)).toEqual({ prod: true });
});
it('toggle twice returns to expanded', () => {
const { result } = renderHook(() => useSidebarGroupCollapse(1));
act(() => { result.current.toggle('prod'); result.current.toggle('prod'); });
expect(result.current.isCollapsed('prod')).toBe(false);
});
it('keys state per node', () => {
const a = renderHook(() => useSidebarGroupCollapse(1));
act(() => a.result.current.toggle('prod'));
const b = renderHook(() => useSidebarGroupCollapse(2));
expect(b.result.current.isCollapsed('prod')).toBe(false);
});
it('restores state from localStorage on mount', () => {
window.localStorage.setItem('sencho:sidebar:groups:1', JSON.stringify({ prod: true }));
const { result } = renderHook(() => useSidebarGroupCollapse(1));
expect(result.current.isCollapsed('prod')).toBe(true);
});
it('setCollapsed writes explicit boolean value', () => {
const { result } = renderHook(() => useSidebarGroupCollapse(1));
act(() => result.current.setCollapsed('prod', true));
expect(result.current.isCollapsed('prod')).toBe(true);
act(() => result.current.setCollapsed('prod', false));
expect(result.current.isCollapsed('prod')).toBe(false);
});
});
@@ -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);
});
});
+69
View File
@@ -0,0 +1,69 @@
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 };
}
@@ -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 };
}
+86
View File
@@ -0,0 +1,86 @@
import { useMemo } from 'react';
import {
Activity,
ArrowUpRight,
BellRing,
Download,
Pin,
PinOff,
Play,
RefreshCw,
RotateCw,
Square,
Tag,
Trash2,
} from 'lucide-react';
import type { MenuGroup, MenuItem, StackMenuCtx } from '@/components/sidebar/sidebar-types';
export function useStackMenuItems(_file: string, ctx: StackMenuCtx): MenuGroup[] {
const {
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
menuVisibility,
} = ctx;
const { showDeploy, showStop, showRestart, showUpdate } = menuVisibility;
return useMemo(() => {
const groups: MenuGroup[] = [];
const inspect: MenuItem[] = [
{ id: 'alerts', label: 'Alerts', icon: BellRing, shortcut: 'A', onSelect: openAlertSheet },
];
if (isPaid) {
inspect.push({ id: 'auto-heal', label: 'Auto-Heal', icon: Activity, shortcut: 'H', onSelect: openAutoHeal });
}
inspect.push({ id: 'check-updates', label: 'Check updates', icon: RefreshCw, shortcut: 'U', onSelect: checkUpdates });
if (stackStatus === 'running' && hasPort) {
inspect.push({ id: 'open-app', label: 'Open App', icon: ArrowUpRight, shortcut: '↗', onSelect: openStackApp });
}
groups.push({ id: 'inspect', items: inspect });
const organize: MenuItem[] = [];
if (isPaid) {
organize.push({
id: 'labels',
label: 'Labels',
icon: Tag,
shortcut: 'L ',
onSelect: () => {},
subItems: labels.map(l => ({
id: `label:${l.id}`,
label: l.name,
icon: Tag,
onSelect: () => toggleLabel(l.id),
})),
});
}
organize.push(
isPinned
? { id: 'pin', label: 'Unpin', icon: PinOff, shortcut: 'P', onSelect: unpin }
: { id: 'pin', label: 'Pin to top', icon: Pin, shortcut: 'P', onSelect: pin }
);
groups.push({ id: 'organize', items: organize });
const lifecycle: MenuItem[] = [];
if (showDeploy) lifecycle.push({ id: 'deploy', label: 'Deploy', icon: Play, shortcut: '⌘↵', onSelect: deploy, disabled: isBusy });
if (showStop) lifecycle.push({ id: 'stop', label: 'Stop', icon: Square, shortcut: '⌘.', onSelect: stop, disabled: isBusy });
if (showRestart) lifecycle.push({ id: 'restart', label: 'Restart', icon: RotateCw, shortcut: '⌘R', onSelect: restart, disabled: isBusy });
if (showUpdate) lifecycle.push({ id: 'update', label: 'Update', icon: Download, shortcut: '⌘↑', onSelect: update, disabled: isBusy });
if (lifecycle.length > 0) groups.push({ id: 'lifecycle', items: lifecycle });
if (canDelete) {
groups.push({
id: 'destructive',
items: [{ id: 'delete', label: 'Delete', icon: Trash2, shortcut: '⌘⌫', destructive: true, onSelect: remove }],
});
}
return groups;
}, [
stackStatus, hasPort, isBusy, isPaid, canDelete, isPinned, labels,
showDeploy, showStop, showRestart, showUpdate,
openAlertSheet, openAutoHeal, checkUpdates, openStackApp,
deploy, stop, restart, update, remove, pin, unpin, toggleLabel,
]);
}