mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
feat(sidebar): cockpit redesign with grouped stacks and activity footer (#702)
* chore: ignore .superpowers/ brainstorm scratch dir
* feat(sidebar): add useStackMenuItems hook with grouped menu model
Pure transform hook that converts StackMenuCtx into four ordered MenuGroup
arrays (inspect, organize, lifecycle, destructive). Shared type contract in
sidebar-types.ts gives both the ContextMenu and DropdownMenu a single source
of truth so they cannot drift. Covered by 8 unit tests.
* refactor(sidebar): stabilize useStackMenuItems memoization deps
Destructure menuVisibility flags into primitive deps so inline object
literals from callers do not defeat memoization. Add a test confirming
isBusy disables all lifecycle items.
* feat(sidebar): add usePinnedStacks hook with per-node localStorage
* refactor(sidebar): stabilize usePinnedStacks eviction signal and isPinned dep
Change evictedOldest shape to { file, seq } so consumer effects re-fire on
repeated evictions. Narrow isPinned's useCallback dep to the current node's
pinned list so it only rebinds on local changes. Add test for eviction
side-effect and a second test for the seq counter.
* feat(sidebar): add useSidebarGroupCollapse hook with per-node keys
* refactor(sidebar): tighten useSidebarGroupCollapse effect ordering
Collapse the two write/read effects into a single skip-next-write ref
pattern so switching nodes no longer writes the previous node's map under
the new key before hydration. Also skip the no-op mount write. Add test
for setCollapsed.
* feat(sidebar): add row + group-header style helpers
* feat(sidebar): add SidebarBrand with mono kicker + serif hero
* feat(sidebar): add SidebarActions wrapper for create + scan
* feat(sidebar): extract SidebarSearch with kbd pill
* feat(sidebar): add StackRow with cyan-rail active state
* refactor(sidebar): dedupe tooltip markup in StackRow, widen test coverage
Extract a local RowTooltip helper so the update and git-pending branches
share the CursorProvider scaffolding. Add four behavioral tests covering
click, keyboard activation, kebab stop-propagation, and the busy loader
branch.
* feat(sidebar): unify context + kebab menus via useStackMenuItems
* feat(sidebar): add StackGroup with collapse and pinned variant
* feat(sidebar): add StackList with pinned + label groups
* feat(sidebar): add SidebarActivityTicker with idle fallback
* feat(sidebar): add StackSidebar container composing the regions
* feat(sidebar): replace sidebar block with StackSidebar composition
* docs(sidebar): add stack sidebar feature page with screenshots
* fix(sidebar): satisfy react-hooks purity and memoization rules
* fix(sidebar): restore "Sencho Logo" alt text for E2E selector
This commit is contained in:
@@ -0,0 +1,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