mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-15 02:42:45 +08:00
feat: add Terminal Dashboard components and state management
- Implemented DashboardToolbar for managing panel toggles and layout presets. - Created FloatingPanel for a generic sliding panel interface. - Developed TerminalGrid for rendering a recursive layout of terminal panes. - Added TerminalPane to encapsulate individual terminal instances with toolbar actions. - Introduced layout utilities for managing Allotment layout trees. - Established Zustand store for terminal grid state management, supporting pane operations and layout resets.
This commit is contained in:
@@ -1,136 +0,0 @@
|
||||
// ========================================
|
||||
// BottomPanel Component
|
||||
// ========================================
|
||||
// Full-width collapsible bottom panel with Queue + Inspector tabs.
|
||||
// Replaces the separate BottomInspector + middle-column QueuePanel layout.
|
||||
// Queue tab shows inline count badge; Inspector tab shows chain indicator.
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ListChecks, Info, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { QueuePanel } from './QueuePanel';
|
||||
import { InspectorContent } from './BottomInspector';
|
||||
import { useIssueQueue } from '@/hooks/useIssues';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
type TabId = 'queue' | 'inspector';
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function BottomPanel() {
|
||||
const { formatMessage } = useIntl();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('queue');
|
||||
|
||||
const queueQuery = useIssueQueue();
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
|
||||
// Count queue items for badge
|
||||
const queueCount = useMemo(() => {
|
||||
if (!queueQuery.data) return 0;
|
||||
const grouped = queueQuery.data.grouped_items ?? {};
|
||||
let count = 0;
|
||||
for (const items of Object.values(grouped)) {
|
||||
count += items.length;
|
||||
}
|
||||
return count;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
const hasChain = associationChain !== null;
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const handleTabClick = useCallback((tab: TabId) => {
|
||||
setActiveTab(tab);
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'border-t border-border bg-muted/30 shrink-0 transition-all duration-200',
|
||||
)}
|
||||
>
|
||||
{/* Tab bar (always visible, ~36px) */}
|
||||
<div className="flex items-center gap-0 shrink-0">
|
||||
{/* Queue tab */}
|
||||
<button
|
||||
onClick={() => handleTabClick('queue')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors border-b-2',
|
||||
activeTab === 'queue' && isOpen
|
||||
? 'border-b-primary text-foreground font-medium'
|
||||
: 'border-b-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<ListChecks className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.bottomPanel.queueTab' })}
|
||||
{queueCount > 0 && (
|
||||
<Badge variant="info" className="text-[10px] px-1.5 py-0 ml-0.5">
|
||||
{queueCount}
|
||||
</Badge>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Inspector tab */}
|
||||
<button
|
||||
onClick={() => handleTabClick('inspector')}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 text-xs transition-colors border-b-2',
|
||||
activeTab === 'inspector' && isOpen
|
||||
? 'border-b-primary text-foreground font-medium'
|
||||
: 'border-b-transparent text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
{formatMessage({ id: 'terminalDashboard.bottomPanel.inspectorTab' })}
|
||||
{hasChain && (
|
||||
<span className="ml-1 w-2 h-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Collapse/expand toggle at right */}
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="ml-auto px-3 py-1.5 text-muted-foreground hover:text-foreground transition-colors"
|
||||
title={formatMessage({
|
||||
id: isOpen
|
||||
? 'terminalDashboard.bottomPanel.collapse'
|
||||
: 'terminalDashboard.bottomPanel.expand',
|
||||
})}
|
||||
>
|
||||
{isOpen ? (
|
||||
<ChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<ChevronUp className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collapsible content area */}
|
||||
<div
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-200',
|
||||
isOpen ? 'max-h-[280px] opacity-100' : 'max-h-0 opacity-0',
|
||||
)}
|
||||
>
|
||||
<div className="h-[280px] border-t border-border/50">
|
||||
{activeTab === 'queue' ? (
|
||||
<QueuePanel embedded />
|
||||
) : (
|
||||
<InspectorContent />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
// ========================================
|
||||
// DashboardToolbar Component
|
||||
// ========================================
|
||||
// Top toolbar for Terminal Dashboard V2.
|
||||
// Provides toggle buttons for floating panels (Sessions/Issues/Queue/Inspector)
|
||||
// and layout preset controls.
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
FolderTree,
|
||||
AlertCircle,
|
||||
ListChecks,
|
||||
Info,
|
||||
LayoutGrid,
|
||||
Columns2,
|
||||
Rows2,
|
||||
Square,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import { useIssues, useIssueQueue } from '@/hooks/useIssues';
|
||||
import { useTerminalGridStore } from '@/stores/terminalGridStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export type PanelId = 'sessions' | 'issues' | 'queue' | 'inspector';
|
||||
|
||||
interface DashboardToolbarProps {
|
||||
activePanel: PanelId | null;
|
||||
onTogglePanel: (panelId: PanelId) => void;
|
||||
}
|
||||
|
||||
// ========== Layout Presets ==========
|
||||
|
||||
const LAYOUT_PRESETS = [
|
||||
{ id: 'single' as const, icon: Square, labelId: 'terminalDashboard.toolbar.layoutSingle' },
|
||||
{ id: 'split-h' as const, icon: Columns2, labelId: 'terminalDashboard.toolbar.layoutSplitH' },
|
||||
{ id: 'split-v' as const, icon: Rows2, labelId: 'terminalDashboard.toolbar.layoutSplitV' },
|
||||
{ id: 'grid-2x2' as const, icon: LayoutGrid, labelId: 'terminalDashboard.toolbar.layoutGrid' },
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function DashboardToolbar({ activePanel, onTogglePanel }: DashboardToolbarProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Session count
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const sessionCount = useMemo(() => {
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
let activeCount = 0;
|
||||
for (const sid of allSessionIds) {
|
||||
const meta = terminalMetas[sid];
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
if (status === 'active') activeCount++;
|
||||
}
|
||||
return activeCount > 0 ? activeCount : allSessionIds.length;
|
||||
}, [groups, terminalMetas]);
|
||||
|
||||
// Issues count
|
||||
const { openCount } = useIssues();
|
||||
|
||||
// Queue count
|
||||
const queueQuery = useIssueQueue();
|
||||
const queueCount = useMemo(() => {
|
||||
if (!queueQuery.data) return 0;
|
||||
const grouped = queueQuery.data.grouped_items ?? {};
|
||||
let count = 0;
|
||||
for (const items of Object.values(grouped)) {
|
||||
count += items.length;
|
||||
}
|
||||
return count;
|
||||
}, [queueQuery.data]);
|
||||
|
||||
// Inspector chain indicator
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const hasChain = associationChain !== null;
|
||||
|
||||
// Layout preset handler
|
||||
const resetLayout = useTerminalGridStore((s) => s.resetLayout);
|
||||
const handlePreset = useCallback(
|
||||
(preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => {
|
||||
resetLayout(preset);
|
||||
},
|
||||
[resetLayout]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 px-2 h-[40px] border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Panel toggle buttons */}
|
||||
<ToolbarButton
|
||||
icon={FolderTree}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.sessions' })}
|
||||
isActive={activePanel === 'sessions'}
|
||||
onClick={() => onTogglePanel('sessions')}
|
||||
badge={sessionCount > 0 ? sessionCount : undefined}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={AlertCircle}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
|
||||
isActive={activePanel === 'issues'}
|
||||
onClick={() => onTogglePanel('issues')}
|
||||
badge={openCount > 0 ? openCount : undefined}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={ListChecks}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
|
||||
isActive={activePanel === 'queue'}
|
||||
onClick={() => onTogglePanel('queue')}
|
||||
badge={queueCount > 0 ? queueCount : undefined}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={Info}
|
||||
label={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
|
||||
isActive={activePanel === 'inspector'}
|
||||
onClick={() => onTogglePanel('inspector')}
|
||||
dot={hasChain}
|
||||
/>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="w-px h-5 bg-border mx-1" />
|
||||
|
||||
{/* Layout presets */}
|
||||
{LAYOUT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
onClick={() => handlePreset(preset.id)}
|
||||
className={cn(
|
||||
'p-1.5 rounded transition-colors',
|
||||
'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
title={formatMessage({ id: preset.labelId })}
|
||||
>
|
||||
<preset.icon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
))}
|
||||
|
||||
{/* Right-aligned title */}
|
||||
<span className="ml-auto text-xs text-muted-foreground font-medium">
|
||||
{formatMessage({ id: 'terminalDashboard.page.title' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Toolbar Button ==========
|
||||
|
||||
function ToolbarButton({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
badge,
|
||||
dot,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
badge?: number;
|
||||
dot?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs transition-colors',
|
||||
isActive
|
||||
? 'bg-primary/10 text-primary font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-muted'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5" />
|
||||
<span>{label}</span>
|
||||
{badge !== undefined && badge > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 ml-0.5">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{dot && (
|
||||
<span className="ml-0.5 w-2 h-2 rounded-full bg-primary shrink-0" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
101
ccw/frontend/src/components/terminal-dashboard/FloatingPanel.tsx
Normal file
101
ccw/frontend/src/components/terminal-dashboard/FloatingPanel.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
// ========================================
|
||||
// FloatingPanel Component
|
||||
// ========================================
|
||||
// Generic floating panel container (Drawer style).
|
||||
// Slides in from left or right side, overlaying the terminal grid.
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface FloatingPanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
side?: 'left' | 'right';
|
||||
width?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function FloatingPanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
side = 'left',
|
||||
width = 320,
|
||||
children,
|
||||
}: FloatingPanelProps) {
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
const handleBackdropClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
},
|
||||
[onClose]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 transition-opacity duration-200',
|
||||
isOpen ? 'opacity-100 pointer-events-auto' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
style={{ top: '40px' }} // Below toolbar
|
||||
onClick={handleBackdropClick}
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
</div>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
ref={panelRef}
|
||||
className={cn(
|
||||
'fixed z-50 flex flex-col bg-background border-border shadow-lg',
|
||||
'transition-transform duration-200 ease-out',
|
||||
side === 'left' && 'left-0 border-r',
|
||||
side === 'right' && 'right-0 border-l',
|
||||
// Transform based on open state and side
|
||||
side === 'left' && (isOpen ? 'translate-x-0' : '-translate-x-full'),
|
||||
side === 'right' && (isOpen ? 'translate-x-0' : 'translate-x-full'),
|
||||
)}
|
||||
style={{
|
||||
top: '40px', // Below toolbar
|
||||
height: 'calc(100vh - 56px - 40px)', // Subtract both app header and toolbar
|
||||
width: `${width}px`,
|
||||
}}
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-border shrink-0">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Panel content */}
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
118
ccw/frontend/src/components/terminal-dashboard/TerminalGrid.tsx
Normal file
118
ccw/frontend/src/components/terminal-dashboard/TerminalGrid.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// ========================================
|
||||
// TerminalGrid Component
|
||||
// ========================================
|
||||
// Recursive Allotment renderer for the terminal split pane layout.
|
||||
// Mirrors the LayoutContainer pattern from cli-viewer but renders
|
||||
// TerminalPane components as leaf nodes.
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Allotment } from 'allotment';
|
||||
import 'allotment/dist/style.css';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { isPaneId } from '@/lib/layout-utils';
|
||||
import {
|
||||
useTerminalGridStore,
|
||||
selectTerminalGridLayout,
|
||||
selectTerminalGridPanes,
|
||||
} from '@/stores/terminalGridStore';
|
||||
import type { AllotmentLayoutGroup } from '@/stores/viewerStore';
|
||||
import { TerminalPane } from './TerminalPane';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
interface GridGroupRendererProps {
|
||||
group: AllotmentLayoutGroup;
|
||||
minSize: number;
|
||||
onSizeChange: (sizes: number[]) => void;
|
||||
}
|
||||
|
||||
// ========== Recursive Group Renderer ==========
|
||||
|
||||
function GridGroupRenderer({ group, minSize, onSizeChange }: GridGroupRendererProps) {
|
||||
const panes = useTerminalGridStore(selectTerminalGridPanes);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(sizes: number[]) => {
|
||||
onSizeChange(sizes);
|
||||
},
|
||||
[onSizeChange]
|
||||
);
|
||||
|
||||
const validChildren = useMemo(() => {
|
||||
return group.children.filter((child) => {
|
||||
if (isPaneId(child)) {
|
||||
return panes[child] !== undefined;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [group.children, panes]);
|
||||
|
||||
if (validChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Allotment
|
||||
vertical={group.direction === 'vertical'}
|
||||
defaultSizes={group.sizes}
|
||||
onChange={handleChange}
|
||||
className="h-full"
|
||||
>
|
||||
{validChildren.map((child, index) => (
|
||||
<Allotment.Pane key={isPaneId(child) ? child : `group-${index}`} minSize={minSize}>
|
||||
{isPaneId(child) ? (
|
||||
<TerminalPane paneId={child} />
|
||||
) : (
|
||||
<GridGroupRenderer
|
||||
group={child}
|
||||
minSize={minSize}
|
||||
onSizeChange={onSizeChange}
|
||||
/>
|
||||
)}
|
||||
</Allotment.Pane>
|
||||
))}
|
||||
</Allotment>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function TerminalGrid({ className }: { className?: string }) {
|
||||
const layout = useTerminalGridStore(selectTerminalGridLayout);
|
||||
const panes = useTerminalGridStore(selectTerminalGridPanes);
|
||||
const setLayout = useTerminalGridStore((s) => s.setLayout);
|
||||
|
||||
const handleSizeChange = useCallback(
|
||||
(sizes: number[]) => {
|
||||
setLayout({ ...layout, sizes });
|
||||
},
|
||||
[layout, setLayout]
|
||||
);
|
||||
|
||||
const content = useMemo(() => {
|
||||
if (!layout.children || layout.children.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Single pane: render directly without Allotment wrapper
|
||||
if (layout.children.length === 1 && isPaneId(layout.children[0])) {
|
||||
const paneId = layout.children[0];
|
||||
if (!panes[paneId]) return null;
|
||||
return <TerminalPane paneId={paneId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<GridGroupRenderer
|
||||
group={layout}
|
||||
minSize={150}
|
||||
onSizeChange={handleSizeChange}
|
||||
/>
|
||||
);
|
||||
}, [layout, panes, handleSizeChange]);
|
||||
|
||||
return (
|
||||
<div className={cn('h-full w-full overflow-hidden bg-background', className)}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
246
ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
Normal file
246
ccw/frontend/src/components/terminal-dashboard/TerminalPane.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
// ========================================
|
||||
// TerminalPane Component
|
||||
// ========================================
|
||||
// Single terminal pane = PaneToolbar + TerminalInstance.
|
||||
// Renders within the TerminalGrid recursive layout.
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
SplitSquareHorizontal,
|
||||
SplitSquareVertical,
|
||||
Eraser,
|
||||
AlertTriangle,
|
||||
X,
|
||||
Terminal,
|
||||
ChevronDown,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { TerminalInstance } from './TerminalInstance';
|
||||
import {
|
||||
useTerminalGridStore,
|
||||
selectTerminalGridPanes,
|
||||
selectTerminalGridFocusedPaneId,
|
||||
} from '@/stores/terminalGridStore';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectAssociationChain,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import { useCliSessionStore } from '@/stores/cliSessionStore';
|
||||
import { getAllPaneIds } from '@/lib/layout-utils';
|
||||
import type { PaneId } from '@/stores/viewerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== Status Styles ==========
|
||||
|
||||
const statusDotStyles: Record<TerminalStatus, string> = {
|
||||
active: 'bg-green-500',
|
||||
idle: 'bg-gray-400',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
// ========== Props ==========
|
||||
|
||||
interface TerminalPaneProps {
|
||||
paneId: PaneId;
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalPane({ paneId }: TerminalPaneProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
// Grid store
|
||||
const panes = useTerminalGridStore(selectTerminalGridPanes);
|
||||
const focusedPaneId = useTerminalGridStore(selectTerminalGridFocusedPaneId);
|
||||
const layout = useTerminalGridStore((s) => s.layout);
|
||||
const splitPane = useTerminalGridStore((s) => s.splitPane);
|
||||
const closePane = useTerminalGridStore((s) => s.closePane);
|
||||
const assignSession = useTerminalGridStore((s) => s.assignSession);
|
||||
const setFocused = useTerminalGridStore((s) => s.setFocused);
|
||||
|
||||
const pane = panes[paneId];
|
||||
const sessionId = pane?.sessionId ?? null;
|
||||
const isFocused = focusedPaneId === paneId;
|
||||
const canClose = getAllPaneIds(layout).length > 1;
|
||||
|
||||
// Session data
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const sessions = useCliSessionStore((s) => s.sessions);
|
||||
|
||||
// Association chain for linked issue badge
|
||||
const associationChain = useIssueQueueIntegrationStore(selectAssociationChain);
|
||||
const linkedIssueId = useMemo(() => {
|
||||
if (!sessionId || !associationChain) return null;
|
||||
if (associationChain.sessionId === sessionId) return associationChain.issueId;
|
||||
return null;
|
||||
}, [sessionId, associationChain]);
|
||||
|
||||
// Terminal metadata
|
||||
const meta = sessionId ? terminalMetas[sessionId] : null;
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
const alertCount = meta?.alertCount ?? 0;
|
||||
|
||||
// Build session options for dropdown
|
||||
const sessionOptions = useMemo(() => {
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
return allSessionIds.map((sid) => {
|
||||
const s = sessions[sid];
|
||||
const name = s ? (s.tool ? `${s.tool} - ${s.shellKind}` : s.shellKind) : sid;
|
||||
return { id: sid, name };
|
||||
});
|
||||
}, [groups, sessions]);
|
||||
|
||||
// Handlers
|
||||
const handleFocus = useCallback(() => {
|
||||
setFocused(paneId);
|
||||
}, [paneId, setFocused]);
|
||||
|
||||
const handleSplitH = useCallback(() => {
|
||||
splitPane(paneId, 'horizontal');
|
||||
}, [paneId, splitPane]);
|
||||
|
||||
const handleSplitV = useCallback(() => {
|
||||
splitPane(paneId, 'vertical');
|
||||
}, [paneId, splitPane]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
closePane(paneId);
|
||||
}, [paneId, closePane]);
|
||||
|
||||
const handleSessionChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const value = e.target.value;
|
||||
assignSession(paneId, value || null);
|
||||
},
|
||||
[paneId, assignSession]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
// Clear is handled by re-assigning the same session (triggers reset in TerminalInstance)
|
||||
if (sessionId) {
|
||||
assignSession(paneId, null);
|
||||
// Use microtask to re-assign after clearing
|
||||
queueMicrotask(() => assignSession(paneId, sessionId));
|
||||
}
|
||||
}, [paneId, sessionId, assignSession]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col h-full border border-border/50 rounded-sm overflow-hidden',
|
||||
isFocused && 'ring-1 ring-primary/40'
|
||||
)}
|
||||
onClick={handleFocus}
|
||||
>
|
||||
{/* PaneToolbar */}
|
||||
<div className="flex items-center gap-1 px-2 py-1 border-b border-border bg-muted/30 shrink-0">
|
||||
{/* Left: Session selector + status */}
|
||||
<div className="flex items-center gap-1.5 min-w-0 flex-1">
|
||||
{sessionId && (
|
||||
<span
|
||||
className={cn('w-2 h-2 rounded-full shrink-0', statusDotStyles[status])}
|
||||
/>
|
||||
)}
|
||||
<div className="relative min-w-0 flex-1 max-w-[180px]">
|
||||
<select
|
||||
value={sessionId ?? ''}
|
||||
onChange={handleSessionChange}
|
||||
className={cn(
|
||||
'w-full text-xs bg-transparent border-none outline-none cursor-pointer',
|
||||
'appearance-none pr-5 truncate',
|
||||
!sessionId && 'text-muted-foreground'
|
||||
)}
|
||||
>
|
||||
<option value="">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
|
||||
</option>
|
||||
{sessionOptions.map((opt) => (
|
||||
<option key={opt.id} value={opt.id}>
|
||||
{opt.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<ChevronDown className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground pointer-events-none" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center: Linked issue badge */}
|
||||
{linkedIssueId && (
|
||||
<span className="text-[10px] font-mono px-1.5 py-0.5 rounded bg-primary/10 text-primary shrink-0">
|
||||
{linkedIssueId}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Right: Action buttons */}
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={handleSplitH}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
title={formatMessage({ id: 'terminalDashboard.pane.splitHorizontal' })}
|
||||
>
|
||||
<SplitSquareHorizontal className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSplitV}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
title={formatMessage({ id: 'terminalDashboard.pane.splitVertical' })}
|
||||
>
|
||||
<SplitSquareVertical className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{sessionId && (
|
||||
<button
|
||||
onClick={handleClear}
|
||||
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
|
||||
title={formatMessage({ id: 'terminalDashboard.pane.clearTerminal' })}
|
||||
>
|
||||
<Eraser className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
{alertCount > 0 && (
|
||||
<span className="flex items-center gap-0.5 px-1 text-destructive">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
<span className="text-[10px] font-semibold tabular-nums">
|
||||
{alertCount > 99 ? '99+' : alertCount}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
{canClose && (
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="p-1 rounded hover:bg-destructive/10 transition-colors text-muted-foreground hover:text-destructive"
|
||||
title={formatMessage({ id: 'terminalDashboard.pane.closePane' })}
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal content */}
|
||||
{sessionId ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalInstance sessionId={sessionId} />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSession' })}
|
||||
</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.pane.selectSessionHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
// ========================================
|
||||
// TerminalTabBar Component
|
||||
// ========================================
|
||||
// Horizontal tab strip for terminal sessions in the Terminal Dashboard.
|
||||
// Renders tabs from sessionManagerStore groups with status indicators and alert badges.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Terminal, AlertTriangle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectSessionManagerActiveTerminalId,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
|
||||
// ========== Status Styles ==========
|
||||
|
||||
const statusDotStyles: Record<TerminalStatus, string> = {
|
||||
active: 'bg-green-500',
|
||||
idle: 'bg-gray-400',
|
||||
error: 'bg-red-500',
|
||||
};
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalTabBar() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const setActiveTerminal = useSessionManagerStore((s) => s.setActiveTerminal);
|
||||
|
||||
// Flatten all sessionIds from all groups
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
|
||||
// Total alerts across all terminals
|
||||
const totalAlerts = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const meta of Object.values(terminalMetas)) {
|
||||
count += meta.alertCount;
|
||||
}
|
||||
return count;
|
||||
}, [terminalMetas]);
|
||||
|
||||
if (allSessionIds.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-muted/30 min-h-[40px]">
|
||||
<Terminal className="w-3.5 h-3.5 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.tabBar.noTabs' })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center border-b border-border bg-muted/30 overflow-x-auto shrink-0">
|
||||
{allSessionIds.map((sessionId) => {
|
||||
const meta = terminalMetas[sessionId];
|
||||
const title = meta?.title ?? sessionId;
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
const alertCount = meta?.alertCount ?? 0;
|
||||
const isActive = activeTerminalId === sessionId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={sessionId}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-2 text-xs border-r border-border',
|
||||
'whitespace-nowrap transition-colors hover:bg-accent/50',
|
||||
isActive
|
||||
? 'bg-background text-foreground border-b-2 border-b-primary'
|
||||
: 'text-muted-foreground'
|
||||
)}
|
||||
onClick={() => setActiveTerminal(sessionId)}
|
||||
title={title}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
className={cn(
|
||||
'w-2 h-2 rounded-full shrink-0',
|
||||
statusDotStyles[status]
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Title */}
|
||||
<span className="truncate max-w-[120px]">{title}</span>
|
||||
|
||||
{/* Alert badge */}
|
||||
{alertCount > 0 && (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-[10px] font-medium leading-none rounded-full bg-destructive text-destructive-foreground shrink-0">
|
||||
{alertCount > 99 ? '99+' : alertCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Total alerts indicator at right end */}
|
||||
{totalAlerts > 0 && (
|
||||
<div className="ml-auto flex items-center gap-1 px-3 py-2 shrink-0 text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />
|
||||
<span className="text-[10px] font-semibold tabular-nums">
|
||||
{totalAlerts > 99 ? '99+' : totalAlerts}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
// ========================================
|
||||
// TerminalWorkbench Component
|
||||
// ========================================
|
||||
// Container for the right panel of the Terminal Dashboard.
|
||||
// Combines TerminalTabBar (tab switching) and TerminalInstance (xterm.js).
|
||||
// When no terminal is active, shows selected issue detail preview
|
||||
// or a compact empty state with action hints.
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import {
|
||||
Terminal,
|
||||
CircleDot,
|
||||
Tag,
|
||||
Clock,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectSessionManagerActiveTerminalId,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import {
|
||||
useIssueQueueIntegrationStore,
|
||||
selectSelectedIssueId,
|
||||
} from '@/stores/issueQueueIntegrationStore';
|
||||
import { useIssues } from '@/hooks/useIssues';
|
||||
import type { Issue } from '@/lib/api';
|
||||
import { TerminalTabBar } from './TerminalTabBar';
|
||||
import { TerminalInstance } from './TerminalInstance';
|
||||
|
||||
// ========== Priority Styles ==========
|
||||
|
||||
const PRIORITY_VARIANT: Record<Issue['priority'], 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
||||
critical: 'destructive',
|
||||
high: 'warning',
|
||||
medium: 'info',
|
||||
low: 'secondary',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<Issue['status'], string> = {
|
||||
open: 'text-info',
|
||||
in_progress: 'text-warning',
|
||||
resolved: 'text-success',
|
||||
closed: 'text-muted-foreground',
|
||||
completed: 'text-success',
|
||||
};
|
||||
|
||||
// ========== Issue Detail Preview ==========
|
||||
|
||||
function IssueDetailPreview({ issue }: { issue: Issue }) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="max-w-lg mx-auto space-y-4">
|
||||
{/* Header hint */}
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
{formatMessage({ id: 'terminalDashboard.workbench.issuePreview' })}
|
||||
</p>
|
||||
|
||||
{/* Title + Status */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-start gap-2">
|
||||
<CircleDot className={cn('w-4 h-4 shrink-0 mt-0.5', STATUS_COLORS[issue.status] ?? 'text-muted-foreground')} />
|
||||
<h3 className="text-base font-semibold text-foreground leading-snug">
|
||||
{issue.title}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 pl-6">
|
||||
<Badge variant={PRIORITY_VARIANT[issue.priority]} className="text-[10px] px-1.5 py-0">
|
||||
{issue.priority}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-muted-foreground font-mono">{issue.id}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context / Description */}
|
||||
{issue.context && (
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<p className="text-xs text-foreground/80 leading-relaxed whitespace-pre-wrap">
|
||||
{issue.context}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata rows */}
|
||||
<div className="space-y-1.5 text-xs text-muted-foreground">
|
||||
{issue.labels && issue.labels.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="w-3.5 h-3.5 shrink-0" />
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{issue.labels.map((label) => (
|
||||
<span key={label} className="px-1.5 py-0.5 rounded bg-muted text-[10px]">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{issue.assignee && (
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{issue.assignee}</span>
|
||||
</div>
|
||||
)}
|
||||
{issue.createdAt && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-3.5 h-3.5 shrink-0" />
|
||||
<span>{new Date(issue.createdAt).toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hint */}
|
||||
<p className="text-[10px] text-muted-foreground/60 pt-2">
|
||||
{formatMessage({ id: 'terminalDashboard.workbench.issuePreviewHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Component ==========
|
||||
|
||||
export function TerminalWorkbench() {
|
||||
const { formatMessage } = useIntl();
|
||||
const activeTerminalId = useSessionManagerStore(selectSessionManagerActiveTerminalId);
|
||||
const selectedIssueId = useIssueQueueIntegrationStore(selectSelectedIssueId);
|
||||
const { issues } = useIssues();
|
||||
|
||||
// Find selected issue for preview
|
||||
const selectedIssue = useMemo(() => {
|
||||
if (!selectedIssueId) return null;
|
||||
return issues.find((i) => i.id === selectedIssueId) ?? null;
|
||||
}, [selectedIssueId, issues]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tab strip (fixed height) */}
|
||||
<TerminalTabBar />
|
||||
|
||||
{/* Terminal content (flex-1, takes remaining space) */}
|
||||
{activeTerminalId ? (
|
||||
<div className="flex-1 min-h-0">
|
||||
<TerminalInstance sessionId={activeTerminalId} />
|
||||
</div>
|
||||
) : selectedIssue ? (
|
||||
/* Issue detail preview when no terminal but issue is selected */
|
||||
<IssueDetailPreview issue={selectedIssue} />
|
||||
) : (
|
||||
/* Compact empty state */
|
||||
<div className="flex-1 flex items-center justify-center text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Terminal className="h-6 w-6 mx-auto mb-1.5 opacity-30" />
|
||||
<p className="text-sm font-medium">
|
||||
{formatMessage({ id: 'terminalDashboard.workbench.noTerminal' })}
|
||||
</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
{formatMessage({ id: 'terminalDashboard.workbench.noTerminalHint' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
ccw/frontend/src/lib/layout-utils.ts
Normal file
214
ccw/frontend/src/lib/layout-utils.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
// ========================================
|
||||
// Layout Utilities
|
||||
// ========================================
|
||||
// Pure functions for manipulating Allotment layout trees.
|
||||
// Extracted from viewerStore for reuse across terminal grid and CLI viewer.
|
||||
|
||||
import type { AllotmentLayoutGroup, PaneId } from '@/stores/viewerStore';
|
||||
|
||||
/**
|
||||
* Check if a layout child is a PaneId (string) or a nested group
|
||||
*/
|
||||
export function isPaneId(value: PaneId | AllotmentLayoutGroup): value is PaneId {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a pane ID in the layout tree
|
||||
*/
|
||||
export function findPaneInLayout(
|
||||
layout: AllotmentLayoutGroup,
|
||||
paneId: PaneId
|
||||
): { found: boolean; parent: AllotmentLayoutGroup | null; index: number } {
|
||||
const search = (
|
||||
group: AllotmentLayoutGroup,
|
||||
_parent: AllotmentLayoutGroup | null
|
||||
): { found: boolean; parent: AllotmentLayoutGroup | null; index: number } => {
|
||||
for (let i = 0; i < group.children.length; i++) {
|
||||
const child = group.children[i];
|
||||
if (isPaneId(child)) {
|
||||
if (child === paneId) {
|
||||
return { found: true, parent: group, index: i };
|
||||
}
|
||||
} else {
|
||||
const result = search(child, group);
|
||||
if (result.found) {
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { found: false, parent: null, index: -1 };
|
||||
};
|
||||
|
||||
return search(layout, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a pane from layout and clean up empty groups
|
||||
*/
|
||||
export function removePaneFromLayout(
|
||||
layout: AllotmentLayoutGroup,
|
||||
paneId: PaneId
|
||||
): AllotmentLayoutGroup {
|
||||
const removeFromGroup = (group: AllotmentLayoutGroup): AllotmentLayoutGroup | null => {
|
||||
const newChildren: (PaneId | AllotmentLayoutGroup)[] = [];
|
||||
|
||||
for (const child of group.children) {
|
||||
if (isPaneId(child)) {
|
||||
if (child !== paneId) {
|
||||
newChildren.push(child);
|
||||
}
|
||||
} else {
|
||||
const cleanedChild = removeFromGroup(child);
|
||||
if (cleanedChild && cleanedChild.children.length > 0) {
|
||||
if (cleanedChild.children.length === 1) {
|
||||
newChildren.push(cleanedChild.children[0]);
|
||||
} else {
|
||||
newChildren.push(cleanedChild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newChildren.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const newSizes = group.sizes
|
||||
? group.sizes.filter((_, i) => {
|
||||
const child = group.children[i];
|
||||
return !isPaneId(child) || child !== paneId;
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const normalizedSizes = newSizes
|
||||
? (() => {
|
||||
const total = newSizes.reduce((sum, s) => sum + s, 0);
|
||||
return total > 0 ? newSizes.map((s) => (s / total) * 100) : undefined;
|
||||
})()
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
direction: group.direction,
|
||||
sizes: normalizedSizes,
|
||||
children: newChildren,
|
||||
};
|
||||
};
|
||||
|
||||
const result = removeFromGroup(layout);
|
||||
return result || { direction: 'horizontal', children: [] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a pane to the layout at a specific position
|
||||
*/
|
||||
export function addPaneToLayout(
|
||||
layout: AllotmentLayoutGroup,
|
||||
newPaneId: PaneId,
|
||||
parentPaneId?: PaneId,
|
||||
direction: 'horizontal' | 'vertical' = 'horizontal'
|
||||
): AllotmentLayoutGroup {
|
||||
if (!parentPaneId) {
|
||||
if (layout.children.length === 0) {
|
||||
return {
|
||||
...layout,
|
||||
children: [newPaneId],
|
||||
sizes: [100],
|
||||
};
|
||||
}
|
||||
|
||||
if (layout.direction === direction) {
|
||||
const currentSizes = layout.sizes || layout.children.map(() => 100 / layout.children.length);
|
||||
const totalSize = currentSizes.reduce((sum, s) => sum + s, 0);
|
||||
const newSize = totalSize / (layout.children.length + 1);
|
||||
const scaleFactor = (totalSize - newSize) / totalSize;
|
||||
|
||||
return {
|
||||
...layout,
|
||||
children: [...layout.children, newPaneId],
|
||||
sizes: [...currentSizes.map((s) => s * scaleFactor), newSize],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
direction,
|
||||
sizes: [50, 50],
|
||||
children: [layout, newPaneId],
|
||||
};
|
||||
}
|
||||
|
||||
const addRelativeTo = (group: AllotmentLayoutGroup): AllotmentLayoutGroup => {
|
||||
const newChildren: (PaneId | AllotmentLayoutGroup)[] = [];
|
||||
let newSizes: number[] | undefined = group.sizes ? [] : undefined;
|
||||
|
||||
for (let i = 0; i < group.children.length; i++) {
|
||||
const child = group.children[i];
|
||||
const childSize = group.sizes ? group.sizes[i] : undefined;
|
||||
|
||||
if (isPaneId(child)) {
|
||||
if (child === parentPaneId) {
|
||||
if (group.direction === direction) {
|
||||
const halfSize = (childSize || 50) / 2;
|
||||
newChildren.push(child, newPaneId);
|
||||
if (newSizes) {
|
||||
newSizes.push(halfSize, halfSize);
|
||||
}
|
||||
} else {
|
||||
const newGroup: AllotmentLayoutGroup = {
|
||||
direction,
|
||||
sizes: [50, 50],
|
||||
children: [child, newPaneId],
|
||||
};
|
||||
newChildren.push(newGroup);
|
||||
if (newSizes && childSize !== undefined) {
|
||||
newSizes.push(childSize);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
newChildren.push(child);
|
||||
if (newSizes && childSize !== undefined) {
|
||||
newSizes.push(childSize);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const result = findPaneInLayout(child, parentPaneId);
|
||||
if (result.found) {
|
||||
newChildren.push(addRelativeTo(child));
|
||||
} else {
|
||||
newChildren.push(child);
|
||||
}
|
||||
if (newSizes && childSize !== undefined) {
|
||||
newSizes.push(childSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...group,
|
||||
children: newChildren,
|
||||
sizes: newSizes,
|
||||
};
|
||||
};
|
||||
|
||||
return addRelativeTo(layout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pane IDs from layout
|
||||
*/
|
||||
export function getAllPaneIds(layout: AllotmentLayoutGroup): PaneId[] {
|
||||
const paneIds: PaneId[] = [];
|
||||
|
||||
const traverse = (group: AllotmentLayoutGroup) => {
|
||||
for (const child of group.children) {
|
||||
if (isPaneId(child)) {
|
||||
paneIds.push(child);
|
||||
} else {
|
||||
traverse(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
traverse(layout);
|
||||
return paneIds;
|
||||
}
|
||||
@@ -68,6 +68,25 @@
|
||||
"blocked": "Blocked"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"sessions": "Sessions",
|
||||
"issues": "Issues",
|
||||
"queue": "Queue",
|
||||
"inspector": "Inspector",
|
||||
"layoutSingle": "Single",
|
||||
"layoutSplitH": "Split Horizontal",
|
||||
"layoutSplitV": "Split Vertical",
|
||||
"layoutGrid": "Grid 2×2"
|
||||
},
|
||||
"pane": {
|
||||
"selectSession": "Select a session",
|
||||
"selectSessionHint": "Choose a terminal session from the dropdown",
|
||||
"splitHorizontal": "Split Right",
|
||||
"splitVertical": "Split Down",
|
||||
"clearTerminal": "Clear Terminal",
|
||||
"closePane": "Close Pane",
|
||||
"linkedIssue": "Linked Issue"
|
||||
},
|
||||
"tabBar": {
|
||||
"noTabs": "No terminal sessions"
|
||||
},
|
||||
|
||||
@@ -68,6 +68,25 @@
|
||||
"blocked": "已阻塞"
|
||||
}
|
||||
},
|
||||
"toolbar": {
|
||||
"sessions": "会话",
|
||||
"issues": "问题",
|
||||
"queue": "队列",
|
||||
"inspector": "检查器",
|
||||
"layoutSingle": "单窗格",
|
||||
"layoutSplitH": "左右分割",
|
||||
"layoutSplitV": "上下分割",
|
||||
"layoutGrid": "2×2 网格"
|
||||
},
|
||||
"pane": {
|
||||
"selectSession": "选择会话",
|
||||
"selectSessionHint": "从下拉菜单中选择终端会话",
|
||||
"splitHorizontal": "向右分割",
|
||||
"splitVertical": "向下分割",
|
||||
"clearTerminal": "清屏",
|
||||
"closePane": "关闭窗格",
|
||||
"linkedIssue": "关联问题"
|
||||
},
|
||||
"tabBar": {
|
||||
"noTabs": "暂无终端会话"
|
||||
},
|
||||
|
||||
@@ -1,104 +1,98 @@
|
||||
// ========================================
|
||||
// Terminal Dashboard Page
|
||||
// Terminal Dashboard Page (V2)
|
||||
// ========================================
|
||||
// Three-column Allotment layout for terminal execution management.
|
||||
// Left: session groups + agent list (with active session count badge)
|
||||
// Middle: full-height IssuePanel
|
||||
// Right: terminal workbench (or issue detail preview)
|
||||
// Bottom: collapsible BottomPanel (Queue + Inspector tabs)
|
||||
// Cross-cutting: AssociationHighlightProvider wraps the layout
|
||||
// Terminal-first layout with floating panels.
|
||||
// Main area: TerminalGrid (tmux-style split panes)
|
||||
// Top: DashboardToolbar with panel toggles and layout presets
|
||||
// Floating panels: Sessions, Issues, Queue, Inspector (overlay, mutually exclusive)
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Allotment } from 'allotment';
|
||||
import 'allotment/dist/style.css';
|
||||
import { FolderTree, Activity } from 'lucide-react';
|
||||
import { AssociationHighlightProvider } from '@/components/terminal-dashboard/AssociationHighlight';
|
||||
import { DashboardToolbar, type PanelId } from '@/components/terminal-dashboard/DashboardToolbar';
|
||||
import { TerminalGrid } from '@/components/terminal-dashboard/TerminalGrid';
|
||||
import { FloatingPanel } from '@/components/terminal-dashboard/FloatingPanel';
|
||||
import { SessionGroupTree } from '@/components/terminal-dashboard/SessionGroupTree';
|
||||
import { AgentList } from '@/components/terminal-dashboard/AgentList';
|
||||
import { IssuePanel } from '@/components/terminal-dashboard/IssuePanel';
|
||||
import { TerminalWorkbench } from '@/components/terminal-dashboard/TerminalWorkbench';
|
||||
import { BottomPanel } from '@/components/terminal-dashboard/BottomPanel';
|
||||
import { AssociationHighlightProvider } from '@/components/terminal-dashboard/AssociationHighlight';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import {
|
||||
useSessionManagerStore,
|
||||
selectGroups,
|
||||
selectTerminalMetas,
|
||||
} from '@/stores/sessionManagerStore';
|
||||
import type { TerminalStatus } from '@/types/terminal-dashboard';
|
||||
import { QueuePanel } from '@/components/terminal-dashboard/QueuePanel';
|
||||
import { InspectorContent } from '@/components/terminal-dashboard/BottomInspector';
|
||||
|
||||
// ========== Main Page Component ==========
|
||||
|
||||
export function TerminalDashboardPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const groups = useSessionManagerStore(selectGroups);
|
||||
const terminalMetas = useSessionManagerStore(selectTerminalMetas);
|
||||
const [activePanel, setActivePanel] = useState<PanelId | null>(null);
|
||||
|
||||
// Active session count for left column header badge
|
||||
const sessionCount = useMemo(() => {
|
||||
const allSessionIds = groups.flatMap((g) => g.sessionIds);
|
||||
let activeCount = 0;
|
||||
for (const sid of allSessionIds) {
|
||||
const meta = terminalMetas[sid];
|
||||
const status: TerminalStatus = meta?.status ?? 'idle';
|
||||
if (status === 'active') {
|
||||
activeCount++;
|
||||
}
|
||||
}
|
||||
return activeCount > 0 ? activeCount : allSessionIds.length;
|
||||
}, [groups, terminalMetas]);
|
||||
const togglePanel = useCallback((panelId: PanelId) => {
|
||||
setActivePanel((prev) => (prev === panelId ? null : panelId));
|
||||
}, []);
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
setActivePanel(null);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[calc(100vh-56px)] overflow-hidden">
|
||||
{/* AssociationHighlightProvider wraps the three-column layout + bottom panel */}
|
||||
<AssociationHighlightProvider>
|
||||
{/* Three-column Allotment layout (flex-1) */}
|
||||
{/* Global toolbar */}
|
||||
<DashboardToolbar
|
||||
activePanel={activePanel}
|
||||
onTogglePanel={togglePanel}
|
||||
/>
|
||||
|
||||
{/* Terminal grid (flex-1, takes all remaining space) */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<Allotment proportionalLayout={true}>
|
||||
{/* Left column: Sessions + Agents */}
|
||||
<Allotment.Pane preferredSize={220} minSize={180} maxSize={320}>
|
||||
<div className="h-full border-r border-border bg-background flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border shrink-0 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold flex items-center gap-2">
|
||||
<FolderTree className="w-4 h-4" />
|
||||
{formatMessage({ id: 'terminalDashboard.columns.sessions' })}
|
||||
</h2>
|
||||
{sessionCount > 0 && (
|
||||
<Badge variant="secondary" className="text-[10px] px-1.5 py-0 flex items-center gap-1">
|
||||
<Activity className="w-3 h-3" />
|
||||
{sessionCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* SessionGroupTree takes remaining space */}
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionGroupTree />
|
||||
</div>
|
||||
{/* AgentList at bottom with max height */}
|
||||
<div className="shrink-0">
|
||||
<AgentList />
|
||||
</div>
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* Middle column: Full-height IssuePanel */}
|
||||
<Allotment.Pane minSize={280}>
|
||||
<div className="h-full border-r border-border bg-background overflow-hidden">
|
||||
<IssuePanel />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
|
||||
{/* Right column: Terminal Workbench */}
|
||||
<Allotment.Pane minSize={300}>
|
||||
<div className="h-full bg-background overflow-hidden">
|
||||
<TerminalWorkbench />
|
||||
</div>
|
||||
</Allotment.Pane>
|
||||
</Allotment>
|
||||
<TerminalGrid />
|
||||
</div>
|
||||
|
||||
{/* BottomPanel: collapsible Queue + Inspector tabs (full-width) */}
|
||||
<BottomPanel />
|
||||
{/* Floating panels (conditional, overlay) */}
|
||||
<FloatingPanel
|
||||
isOpen={activePanel === 'sessions'}
|
||||
onClose={closePanel}
|
||||
title={formatMessage({ id: 'terminalDashboard.toolbar.sessions' })}
|
||||
side="left"
|
||||
width={280}
|
||||
>
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<SessionGroupTree />
|
||||
</div>
|
||||
<div className="shrink-0">
|
||||
<AgentList />
|
||||
</div>
|
||||
</div>
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
isOpen={activePanel === 'issues'}
|
||||
onClose={closePanel}
|
||||
title={formatMessage({ id: 'terminalDashboard.toolbar.issues' })}
|
||||
side="left"
|
||||
width={380}
|
||||
>
|
||||
<IssuePanel />
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
isOpen={activePanel === 'queue'}
|
||||
onClose={closePanel}
|
||||
title={formatMessage({ id: 'terminalDashboard.toolbar.queue' })}
|
||||
side="right"
|
||||
width={400}
|
||||
>
|
||||
<QueuePanel />
|
||||
</FloatingPanel>
|
||||
|
||||
<FloatingPanel
|
||||
isOpen={activePanel === 'inspector'}
|
||||
onClose={closePanel}
|
||||
title={formatMessage({ id: 'terminalDashboard.toolbar.inspector' })}
|
||||
side="right"
|
||||
width={360}
|
||||
>
|
||||
<InspectorContent />
|
||||
</FloatingPanel>
|
||||
</AssociationHighlightProvider>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -275,6 +275,22 @@ export type {
|
||||
MonitorAlert,
|
||||
} from '../types/terminal-dashboard';
|
||||
|
||||
// Terminal Grid Store
|
||||
export {
|
||||
useTerminalGridStore,
|
||||
selectTerminalGridLayout,
|
||||
selectTerminalGridPanes,
|
||||
selectTerminalGridFocusedPaneId,
|
||||
selectTerminalPane,
|
||||
} from './terminalGridStore';
|
||||
|
||||
export type {
|
||||
TerminalPaneState,
|
||||
TerminalGridState,
|
||||
TerminalGridActions,
|
||||
TerminalGridStore,
|
||||
} from './terminalGridStore';
|
||||
|
||||
// Issue Queue Integration Store Types
|
||||
export type {
|
||||
AssociationChain,
|
||||
|
||||
247
ccw/frontend/src/stores/terminalGridStore.ts
Normal file
247
ccw/frontend/src/stores/terminalGridStore.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
// ========================================
|
||||
// Terminal Grid Store
|
||||
// ========================================
|
||||
// Zustand store for terminal grid layout state.
|
||||
// Manages tmux-style split pane layout where each pane holds a terminal session.
|
||||
// Reuses AllotmentLayoutGroup tree structure and pure layout functions from layout-utils.
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { AllotmentLayoutGroup, PaneId } from './viewerStore';
|
||||
import {
|
||||
addPaneToLayout,
|
||||
removePaneFromLayout,
|
||||
getAllPaneIds,
|
||||
} from '@/lib/layout-utils';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface TerminalPaneState {
|
||||
id: PaneId;
|
||||
/** Bound terminal session key (null = empty pane awaiting assignment) */
|
||||
sessionId: string | null;
|
||||
}
|
||||
|
||||
export interface TerminalGridState {
|
||||
layout: AllotmentLayoutGroup;
|
||||
panes: Record<PaneId, TerminalPaneState>;
|
||||
focusedPaneId: PaneId | null;
|
||||
nextPaneIdCounter: number;
|
||||
}
|
||||
|
||||
export interface TerminalGridActions {
|
||||
setLayout: (layout: AllotmentLayoutGroup) => void;
|
||||
splitPane: (paneId: PaneId, direction: 'horizontal' | 'vertical') => PaneId;
|
||||
closePane: (paneId: PaneId) => void;
|
||||
assignSession: (paneId: PaneId, sessionId: string | null) => void;
|
||||
setFocused: (paneId: PaneId) => void;
|
||||
resetLayout: (preset: 'single' | 'split-h' | 'split-v' | 'grid-2x2') => void;
|
||||
}
|
||||
|
||||
export type TerminalGridStore = TerminalGridState & TerminalGridActions;
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const GRID_STORAGE_KEY = 'terminal-grid-storage';
|
||||
const GRID_STORAGE_VERSION = 1;
|
||||
|
||||
// ========== Helpers ==========
|
||||
|
||||
const generatePaneId = (counter: number): PaneId => `tpane-${counter}`;
|
||||
|
||||
// ========== Initial State ==========
|
||||
|
||||
function createInitialLayout(): { layout: AllotmentLayoutGroup; panes: Record<PaneId, TerminalPaneState>; focusedPaneId: PaneId; nextPaneIdCounter: number } {
|
||||
const paneId = generatePaneId(1);
|
||||
return {
|
||||
layout: { direction: 'horizontal', sizes: [100], children: [paneId] },
|
||||
panes: { [paneId]: { id: paneId, sessionId: null } },
|
||||
focusedPaneId: paneId,
|
||||
nextPaneIdCounter: 2,
|
||||
};
|
||||
}
|
||||
|
||||
const initial = createInitialLayout();
|
||||
|
||||
const initialState: TerminalGridState = {
|
||||
layout: initial.layout,
|
||||
panes: initial.panes,
|
||||
focusedPaneId: initial.focusedPaneId,
|
||||
nextPaneIdCounter: initial.nextPaneIdCounter,
|
||||
};
|
||||
|
||||
// ========== Store ==========
|
||||
|
||||
export const useTerminalGridStore = create<TerminalGridStore>()(
|
||||
persist(
|
||||
devtools(
|
||||
(set, get) => ({
|
||||
...initialState,
|
||||
|
||||
setLayout: (layout) => {
|
||||
set({ layout }, false, 'terminalGrid/setLayout');
|
||||
},
|
||||
|
||||
splitPane: (paneId, direction) => {
|
||||
const state = get();
|
||||
const newPaneId = generatePaneId(state.nextPaneIdCounter);
|
||||
const newLayout = addPaneToLayout(state.layout, newPaneId, paneId, direction);
|
||||
|
||||
set(
|
||||
{
|
||||
layout: newLayout,
|
||||
panes: {
|
||||
...state.panes,
|
||||
[newPaneId]: { id: newPaneId, sessionId: null },
|
||||
},
|
||||
focusedPaneId: newPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter + 1,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/splitPane'
|
||||
);
|
||||
|
||||
return newPaneId;
|
||||
},
|
||||
|
||||
closePane: (paneId) => {
|
||||
const state = get();
|
||||
const allPaneIds = getAllPaneIds(state.layout);
|
||||
if (allPaneIds.length <= 1) return;
|
||||
|
||||
const newLayout = removePaneFromLayout(state.layout, paneId);
|
||||
const newPanes = { ...state.panes };
|
||||
delete newPanes[paneId];
|
||||
|
||||
let newFocused = state.focusedPaneId;
|
||||
if (newFocused === paneId) {
|
||||
const remaining = getAllPaneIds(newLayout);
|
||||
newFocused = remaining.length > 0 ? remaining[0] : null;
|
||||
}
|
||||
|
||||
set(
|
||||
{
|
||||
layout: newLayout,
|
||||
panes: newPanes,
|
||||
focusedPaneId: newFocused,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/closePane'
|
||||
);
|
||||
},
|
||||
|
||||
assignSession: (paneId, sessionId) => {
|
||||
const state = get();
|
||||
const pane = state.panes[paneId];
|
||||
if (!pane) return;
|
||||
|
||||
set(
|
||||
{
|
||||
panes: {
|
||||
...state.panes,
|
||||
[paneId]: { ...pane, sessionId },
|
||||
},
|
||||
},
|
||||
false,
|
||||
'terminalGrid/assignSession'
|
||||
);
|
||||
},
|
||||
|
||||
setFocused: (paneId) => {
|
||||
const state = get();
|
||||
if (!state.panes[paneId]) return;
|
||||
set({ focusedPaneId: paneId }, false, 'terminalGrid/setFocused');
|
||||
},
|
||||
|
||||
resetLayout: (preset) => {
|
||||
let counter = get().nextPaneIdCounter;
|
||||
|
||||
const createPane = (): TerminalPaneState => {
|
||||
const id = generatePaneId(counter++);
|
||||
return { id, sessionId: null };
|
||||
};
|
||||
|
||||
let layout: AllotmentLayoutGroup;
|
||||
const panes: Record<PaneId, TerminalPaneState> = {};
|
||||
|
||||
switch (preset) {
|
||||
case 'single': {
|
||||
const p = createPane();
|
||||
panes[p.id] = p;
|
||||
layout = { direction: 'horizontal', sizes: [100], children: [p.id] };
|
||||
break;
|
||||
}
|
||||
case 'split-h': {
|
||||
const p1 = createPane();
|
||||
const p2 = createPane();
|
||||
panes[p1.id] = p1;
|
||||
panes[p2.id] = p2;
|
||||
layout = { direction: 'horizontal', sizes: [50, 50], children: [p1.id, p2.id] };
|
||||
break;
|
||||
}
|
||||
case 'split-v': {
|
||||
const p1 = createPane();
|
||||
const p2 = createPane();
|
||||
panes[p1.id] = p1;
|
||||
panes[p2.id] = p2;
|
||||
layout = { direction: 'vertical', sizes: [50, 50], children: [p1.id, p2.id] };
|
||||
break;
|
||||
}
|
||||
case 'grid-2x2': {
|
||||
const p1 = createPane();
|
||||
const p2 = createPane();
|
||||
const p3 = createPane();
|
||||
const p4 = createPane();
|
||||
panes[p1.id] = p1;
|
||||
panes[p2.id] = p2;
|
||||
panes[p3.id] = p3;
|
||||
panes[p4.id] = p4;
|
||||
layout = {
|
||||
direction: 'vertical',
|
||||
sizes: [50, 50],
|
||||
children: [
|
||||
{ direction: 'horizontal', sizes: [50, 50], children: [p1.id, p2.id] },
|
||||
{ direction: 'horizontal', sizes: [50, 50], children: [p3.id, p4.id] },
|
||||
],
|
||||
};
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const firstPaneId = Object.keys(panes)[0] || null;
|
||||
set(
|
||||
{
|
||||
layout,
|
||||
panes,
|
||||
focusedPaneId: firstPaneId,
|
||||
nextPaneIdCounter: counter,
|
||||
},
|
||||
false,
|
||||
'terminalGrid/resetLayout'
|
||||
);
|
||||
},
|
||||
}),
|
||||
{ name: 'TerminalGridStore' }
|
||||
),
|
||||
{
|
||||
name: GRID_STORAGE_KEY,
|
||||
version: GRID_STORAGE_VERSION,
|
||||
partialize: (state) => ({
|
||||
layout: state.layout,
|
||||
panes: state.panes,
|
||||
focusedPaneId: state.focusedPaneId,
|
||||
nextPaneIdCounter: state.nextPaneIdCounter,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
// ========== Selectors ==========
|
||||
|
||||
export const selectTerminalGridLayout = (state: TerminalGridStore) => state.layout;
|
||||
export const selectTerminalGridPanes = (state: TerminalGridStore) => state.panes;
|
||||
export const selectTerminalGridFocusedPaneId = (state: TerminalGridStore) => state.focusedPaneId;
|
||||
export const selectTerminalPane = (paneId: PaneId) => (state: TerminalGridStore) =>
|
||||
state.panes[paneId];
|
||||
Reference in New Issue
Block a user