mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +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:
@@ -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
|
||||
|
||||
@@ -95,6 +95,7 @@
|
||||
"pages": [
|
||||
"features/overview",
|
||||
"features/dashboard",
|
||||
"features/sidebar",
|
||||
"features/stack-management",
|
||||
"features/editor",
|
||||
"features/resources",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user