|
| 1 | +import { lazy, Suspense, useState } from 'react'; |
| 2 | +import { X, FileText, Network, CreditCard } from 'lucide-react'; |
| 3 | +import { useGraph } from '../contexts/GraphContext'; |
| 4 | +import { getTypeConfig, getStatusConfig } from '../constants/workItemConstants'; |
| 5 | +import type { WorkItemType } from '../constants/workItemConstants'; |
| 6 | +import { NodeSubgraphPreview } from './NodeSubgraphPreview'; |
| 7 | + |
| 8 | +// Heavy (markdown + Prism) — lazy so it's out of the main bundle until a node's |
| 9 | +// contents are first opened. |
| 10 | +const NodeContentRenderer = lazy(() => import('./NodeContentRenderer')); |
| 11 | + |
| 12 | +type Mode = 'card' | 'contents' | 'diagram'; |
| 13 | + |
| 14 | +interface NodeInspectorProps { |
| 15 | + node: any; |
| 16 | + onClose: () => void; |
| 17 | +} |
| 18 | + |
| 19 | +/** |
| 20 | + * Docked inspector: shows the selected node's Card (summary), Contents (its |
| 21 | + * description rendered as readable markdown/code), or Diagram (its sub-graph), |
| 22 | + * each at full legible size regardless of canvas zoom. The mode is an explicit, |
| 23 | + * per-node toggle — not a side effect of zooming in. |
| 24 | + */ |
| 25 | +export function NodeInspector({ node, onClose }: NodeInspectorProps) { |
| 26 | + const { descendInto } = useGraph(); |
| 27 | + const hasSubgraph = !!node?.subgraphId; |
| 28 | + const [modeByNode, setModeByNode] = useState<Record<string, Mode>>({}); |
| 29 | + const mode: Mode = modeByNode[node.id] ?? (node.description ? 'contents' : 'card'); |
| 30 | + const setMode = (m: Mode) => setModeByNode((prev) => ({ ...prev, [node.id]: m })); |
| 31 | + |
| 32 | + const typeCfg = getTypeConfig(node.type as WorkItemType); |
| 33 | + const statusCfg = getStatusConfig(node.status as any); |
| 34 | + |
| 35 | + return ( |
| 36 | + <div |
| 37 | + data-testid="node-inspector" |
| 38 | + className="h-full flex flex-col bg-gray-900/95 backdrop-blur-sm border-l border-gray-700/60 w-full" |
| 39 | + > |
| 40 | + {/* Header */} |
| 41 | + <div className="flex items-start gap-2 p-3 border-b border-gray-700/60"> |
| 42 | + <div className="flex-1 min-w-0"> |
| 43 | + <div className="text-[10px] uppercase tracking-wide" style={{ color: typeCfg.hexColor }}>{typeCfg.label}</div> |
| 44 | + <div className="text-sm font-semibold text-white truncate" title={node.title}>{node.title}</div> |
| 45 | + </div> |
| 46 | + <button onClick={onClose} className="p-1 text-gray-400 hover:text-white rounded hover:bg-gray-700/50" title="Close"> |
| 47 | + <X className="h-4 w-4" /> |
| 48 | + </button> |
| 49 | + </div> |
| 50 | + |
| 51 | + {/* Mode toggle */} |
| 52 | + <div className="flex gap-1 p-2 border-b border-gray-700/60"> |
| 53 | + <ModeBtn active={mode === 'card'} onClick={() => setMode('card')} icon={<CreditCard className="h-3.5 w-3.5" />} label="Card" /> |
| 54 | + <ModeBtn active={mode === 'contents'} onClick={() => setMode('contents')} icon={<FileText className="h-3.5 w-3.5" />} label="Contents" /> |
| 55 | + <ModeBtn active={mode === 'diagram'} onClick={() => setMode('diagram')} icon={<Network className="h-3.5 w-3.5" />} label="Diagram" disabled={!hasSubgraph} title={hasSubgraph ? 'Sub-graph' : 'No sub-graph'} /> |
| 56 | + </div> |
| 57 | + |
| 58 | + {/* Body */} |
| 59 | + <div className="flex-1 overflow-y-auto"> |
| 60 | + {mode === 'card' && ( |
| 61 | + <div className="p-3 space-y-3 text-sm"> |
| 62 | + <Row label="Type" value={typeCfg.label} color={typeCfg.hexColor} /> |
| 63 | + <Row label="Status" value={statusCfg?.label ?? node.status} color={statusCfg?.hexColor} /> |
| 64 | + {typeof node.priority === 'number' && <Row label="Priority" value={`${Math.round(node.priority * 100)}%`} />} |
| 65 | + {Array.isArray(node.tags) && node.tags.length > 0 && ( |
| 66 | + <div> |
| 67 | + <div className="text-xs text-gray-500 mb-1">Tags</div> |
| 68 | + <div className="flex flex-wrap gap-1"> |
| 69 | + {node.tags.map((t: string) => <span key={t} className="text-[11px] bg-gray-700/60 text-gray-200 rounded px-1.5 py-0.5">{t}</span>)} |
| 70 | + </div> |
| 71 | + </div> |
| 72 | + )} |
| 73 | + {node.description && ( |
| 74 | + <div> |
| 75 | + <div className="text-xs text-gray-500 mb-1">Description (preview)</div> |
| 76 | + <div className="text-xs text-gray-300 line-clamp-3 whitespace-pre-wrap">{node.description}</div> |
| 77 | + <button onClick={() => setMode('contents')} className="text-xs text-blue-400 hover:text-blue-300 mt-1">Read full contents →</button> |
| 78 | + </div> |
| 79 | + )} |
| 80 | + </div> |
| 81 | + )} |
| 82 | + |
| 83 | + {mode === 'contents' && ( |
| 84 | + <div className="p-3"> |
| 85 | + <Suspense fallback={<div className="text-sm text-gray-500">Loading…</div>}> |
| 86 | + <NodeContentRenderer content={node.description ?? ''} /> |
| 87 | + </Suspense> |
| 88 | + </div> |
| 89 | + )} |
| 90 | + |
| 91 | + {mode === 'diagram' && ( |
| 92 | + hasSubgraph ? ( |
| 93 | + <NodeSubgraphPreview |
| 94 | + subgraphId={node.subgraphId} |
| 95 | + subgraphName={node.subgraph?.name} |
| 96 | + onOpen={() => descendInto(node.subgraphId)} |
| 97 | + /> |
| 98 | + ) : ( |
| 99 | + <div className="p-4 text-sm text-gray-500">This node has no sub-graph diagram.</div> |
| 100 | + ) |
| 101 | + )} |
| 102 | + </div> |
| 103 | + </div> |
| 104 | + ); |
| 105 | +} |
| 106 | + |
| 107 | +function ModeBtn({ active, onClick, icon, label, disabled, title }: { active: boolean; onClick: () => void; icon: React.ReactNode; label: string; disabled?: boolean; title?: string }) { |
| 108 | + return ( |
| 109 | + <button |
| 110 | + onClick={onClick} |
| 111 | + disabled={disabled} |
| 112 | + title={title} |
| 113 | + className={`flex-1 flex items-center justify-center gap-1.5 px-2 py-1.5 rounded-lg text-xs font-medium transition-colors ${ |
| 114 | + disabled |
| 115 | + ? 'text-gray-600 cursor-not-allowed' |
| 116 | + : active |
| 117 | + ? 'bg-emerald-500/25 text-emerald-200 border border-emerald-400/40' |
| 118 | + : 'text-gray-300 hover:bg-gray-700/50 border border-transparent' |
| 119 | + }`} |
| 120 | + > |
| 121 | + {icon} |
| 122 | + {label} |
| 123 | + </button> |
| 124 | + ); |
| 125 | +} |
| 126 | + |
| 127 | +function Row({ label, value, color }: { label: string; value: string; color?: string }) { |
| 128 | + return ( |
| 129 | + <div className="flex items-center justify-between"> |
| 130 | + <span className="text-xs text-gray-500">{label}</span> |
| 131 | + <span className="text-sm font-medium" style={color ? { color } : undefined}>{value}</span> |
| 132 | + </div> |
| 133 | + ); |
| 134 | +} |
0 commit comments