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:
catlog22
2026-02-14 22:13:45 +08:00
parent 37d19ada75
commit 75558dc411
28 changed files with 3375 additions and 2598 deletions

View 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>
);
}