diff --git a/app/query.go b/app/query.go index 3eecf08..13752a5 100644 --- a/app/query.go +++ b/app/query.go @@ -52,12 +52,12 @@ func FetchTasks(conn *thingsdb.Conn, scope string) ([]Task, error) { res, err := conn.QueryRaw(scope, ` // TiCode query tasks assert(tasks().len() <= 100, 'too many tasks; use "tasks()" instead'); - tasks().map(|task| { + return tasks().map(|task| { id: task.id(), owner: task.owner(), at: task.at(), error: task.err(), - }); + }), 1; `, nil) if err != nil { return nil, err @@ -194,13 +194,13 @@ func FetchTask(conn *thingsdb.Conn, scope string, taskId uint64) (*TaskDetail, e var taskDetail TaskDetail res, err := conn.QueryRaw(scope, ` task = task(task_id); - { + return { id: task.id(), owner: task.owner(), at: task.at(), error: task.err(), closure: str(task.closure()), - }; + }, 1; `, map[string]any{"task_id": taskId}) if err != nil { return nil, err @@ -216,7 +216,7 @@ func FetchThing(conn *thingsdb.Conn, scope string, thingsId uint64) (any, error) // Thing ID 1 is always the root for containers, except for old ThingsDB // collections. However, in an old ThingsDB collecion there is no "1", // so we can in this case just return the root. - return conn.Query(scope, "root();", nil) + return conn.Query(scope, "return root(), 1;", nil) } - return conn.Query(scope, "thing(id);", map[string]any{"id": thingsId}) + return conn.Query(scope, "return thing(id), 1;", map[string]any{"id": thingsId}) } diff --git a/eslint.config.mjs b/eslint.config.mjs index 6aa4007..28949d9 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,7 +20,7 @@ export default defineConfig([ }, rules: { "semi": ["error", "always"], - "indent": ["error", 2] + "indent": ["error", 2], } }, ]); diff --git a/package-lock.json b/package-lock.json index 21b9635..e3de52e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.3.0", + "elkjs": "^0.11.1", "lossless-json": "^4.3.0", "radix-ui": "^1.4.3", "react": "^19.2.6", @@ -3593,6 +3594,12 @@ "dev": true, "license": "ISC" }, + "node_modules/elkjs": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/elkjs/-/elkjs-0.11.1.tgz", + "integrity": "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==", + "license": "EPL-2.0" + }, "node_modules/enhanced-resolve": { "version": "5.23.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.23.0.tgz", diff --git a/package.json b/package.json index 19cda78..ca94fc2 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-icons": "^1.3.2", "@radix-ui/themes": "^3.3.0", + "elkjs": "^0.11.1", "lossless-json": "^4.3.0", "radix-ui": "^1.4.3", "react": "^19.2.6", diff --git a/src/App.tsx b/src/App.tsx index 5f081ab..bcbda41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -18,7 +18,7 @@ export default function App() { {errorMessage && ( setErrorMessage(null)} + onClear={() => { setErrorMessage(null); }} /> )} @@ -32,7 +32,7 @@ export default function App() { {errorMessage && ( setErrorMessage(null)} + onClear={() => { setErrorMessage(null); }} /> )} diff --git a/src/components/AboutModal.tsx b/src/components/AboutModal.tsx index 8925e14..4f44193 100644 --- a/src/components/AboutModal.tsx +++ b/src/components/AboutModal.tsx @@ -16,7 +16,7 @@ export default function AboutModal({ isOpen, onOpenChange }: AboutModalProps) { e.preventDefault()} + onOpenAutoFocus={(e) => { e.preventDefault(); }} > void; title?: string; description: string; @@ -12,7 +11,6 @@ interface ConfirmDialogProps { } export default function ConfirmDialog({ - open, onOpenChange, title = 'Are you sure?', description, @@ -22,7 +20,7 @@ export default function ConfirmDialog({ onConfirm, }: ConfirmDialogProps) { return ( - + {title} diff --git a/src/components/ConnectionOverlay.tsx b/src/components/ConnectionOverlay.tsx index 4dffc8a..927d2f3 100644 --- a/src/components/ConnectionOverlay.tsx +++ b/src/components/ConnectionOverlay.tsx @@ -21,7 +21,7 @@ export default function ConnectionOverlay() { setShowOverlay(false); }, 500); - return () => clearTimeout(timer); + return () => { clearTimeout(timer); }; }, [status]); if (!showOverlay) return null; diff --git a/src/components/FieldsTab.tsx b/src/components/FieldsTab.tsx index 2258529..5941538 100644 --- a/src/components/FieldsTab.tsx +++ b/src/components/FieldsTab.tsx @@ -66,7 +66,7 @@ export default function FieldsTab({ tp, onNavigateToType }: FieldsTabProps) { setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" m="1" style={{ flexShrink: 0 }} @@ -80,7 +80,7 @@ export default function FieldsTab({ tp, onNavigateToType }: FieldsTabProps) { size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -124,10 +124,10 @@ export default function FieldsTab({ tp, onNavigateToType }: FieldsTabProps) { } as React.CSSProperties} > {filtered.map(([name, definition]) => { - const relation: Relation | undefined = tp.relations?.[name]; + const relation: Relation | undefined = tp.relations[name]; const isComplex = typeof definition !== 'string'; - const isRowOpen = !!expandedFields[name]; + const isRowOpen = expandedFields[name]; const stringifiedThisDef = getDefinitionString(definition); const cardinality = relation ? determineCardinality(stringifiedThisDef, relation.definition) : null; @@ -140,7 +140,7 @@ export default function FieldsTab({ tp, onNavigateToType }: FieldsTabProps) { return ( isComplex && toggleFieldExpand(name)} + onClick={() => { if (isComplex) toggleFieldExpand(name); }} style={{ display: 'grid', gridTemplateColumns: '180px 200px auto', @@ -185,7 +185,7 @@ export default function FieldsTab({ tp, onNavigateToType }: FieldsTabProps) { {relation ? ( - e.stopPropagation()}> + { e.stopPropagation(); }}> {cardinality} @@ -193,7 +193,7 @@ export default function FieldsTab({ tp, onNavigateToType }: FieldsTabProps) { onNavigateToType(relation.type)} + onClick={() => { onNavigateToType(relation.type); }} style={{ cursor: 'pointer', padding: '2px 6px', diff --git a/src/components/MethodsTab.tsx b/src/components/MethodsTab.tsx index 2aa9159..71d21a7 100644 --- a/src/components/MethodsTab.tsx +++ b/src/components/MethodsTab.tsx @@ -32,7 +32,7 @@ export default function MethodsTab({ } return methodNames.filter((name) => { - const nameMatch = name?.toLowerCase().includes(cleanedQuery); + const nameMatch = name.toLowerCase().includes(cleanedQuery); return nameMatch; }); @@ -41,9 +41,9 @@ export default function MethodsTab({ useEffect(() => { if (methodNames.length > 0) { const name = methodNames[0]; - queueMicrotask(() => setSelectedMethodName(name)); + queueMicrotask(() => { setSelectedMethodName(name); }); } else { - queueMicrotask(() => setSelectedMethodName(null)); + queueMicrotask(() => { setSelectedMethodName(null); }); } }, [methodNames]); @@ -58,7 +58,7 @@ export default function MethodsTab({ ); } - const activeMethod: Method | undefined = methods?.[selectedMethodName || '']; + const activeMethod: Method | undefined = methods?.[selectedMethodName ?? '']; return ( @@ -67,7 +67,7 @@ export default function MethodsTab({ setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" style={{ flexShrink: 0 }} > @@ -80,7 +80,7 @@ export default function MethodsTab({ size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -121,7 +121,7 @@ export default function MethodsTab({ return ( setSelectedMethodName(mName)} + onClick={() => { setSelectedMethodName(mName); }} px="2" py="1" style={{ @@ -156,7 +156,7 @@ export default function MethodsTab({ - {selectedMethodName}({activeMethod.arguments?.join(', ') || ''}) + {selectedMethodName}({activeMethod.arguments.join(', ') || ''}) diff --git a/src/components/NewWorkspaceModal.tsx b/src/components/NewWorkspaceModal.tsx index 85db027..bb6ec73 100644 --- a/src/components/NewWorkspaceModal.tsx +++ b/src/components/NewWorkspaceModal.tsx @@ -27,9 +27,7 @@ export default function NewWorkspaceModal() { const [showToken, setShowToken] = useState(false); const sanitizedName = name.replace(/[^a-zA-Z0-9-_ ]/g, ''); - const workfolder = customWorkfolder !== null - ? customWorkfolder - : `~/ThingsCode/${sanitizedName}`; + const workfolder = customWorkfolder ?? `~/ThingsCode/${sanitizedName}`; const resetForm = () => { setName(''); @@ -44,10 +42,10 @@ export default function NewWorkspaceModal() { setWorkspaceType('development'); }; - const handleSubmit = (e: React.ChangeEvent) => { + const handleSubmit = async (e: React.ChangeEvent) => { e.preventDefault(); - addWorkspace({ + await addWorkspace({ name, host, port, @@ -92,7 +90,7 @@ export default function NewWorkspaceModal() { Add a node config profile and link it to a local workspace path. -
+ { void handleSubmit(e); }}> {/* Name Input */} @@ -124,7 +122,7 @@ export default function NewWorkspaceModal() { Controls a visual badge to warn you of the current context setWorkspaceType(val as WorkspaceType)} + onValueChange={(val) => { setWorkspaceType(val as WorkspaceType); }} > @@ -156,14 +154,14 @@ export default function NewWorkspaceModal() { Host / IP - setHost(e.target.value)} required /> + { setHost(e.target.value); }} required /> Port setPort(parseInt(e.target.value) || 0)} + onChange={(e) => { setPort(parseInt(e.target.value) || 0); }} required /> @@ -185,7 +183,7 @@ export default function NewWorkspaceModal() { Authentication Mode setAuthType(value as 'credentials' | 'token')} + onValueChange={(value) => { setAuthType(value as 'credentials' | 'token'); }} size="2" variant="surface" > @@ -209,7 +207,7 @@ export default function NewWorkspaceModal() { setToken(e.target.value)} + onChange={e => { setToken(e.target.value); }} required > @@ -217,7 +215,7 @@ export default function NewWorkspaceModal() { type="button" variant="ghost" color="gray" - onClick={() => setShowToken(!showToken)} + onClick={() => { setShowToken(!showToken); }} className="cursor-pointer" > {showToken ? : } @@ -229,21 +227,21 @@ export default function NewWorkspaceModal() { Username - setUsername(e.target.value)} /> + { setUsername(e.target.value); }} /> Password setPassword(e.target.value)} + onChange={e => { setPassword(e.target.value); }} > setShowPassword(!showPassword)} + onClick={() => { setShowPassword(!showPassword); }} className="cursor-pointer" > {showPassword ? : } diff --git a/src/components/NodeStatusBadge.tsx b/src/components/NodeStatusBadge.tsx index 5903c70..c8f2252 100644 --- a/src/components/NodeStatusBadge.tsx +++ b/src/components/NodeStatusBadge.tsx @@ -16,7 +16,7 @@ export default function NodeStatusBadge() { return null; } - const color = nodeStatus ? STATUS_COLOR_MAP[nodeStatus.Status] || 'gray' : 'gray'; + const color = STATUS_COLOR_MAP[nodeStatus.Status] ?? 'gray'; return ( diff --git a/src/components/QuickConnectModal.tsx b/src/components/QuickConnectModal.tsx index 1e881a7..031618f 100644 --- a/src/components/QuickConnectModal.tsx +++ b/src/components/QuickConnectModal.tsx @@ -21,10 +21,10 @@ export default function QuickConnectModal() { const [showPassword, setShowPassword] = useState(false); const [showToken, setShowToken] = useState(false); - const handleSubmit = async (e: React.ChangeEvent) => { + const handleSubmit = (e: React.ChangeEvent) => { e.preventDefault(); - quickConnect({ + void quickConnect({ name: `${host}:${port}`, host, port, @@ -63,20 +63,20 @@ export default function QuickConnectModal() { Establish a temporary session link. This configuration won't be saved to your workspaces list. - + { handleSubmit(e); }}> {/* Host & Port Configuration Row */} Host / IP - setHost(e.target.value)} required /> + { setHost(e.target.value); }} required /> Port setPort(Number(e.target.value))} + onChange={e => { setPort(Number(e.target.value)); }} required /> @@ -98,7 +98,7 @@ export default function QuickConnectModal() { Authentication Mode setAuthType(value as 'credentials' | 'token')} + onValueChange={(value) => { setAuthType(value as 'credentials' | 'token'); }} size="2" variant="surface" > @@ -119,9 +119,15 @@ export default function QuickConnectModal() { {authType === 'token' ? ( @@ -61,8 +61,8 @@ export default function WorkspaceModal() { Workfolder setForm({ ...form, workfolder: e.target.value })} + value={form.workfolder ?? ''} + onChange={e => { setForm({ ...form, workfolder: e.target.value }); }} /> @@ -72,7 +72,7 @@ export default function WorkspaceModal() { Controls a visual badge to warn you of the current context setForm({ ...form, type: val as WorkspaceType })} + onValueChange={(val) => { setForm({ ...form, type: val as WorkspaceType }); }} > @@ -105,8 +105,8 @@ export default function WorkspaceModal() { Host / IP setForm({ ...form, host: e.target.value })} + value={form.host ?? ''} + onChange={e => { setForm({ ...form, host: e.target.value }); }} required /> @@ -114,8 +114,8 @@ export default function WorkspaceModal() { Port setForm({ ...form, port: parseInt(e.target.value) || 0 })} + value={form.port ?? ''} + onChange={e => { setForm({ ...form, port: parseInt(e.target.value) || 0 }); }} required /> @@ -129,7 +129,7 @@ export default function WorkspaceModal() { setForm({ ...form, ssl: checked })} + onCheckedChange={(checked) => { setForm({ ...form, ssl: checked }); }} /> @@ -139,8 +139,8 @@ export default function WorkspaceModal() { Authentication Mode setForm({ ...form, authType: val })} + value={form.authType ?? 'credentials'} + onValueChange={(val: 'credentials' | 'token') => { setForm({ ...form, authType: val }); }} size="2" variant="surface" > @@ -164,15 +164,15 @@ export default function WorkspaceModal() { setForm({ ...form, token: e.target.value })} + value={form.token ?? ''} + onChange={e => { setForm({ ...form, token: e.target.value }); }} > setShowToken(!showToken)} + onClick={() => { setShowToken(!showToken); }} className="cursor-pointer" > {showToken ? : } @@ -185,23 +185,23 @@ export default function WorkspaceModal() { Username setForm({ ...form, username: e.target.value })} + value={form.username ?? ''} + onChange={e => { setForm({ ...form, username: e.target.value }); }} /> Password setForm({ ...form, password: e.target.value })} + value={form.password ?? ''} + onChange={e => { setForm({ ...form, password: e.target.value }); }} > setShowPassword(!showPassword)} + onClick={() => { setShowPassword(!showPassword); }} className="cursor-pointer" > {showPassword ? : } @@ -216,7 +216,7 @@ export default function WorkspaceModal() { {/* Action Row */} - diff --git a/src/components/diagram/DiagramCanvas.tsx b/src/components/diagram/DiagramCanvas.tsx new file mode 100644 index 0000000..9248cfc --- /dev/null +++ b/src/components/diagram/DiagramCanvas.tsx @@ -0,0 +1,530 @@ +import { useState, useEffect, useMemo, useRef } from 'react'; +import { createPortal } from 'react-dom'; +import type { MouseEvent as ReactMouseEvent, WheelEvent as ReactWheelEvent } from 'react'; +import { Text, Badge, Box, Flex, IconButton, Theme } from '@radix-ui/themes'; +import { CubeIcon, TokensIcon, Cross2Icon, PlusIcon, ResetIcon, MinusIcon } from '@radix-ui/react-icons'; +import ELK from 'elkjs/lib/elk.bundled.js'; +import type { ElkNode, ElkExtendedEdge } from 'elkjs'; +import { useTheme } from '../../hooks'; +import type { Cardinality, Enum, Type } from '../../types'; +import { determineCardinality } from '../../utils'; + +type DiagramData = [Type[], Enum[]]; + +interface DiagramCanvasProps { + onClose: () => void; + data: DiagramData; + onNavigateToType?: (name: string) => void; + onNavigateToEnum?: (name: string) => void; + includeWpo: boolean; + includeStandalone: boolean; +} + +interface Node extends ElkNode { + tp?: Type; + enu?: Enum; +} + +interface Edge extends ElkExtendedEdge { + card: Cardinality; + label: string; +} + +const elk = new ELK({ + workerUrl: URL.createObjectURL( + new Blob([`importScripts('https://cdn.jsdelivr.net/npm/elkjs@0.9.3/lib/elk.bundled.js');`], { + type: 'application/javascript' + }) + ) +}); + +const getCardinalityColor = (cardinality: string | undefined): string => { + if (!cardinality) return 'var(--gray-6)'; + if (cardinality === '1:1') return 'var(--amber-8)'; + if (cardinality === '1:N' || cardinality === 'N:1') return 'var(--iris-8)'; + if (cardinality === 'N:N') return 'var(--crimson-8)'; + return 'var(--gray-6)'; +}; + +export default function DiagramCanvas({ + onClose, + data, + onNavigateToType, + onNavigateToEnum, + includeWpo, + includeStandalone +}: DiagramCanvasProps) { + const { appearance } = useTheme(); + + const [types, enums] = data; + + const [nodes, setNodes] = useState([]); + const [edges, setEdges] = useState([]); + const [pan, setPan] = useState({ x: 100, y: 100 }); + const [zoom, setZoom] = useState(1.00); + const [isReady, setIsReady] = useState(false); + + const [hoveredEdge, setHoveredEdge] = useState(null); + const [tooltipPos, setTooltipPos] = useState({ x: 0, y: 0 }); + + const isDragging = useRef(false); + const dragStart = useRef({ x: 0, y: 0 }); + const svgRef = useRef(null); + + const nodesRef = useRef([]); + + const navigateToNode = (targetId: string) => { + if (!svgRef.current) return; + const targetNode = nodesRef.current.find(n => n.id === targetId); + if (!targetNode) return; + + // Get the dynamic dimension boundaries of the physical browser viewport window + const rect = svgRef.current.getBoundingClientRect(); + const viewWidth = rect.width; + const viewHeight = rect.height; + + // Apply viewport midpoint geometry delta scaling formula + const newX = viewWidth / 2 - (targetNode.x! + targetNode.width! / 2) * zoom; + const newY = viewHeight / 2 - (targetNode.y! + targetNode.height! / 2) * zoom; + + setPan({ x: newX, y: newY }); + }; + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose]); + + useEffect(() => { + let active = true; + async function computeLayout() { + const childrenNodes: Node[] = []; + const edgeRoutes: Edge[] = []; + const processedPairs = new Set(); + const nameOnryRe = /[^_a-zA-Z0-9]/g; + const names = new Set([ + ...types.map(tp => tp.name), + ...enums.map(en => en.name), + ]); + const standalone = new Set(includeStandalone ? [] : [...names]); + const calcTypeEdges = (tp: Type) => { + tp.fields.forEach(([fName, fDef]) => { + if (typeof fDef === 'string') { + const rel = tp.relations[fName]; + if (rel !== undefined) { + const pairKey = `edge-${[tp.name, rel.type].sort().join('-')}`; + if (processedPairs.has(pairKey)) return; + processedPairs.add(pairKey); + + const card = determineCardinality(fDef, rel.definition); + if (tp.name !== rel.type) { + standalone.delete(tp.name); + standalone.delete(rel.type); + } + edgeRoutes.push({ + id: pairKey, + sources: [tp.name], + targets: [rel.type], + card: card, + label: `${tp.name} (${card}) ${rel.type}`, + }); + return; + } + const other = fDef.replace(nameOnryRe, ''); + const pairKey = `edge-${[tp.name, other].sort().join('-')}`; + if (!names.has(other) || processedPairs.has(pairKey)) return; + processedPairs.add(pairKey); + + const card = determineCardinality(fDef, undefined); + if (tp.name !== other) { + standalone.delete(tp.name); + standalone.delete(other); + } + edgeRoutes.push({ + id: pairKey, + sources: [tp.name], + targets: [other], + card: card, + label: `${tp.name} (${card}) ${other}`, + }); + } + }); + }; + + types.forEach((tp) => { + if (!includeWpo && tp.wrapOnly) return; + calcTypeEdges(tp); + }); + + types.forEach((tp) => { + if ((!includeWpo && tp.wrapOnly) || standalone.has(tp.name)) return; + const headerHeight = 40; + const fieldRowHeight = 20; + const totalHeight = headerHeight + (tp.fields.length * fieldRowHeight) + 8; + + childrenNodes.push({ + id: tp.name, + width: 300, + height: totalHeight, + layoutOptions: { 'elk.portConstraints': 'FREE' }, + tp: tp, + }); + }); + + enums.forEach((enu) => { + if (standalone.has(enu.name)) return; + const headerHeight = 40; + const memberRowHeight = 20; + const totalHeight = headerHeight + (enu.members.length * memberRowHeight) + 8; + childrenNodes.push({ + id: enu.name, + width: 200, + height: totalHeight, + enu: enu, + }); + }); + + const layoutGraph: ElkNode = { + id: 'root', + layoutOptions: { + 'elk.algorithm': 'layered', + }, + children: childrenNodes, + edges: edgeRoutes.filter((edge) => !standalone.has(edge.sources[0])), + }; + + try { + const result = await elk.layout(layoutGraph); + if (!active) return; + + nodesRef.current = result.children as Node[] || []; // Store coordinates in ref + setNodes(result.children as Node[] || []); + setEdges(result.edges as Edge[] || []); + setIsReady(true); + } catch (err) { + console.error('ELK Engine fault:', err); + } + } + + computeLayout(); + return () => { active = false; }; + }, [types, enums, includeStandalone, includeWpo]); + + const renderedEdges = useMemo(() => { + return edges.map((edge) => { + const sections = edge.sections?.[0]; + if (!sections) return { ...edge, path: '' }; + + const start = sections.startPoint; + const end = sections.endPoint; + let pathString = `M ${start.x} ${start.y}`; + + if (sections.bendPoints) { + sections.bendPoints.forEach((bp) => { + pathString += ` L ${bp.x} ${bp.y}`; + }); + } + pathString += ` L ${end.x} ${end.y}`; + return { ...edge, path: pathString }; + }); + }, [edges]); + + const handleZoomWheel = (event: ReactWheelEvent) => { + event.stopPropagation(); + if (!svgRef.current) return; + + const zoomIntensity = 0.05; + const rect = svgRef.current.getBoundingClientRect(); + const mouseX = event.clientX - rect.left; + const mouseY = event.clientY - rect.top; + + const canvasX = (mouseX - pan.x) / zoom; + const canvasY = (mouseY - pan.y) / zoom; + + const zoomFactor = event.deltaY < 0 ? 1 + zoomIntensity : 1 - zoomIntensity; + const nextZoom = Math.min(Math.max(zoom * zoomFactor, 0.1), 2.0); + + setPan({ + x: mouseX - canvasX * nextZoom, + y: mouseY - canvasY * nextZoom + }); + setZoom(nextZoom); + if (hoveredEdge) setHoveredEdge(null); + }; + + const handleZoomStep = (zoomStep: number) => { + if (!svgRef.current) return; + + const rect = svgRef.current.getBoundingClientRect(); + const centerX = rect.right / 2; + const centerY = rect.bottom / 2; + const canvasX = (centerX - pan.x) / zoom; + const canvasY = (centerY - pan.y) / zoom; + + const nextZoom = Math.min(Math.max(zoom + zoomStep, 0.1), 2.0); + + setPan({ + x: centerX - canvasX * nextZoom, + y: centerY - canvasY * nextZoom + }); + setZoom(nextZoom); + if (hoveredEdge) setHoveredEdge(null); + }; + + const handlePanStart = (event: ReactMouseEvent) => { + if (event.button !== 0) return; + isDragging.current = true; + dragStart.current = { x: event.clientX - pan.x, y: event.clientY - pan.y }; + }; + + const handlePanMove = (event: ReactMouseEvent) => { + if (!isDragging.current) return; + setPan({ + x: event.clientX - dragStart.current.x, + y: event.clientY - dragStart.current.y + }); + }; + + const handlePanEnd = () => { + isDragging.current = false; + }; + + const handleEdgeMouseMove = (event: ReactMouseEvent, edge: Edge) => { + setHoveredEdge(edge); + setTooltipPos({ x: event.clientX + 14, y: event.clientY + 14 }); + }; + + + return createPortal( + +
+
+ {!isReady ? ( + + + Calculating coordinates... + + + ) : ( + { handlePanEnd(); setHoveredEdge(null); }} + onWheel={handleZoomWheel} + > + + + + + + + + + + + + + + + + + + {/* EDGES */} + {renderedEdges.map((edge) => { + const card = edge.card ?? '1:1'; + const [srcType, tgtType] = card.split(':'); + const markerStart = srcType === 'N' ? 'url(#marker-many-src)' : srcType === '1' ? 'url(#marker-one-src)' : 'url(#marker-no-src)'; + const markerEnd = tgtType === 'N' ? 'url(#marker-many-tgt)' : tgtType === '1' ? 'url(#marker-one-src)' : 'url(#marker-no-src)'; + const lineColor = getCardinalityColor(edge.card); + const lineWidth = edge.card.includes('0') ? '2px' : '3px'; + const isThisHovered = hoveredEdge?.id === edge.id; + return ( + { handleEdgeMouseMove(e, edge); }} + onMouseLeave={() => { setHoveredEdge(null); }} + style={{ + stroke: lineColor, + strokeWidth: isThisHovered ? '5px' : lineWidth, + }} + className="fill-none transition-all duration-700 cursor-pointer opacity-75 hover:opacity-100" + /> + ); + })} + + {/* NODES */} + {nodes.map((node) => ( + + {node.tp && ( + + onNavigateToType?.(node.id)} + > + + + {node.id} + + {node.tp.wrapOnly && WPO} + + + + {node.tp.fields.map(([fName, fDef]) => { + const nameOnryRe = /[^_a-zA-Z0-9]/g; + const target = typeof fDef === 'string' ? fDef.replace(nameOnryRe, '') : ''; + const targetNode = target ? nodes.find(n => n.id === target) : undefined; + const isClickable = !!targetNode; + + return ( + { + if (!isClickable) return; + e.stopPropagation(); + navigateToNode(target); + }} + > + {fName} + {String(fDef)} + + ); + })} + + + )} + {node.enu && ( + + onNavigateToEnum?.(node.id)} + > + + {node.id} + + + {node.enu.members.map(([mName, mVal]) => ( + + {mName} + {typeof mVal === 'string' ? mVal : JSON.stringify(mVal)} + + ))} + + + )} + + ))} + + + )} +
+ +
+
+
+ + {/* ZOOM OUT */} + { handleZoomStep(-0.05); }} + className="cursor-pointer rounded-full" + title="Zoom Out" + > + + + + + + {/* ZOOM IN */} + { handleZoomStep(0.05); }} + className="cursor-pointer rounded-full" + title="Zoom In" + > + + + +
+ { + e.stopPropagation(); + onClose(); + }} + className="cursor-pointer rounded-full" + > + + +
+
+ + {hoveredEdge && ( +
+ + + {hoveredEdge.label} + +
+ )} + +
+
, + document.body + ); +} \ No newline at end of file diff --git a/src/components/diagram/DiagramLauncher.tsx b/src/components/diagram/DiagramLauncher.tsx new file mode 100644 index 0000000..541b577 --- /dev/null +++ b/src/components/diagram/DiagramLauncher.tsx @@ -0,0 +1,184 @@ +import { useState } from 'react'; +import { IconButton, Tooltip, Flex, Checkbox, Text, Dialog, Button } from '@radix-ui/themes'; +import { ComponentInstanceIcon, SymbolIcon } from '@radix-ui/react-icons'; +import { useActiveWorkspaceId, useWebSocket } from '../../hooks'; +import type { Type, Enum } from '../../types'; +import DiagramCanvas from './DiagramCanvas'; +import TypeModal from '../studio/TypeModal'; +import EnumModal from '../studio/EnumModal'; + +interface DiagramLauncherProps { + scope: string; + disabled: boolean; +} + +export default function DiagramLauncher({ scope, disabled }: DiagramLauncherProps) { + const activeId = useActiveWorkspaceId(); + const { emit } = useWebSocket(); + + // Dialog configuration states + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [includeWpo, setIncludeWpo] = useState(false); + const [includeEnums, setIncludeEnums] = useState(false); + const [includeStandalone, setIncludeStandalone] = useState(false); + + // Canvas states + const [isCanvasOpen, setIsCanvasOpen] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [graphData, setGraphData] = useState<[Type[], Enum[]] | null>(null); + + // Type and Enum modal states + const [viewType, setViewType] = useState(null); + const [viewEnum, setViewEnum] = useState(null); + + const handleOnNavigateToType = (name: string) => { + setIsCanvasOpen(false); + setViewType(graphData?.[0].find(tp => tp.name === name) ?? null); + }; + + const handleOnNavigateToEnum = (name: string) => { + setIsCanvasOpen(false); + setViewEnum(graphData?.[1].find(enu => enu.name === name) ?? null); + }; + + const handleLaunchDiagram = async () => { + setIsLoading(true); + try { + const [resolvedTypes, resolvedEnums] = await Promise.all([ + emit('FETCH_TYPES', { id: activeId, scope }), + includeEnums + ? emit('FETCH_ENUMS', { id: activeId, scope }) + : Promise.resolve([] as Enum[]), + ]) as [Type[], Enum[]]; + + setGraphData([resolvedTypes, resolvedEnums]); + setIsDialogOpen(false); + setIsCanvasOpen(true); + } catch (err) { + console.error('Failed to resolve collection schema profiles:', err); + } finally { + setIsLoading(false); + } + }; + + const handleCloseDiagram = () => { + setIsCanvasOpen(false); + setGraphData(null); + }; + + return ( + + + + + { void setIsDialogOpen(true); }} + className="cursor-pointer" + > + + + + + + + + Configure Collection Graph + + + Select which extra components to include for scope: {scope} + + + + + setIncludeWpo(!!checked)} + /> + + Include wrap-only types + + + + + { setIncludeEnums(!!checked); }} + /> + + Include enumerators + + + + + + { setIncludeStandalone(!!checked); }} + /> + + Include standalone + + + + Include standalone types and enumerators that have no connection with any other type. + + + + + + + + + + + + + + + {isCanvasOpen && graphData && ( + + )} + {viewType && ( + { setViewType(null); }} + tp={viewType} + onNavigateToType={handleOnNavigateToType} + /> + )} + {viewEnum && ( + { setViewEnum(null); }} + enu={viewEnum} + scope={scope} + /> + )} + + ); +} \ No newline at end of file diff --git a/src/components/icons/HashIcon.tsx b/src/components/icons/HashIcon.tsx index 49b30db..7b37a00 100644 --- a/src/components/icons/HashIcon.tsx +++ b/src/components/icons/HashIcon.tsx @@ -14,8 +14,8 @@ export default function HashIcon({ color = 'currentColor' }: HashIconProps) { - const w = width || size || 16; - const h = height || size || 16; + const w = width ?? size ?? 16; + const h = height ?? size ?? 16; const numericHeight = typeof h === 'number' ? h diff --git a/src/components/studio/BackupModal.tsx b/src/components/studio/BackupModal.tsx index 90c8d4f..441b8a4 100644 --- a/src/components/studio/BackupModal.tsx +++ b/src/components/studio/BackupModal.tsx @@ -84,7 +84,7 @@ export default function BackupModal({ General - Files Generated ({backup.files?.length || 0}) + Files Generated ({backup.files.length}) @@ -179,7 +179,7 @@ export default function BackupModal({ backgroundColor: 'var(--gray-1)' }} > - {(!backup.files || backup.files.length === 0) ? ( + {(backup.files.length === 0) ? ( diff --git a/src/components/studio/BackupsPanel.tsx b/src/components/studio/BackupsPanel.tsx index dcbbcf3..7a97d73 100644 --- a/src/components/studio/BackupsPanel.tsx +++ b/src/components/studio/BackupsPanel.tsx @@ -22,8 +22,8 @@ export default function BackupsPanel({ scope }: BackupsPanelProps) { setIsLoading(true); setFetchError(null); try { - const response = await emit('FETCH_BACKUPS', { id: activeId, scope }) as Backup[]; - setBackups(response || []); + const response: Backup[] = await emit('FETCH_BACKUPS', { id: activeId, scope }); + setBackups(response); } catch (err: unknown) { console.error("Failed to fetch backups:", err); setFetchError(errStr(err, "Failed to fetch backups.")); @@ -33,7 +33,7 @@ export default function BackupsPanel({ scope }: BackupsPanelProps) { }, [activeId, emit, scope]); useEffect(() => { - queueMicrotask(fetchBackups); + queueMicrotask(() => { void fetchBackups(); }); }, [fetchBackups]); return ( @@ -48,7 +48,7 @@ export default function BackupsPanel({ scope }: BackupsPanelProps) { variant="soft" color="gray" disabled={isLoading} - onClick={fetchBackups} + onClick={() => { void fetchBackups(); }} style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }} > @@ -104,7 +104,7 @@ export default function BackupsPanel({ scope }: BackupsPanelProps) { borderColor: 'var(--gray-4)', cursor: 'pointer', }} - onClick={() => setViewBackup(backup)} + onClick={() => { setViewBackup(backup); }} > @@ -168,7 +168,7 @@ export default function BackupsPanel({ scope }: BackupsPanelProps) { {viewBackup && ( setViewBackup(null)} + onClose={() => { setViewBackup(null); }} scope={scope} backup={viewBackup} /> diff --git a/src/components/studio/CollectionContextPanel.tsx b/src/components/studio/CollectionContextPanel.tsx index 6d8021c..15bd76b 100644 --- a/src/components/studio/CollectionContextPanel.tsx +++ b/src/components/studio/CollectionContextPanel.tsx @@ -52,7 +52,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('rooms')} + onClick={() => { toggleSection('rooms'); }} > Rooms {openSection === 'rooms' ? : } @@ -67,7 +67,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('tasks')} + onClick={() => { toggleSection('tasks'); }} > Tasks {openSection === 'tasks' ? : } @@ -82,7 +82,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('procedures')} + onClick={() => { toggleSection('procedures'); }} > Procedures {openSection === 'procedures' ? : } @@ -97,7 +97,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('enums')} + onClick={() => { toggleSection('enums'); }} > Enums {openSection === 'enums' ? : } @@ -112,7 +112,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('types')} + onClick={() => { toggleSection('types'); }} > Types {openSection === 'types' ? : } @@ -127,7 +127,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('explorer')} + onClick={() => { toggleSection('explorer'); }} > Explorer {openSection === 'explorer' ? : } @@ -143,7 +143,7 @@ export default function CollectionContextPanel({ scope, requireCommit }: Collect justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('history')} + onClick={() => { toggleSection('history'); }} > History {openSection === 'history' ? : } diff --git a/src/components/studio/CommitModal.tsx b/src/components/studio/CommitModal.tsx index 0faf4c9..91c7f83 100644 --- a/src/components/studio/CommitModal.tsx +++ b/src/components/studio/CommitModal.tsx @@ -23,16 +23,11 @@ export default function CommitModal({ commitId, scope, onClose }: CommitModalPro const [error, setError] = useState(null); useEffect(() => { - if (commitId === null) { - queueMicrotask(() => setCommit(null)); - return; - } - const fetchCommit = async () => { setIsLoading(true); setError(null); try { - const response = await emit('FETCH_COMMIT', { id: activeId, scope, commitId }) as Commit; + const response: Commit = await emit('FETCH_COMMIT', { id: activeId, scope, commitId }); setCommit(response); } catch (err: unknown) { console.error("Failed to fetch commit details:", err); @@ -42,7 +37,7 @@ export default function CommitModal({ commitId, scope, onClose }: CommitModalPro } }; - queueMicrotask(fetchCommit); + queueMicrotask(() => { void fetchCommit(); }); }, [commitId, scope, activeId, emit]); return ( diff --git a/src/components/studio/CreateFileDialog.tsx b/src/components/studio/CreateFileDialog.tsx index 03df7ab..8bbdc42 100644 --- a/src/components/studio/CreateFileDialog.tsx +++ b/src/components/studio/CreateFileDialog.tsx @@ -68,7 +68,7 @@ export default function CreateFileDialog({ setFilename(e.target.value)} + onChange={(e) => { setFilename(e.target.value); }} placeholder="filename.ti" size="2" mb="4" diff --git a/src/components/studio/EnumModal.tsx b/src/components/studio/EnumModal.tsx index 714325b..2c5cec7 100644 --- a/src/components/studio/EnumModal.tsx +++ b/src/components/studio/EnumModal.tsx @@ -15,8 +15,6 @@ export default function EnumModal({ onClose, enu, scope }: EnumModalProps) { const [activeTab, setActiveTab] = useState('general'); const [expandedMembers, setExpandedMembers] = useState>({}); - if (!enu) return null; - const toggleMemberExplorer = (key: string) => { setExpandedMembers((prev) => ({ ...prev, @@ -25,16 +23,20 @@ export default function EnumModal({ onClose, enu, scope }: EnumModalProps) { }; const renderMemberValue = (val: string | number | ThingId) => { - if (typeof val === 'object' && val !== null && '#' in val) { + if (typeof val === 'object' && Object.keys(val).length === 1) { + const thingId = Object.values(val)[0]; return ( - #{val['#']} + #{thingId} ); } + if (typeof val === 'object') { + return {JSON.stringify(val)}; + } return {String(val)}; }; @@ -148,15 +150,15 @@ export default function EnumModal({ onClose, enu, scope }: EnumModalProps) { marginTop: '10px', } as React.CSSProperties} > - {enu.members?.map(([key, val]) => { + {enu.members.map(([key, val]) => { const isDefault = key === enu.default; - const isThingType = enu.type === 'thing' && typeof val === 'object' && val !== null && '#' in val; - const isRowOpen = !!expandedMembers[key]; + const isThingType = enu.type === 'thing' && typeof val === 'object' && Object.keys(val).length === 1; + const isRowOpen = expandedMembers[key]; return ( isThingType && toggleMemberExplorer(key)} + onClick={() => { if (isThingType) toggleMemberExplorer(key); }} style={{ display: 'inline-flex', alignItems: 'center', @@ -208,7 +210,7 @@ export default function EnumModal({ onClose, enu, scope }: EnumModalProps) {
diff --git a/src/components/studio/EnumsPanel.tsx b/src/components/studio/EnumsPanel.tsx index 0dabc92..58309e9 100644 --- a/src/components/studio/EnumsPanel.tsx +++ b/src/components/studio/EnumsPanel.tsx @@ -25,8 +25,8 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { setIsLoading(true); setFetchError(null); try { - const response = await emit('FETCH_ENUMS', { id: activeId, scope }) as Enum[]; - setEnums(response || []); + const response: Enum[] = await emit('FETCH_ENUMS', { id: activeId, scope }); + setEnums(response); } catch (err: unknown) { console.error("Failed to fetch enums:", err); setFetchError(errStr(err, "Failed to fetch enums.")); @@ -36,7 +36,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { }, [activeId, emit, scope]); useEffect(() => { - queueMicrotask(fetchEnums); + queueMicrotask(() => { void fetchEnums(); }); }, [fetchEnums]); const filtered = useMemo(() => { @@ -46,7 +46,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { } return enums.filter((enu) => { - const nameMatch = enu.name?.toLowerCase().includes(cleanedQuery); + const nameMatch = enu.name.toLowerCase().includes(cleanedQuery); return nameMatch; }); @@ -64,7 +64,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { variant="soft" color="gray" disabled={isLoading} - onClick={fetchEnums} + onClick={() => { void fetchEnums(); }} style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }} > @@ -76,7 +76,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" > @@ -88,7 +88,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -160,7 +160,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { borderColor: 'var(--gray-4)', cursor: 'pointer', }} - onClick={() => setViewEnum(enu)} + onClick={() => { setViewEnum(enu); }} > @@ -193,7 +193,7 @@ export default function EnumsPanel({ scope }: EnumsPanelProps) { {viewEnum && ( setViewEnum(null)} + onClose={() => { setViewEnum(null); }} enu={viewEnum} scope={scope} /> diff --git a/src/components/studio/EventArgsModal.tsx b/src/components/studio/EventArgsModal.tsx index 8bb5ebe..35f9309 100644 --- a/src/components/studio/EventArgsModal.tsx +++ b/src/components/studio/EventArgsModal.tsx @@ -20,7 +20,7 @@ export default function EventArgsModal({ const [copied, setCopied] = useState(false); const formattedJson = useMemo(() => { - if (!eventItem || !eventItem.args) { + if (!eventItem?.args) { return '[]'; } return JSON.stringify(eventItem.args, null, 2); @@ -30,7 +30,7 @@ export default function EventArgsModal({ try { await navigator.clipboard.writeText(formattedJson); setCopied(true); - setTimeout(() => setCopied(false), 2000); + setTimeout(() => { setCopied(false); }, 2000); } catch (err) { console.error('Failed to copy event arguments payload:', err); } @@ -44,7 +44,7 @@ export default function EventArgsModal({ event.preventDefault()} + onOpenAutoFocus={(event) => { event.preventDefault(); }} > @@ -83,7 +83,7 @@ export default function EventArgsModal({ variant="soft" color={copied ? "green" : "gray"} highContrast={!copied} - onClick={handleCopyToClipboard} + onClick={() => { void handleCopyToClipboard(); }} style={{ cursor: 'pointer', boxShadow: 'var(--shadow-2)', diff --git a/src/components/studio/HistoryPanel.tsx b/src/components/studio/HistoryPanel.tsx index 5880980..c157d7f 100644 --- a/src/components/studio/HistoryPanel.tsx +++ b/src/components/studio/HistoryPanel.tsx @@ -24,8 +24,8 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { setIsLoading(true); setFetchError(null); try { - const response = await emit('FETCH_HISTORY', { id: activeId, scope }) as Commit[]; - setCommits(response.sort(commit => -commit.id) || []); + const response: Commit[] = await emit('FETCH_HISTORY', { id: activeId, scope }); + setCommits(response.sort(commit => -commit.id)); } catch (err: unknown) { console.error("Failed to fetch history:", err); setFetchError(errStr(err, "Failed to fetch history.")); @@ -35,7 +35,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { }, [activeId, emit, scope]); useEffect(() => { - queueMicrotask(fetchHistory); + queueMicrotask(() => { void fetchHistory(); }); }, [fetchHistory]); const filtered = useMemo(() => { @@ -45,7 +45,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { } return commits.filter((commit) => { - const messageMatch = commit.message?.toLowerCase().includes(cleanedQuery); + const messageMatch = commit.message.toLowerCase().includes(cleanedQuery); return messageMatch; }); @@ -64,7 +64,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { variant="soft" color="gray" disabled={isLoading} - onClick={fetchHistory} + onClick={() => { void fetchHistory(); }} style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }} > @@ -76,7 +76,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" > @@ -88,7 +88,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -167,7 +167,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { borderColor: hasError ? 'var(--orange-6)' : 'var(--gray-4)', cursor: 'pointer', }} - onClick={() => setViewCommitId(commit.id)} + onClick={() => { setViewCommitId(commit.id); }} > @@ -239,7 +239,7 @@ export default function HistorysPanel({ scope }: HistorysPanelProps) { setViewCommitId(null)} + onClose={() => { setViewCommitId(null); }} /> )} diff --git a/src/components/studio/ModuleModal.tsx b/src/components/studio/ModuleModal.tsx index ab47e3c..06c7980 100644 --- a/src/components/studio/ModuleModal.tsx +++ b/src/components/studio/ModuleModal.tsx @@ -123,7 +123,7 @@ export default function ModuleModal({ )} - {module.tasks !== null && ( + {module.tasks !== undefined && ( Running tasks @@ -133,7 +133,7 @@ export default function ModuleModal({ )} - {module.restarts !== null && ( + {module.restarts !== undefined && ( Module restarts diff --git a/src/components/studio/ModulesPanel.tsx b/src/components/studio/ModulesPanel.tsx index 1023262..8645c13 100644 --- a/src/components/studio/ModulesPanel.tsx +++ b/src/components/studio/ModulesPanel.tsx @@ -22,8 +22,8 @@ export default function ModulesPanel({ scope }: ModulesPanelProps) { setIsLoading(true); setFetchError(null); try { - const response = await emit('FETCH_MODULES', { id: activeId, scope }) as Module[]; - setModules(response || []); + const response: Module[] = await emit('FETCH_MODULES', { id: activeId, scope }); + setModules(response); } catch (err: unknown) { console.error("Failed to fetch modules:", err); setFetchError(errStr(err, "Failed to fetch modules.")); @@ -33,7 +33,7 @@ export default function ModulesPanel({ scope }: ModulesPanelProps) { }, [activeId, emit, scope]); useEffect(() => { - queueMicrotask(fetchModules); + queueMicrotask(() => { void fetchModules(); }); }, [fetchModules]); return ( @@ -48,7 +48,7 @@ export default function ModulesPanel({ scope }: ModulesPanelProps) { variant="soft" color="gray" disabled={isLoading} - onClick={fetchModules} + onClick={() => { void fetchModules(); }} style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }} > @@ -92,114 +92,111 @@ export default function ModulesPanel({ scope }: ModulesPanelProps) { )} - {modules.map((module) => { - const isGithub = module.githubOwner && module.githubRef && module.githubRepo; - return ( - setViewModule(module)} - > - - - - {isGithub ? ( - - - - ): ( - - )} - - {module.name} - - - - - {/* ⚡ PENDING/OK/FAILED */} - {module.status === "installing module..." ? ( - - installing… - - ) : module.status === "running" ? ( - - running - - ) : ( - - fault - + {modules.map((module) => ( + { setViewModule(module); }} + > + + + + {module.githubOwner && module.githubRepo && module.githubRef ? ( + + + + ): ( + )} - - - {module.version && ( - - - Version: {module.version} - - {module.doc && module.doc.startsWith('http') && ( - - - - - - - + {module.name} + + + - )} - + {/* ⚡ PENDING/OK/FAILED */} + {module.status === "installing module..." ? ( + + installing… + + ) : module.status === "running" ? ( + + running + + ) : ( + + fault + )} - - ); - })} + + {module.version && ( + + + Version: {module.version} + + {module.doc && module.doc.startsWith('http') && ( + + + + + + + + + )} + + )} + + + ))} {viewModule && ( setViewModule(null)} + onClose={() => { setViewModule(null); }} scope={scope} module={viewModule} /> diff --git a/src/components/studio/MyUserModal.tsx b/src/components/studio/MyUserModal.tsx index 8960e13..a3d9802 100644 --- a/src/components/studio/MyUserModal.tsx +++ b/src/components/studio/MyUserModal.tsx @@ -16,14 +16,14 @@ export default function MyUserModal({ onClose }: MyUserModalProps) { useEffect(() => { const fetchMyUser= async () => { try { - const response = await emit('FETCH_USER', { id: activeId }) as User; + const response: User = await emit('FETCH_USER', { id: activeId }); setMyUser(response); } catch (err: unknown) { console.error("Failed to fetch current user info:", err); } }; - queueMicrotask(fetchMyUser); + queueMicrotask(() => { void fetchMyUser(); }); }, [activeId, emit]); return myUser ? ( diff --git a/src/components/studio/NodeContextPanel.tsx b/src/components/studio/NodeContextPanel.tsx index e6d80f3..fa38b61 100644 --- a/src/components/studio/NodeContextPanel.tsx +++ b/src/components/studio/NodeContextPanel.tsx @@ -48,7 +48,7 @@ export default function NodeContextPanel({ scope }: NodeContextPanelProps) { justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('node-info')} + onClick={() => { toggleSection('node-info'); }} > Node Info {openSection === 'node-info' ? : } @@ -65,7 +65,7 @@ export default function NodeContextPanel({ scope }: NodeContextPanelProps) { justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('counters')} + onClick={() => { toggleSection('counters'); }} > Counters {openSection === 'counters' ? : } @@ -82,7 +82,7 @@ export default function NodeContextPanel({ scope }: NodeContextPanelProps) { justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('backups')} + onClick={() => { toggleSection('backups'); }} > Backups {openSection === 'backups' ? : } @@ -97,7 +97,7 @@ export default function NodeContextPanel({ scope }: NodeContextPanelProps) { justify="between" py="1" className="cursor-pointer select-none" - onClick={() => toggleSection('modules')} + onClick={() => { toggleSection('modules'); }} > Modules {openSection === 'modules' ? : } diff --git a/src/components/studio/NodeCountersPanel.tsx b/src/components/studio/NodeCountersPanel.tsx index e409fd0..4ce30e3 100644 --- a/src/components/studio/NodeCountersPanel.tsx +++ b/src/components/studio/NodeCountersPanel.tsx @@ -85,7 +85,7 @@ export default function NodeCountersPanel({ scope }: NodeCountersPanelProps) { queueMicrotask(() => { if (abortCheck.isMounted) { - fetchCounters(abortCheck); + void fetchCounters(abortCheck); } }); @@ -95,7 +95,6 @@ export default function NodeCountersPanel({ scope }: NodeCountersPanelProps) { }, [fetchCounters]); const formatDuration = (seconds: number) => { - if (seconds === undefined || seconds === null) return '0s'; if (seconds < 0.001) return `${(seconds * 1000000).toFixed(0)} μs`; if (seconds < 1) return `${(seconds * 1000).toFixed(2)} ms`; return `${seconds.toFixed(3)} s`; @@ -116,7 +115,12 @@ export default function NodeCountersPanel({ scope }: NodeCountersPanelProps) { if (!counters) { return ( - ); @@ -279,7 +283,7 @@ export default function NodeCountersPanel({ scope }: NodeCountersPanelProps) { size="1" variant="soft" color="gray" - onClick={() => fetchCounters()} + onClick={() => { void fetchCounters(); }} className="cursor-pointer" > Refresh @@ -288,7 +292,7 @@ export default function NodeCountersPanel({ scope }: NodeCountersPanelProps) { size="1" variant="soft" color="red" - onClick={() => setIsResetCounters(true)} + onClick={() => { setIsResetCounters(true); }} disabled={isResetting} style={{ cursor: isResetting ? 'not-allowed' : 'pointer' }} > @@ -296,15 +300,16 @@ export default function NodeCountersPanel({ scope }: NodeCountersPanelProps) { - + {isResetCounters && ( + { void handleResetCounters(); }} + /> + )} ); diff --git a/src/components/studio/NodeInfoPanel.tsx b/src/components/studio/NodeInfoPanel.tsx index ed917bd..7e2f6c3 100644 --- a/src/components/studio/NodeInfoPanel.tsx +++ b/src/components/studio/NodeInfoPanel.tsx @@ -22,7 +22,7 @@ export default function NodeInfoPanel({ scope }: NodeInfoPanelProps) { const [isInspectOpen, setIsInspectOpen] = useState(false); const [isLogLevelOpen, setIsLogLevelOpen] = useState(false); - const nodeIdMatch = scope.match(/\d+/); + const nodeIdMatch = /\d+/.exec(scope)?.[0]; const nodeId = nodeIdMatch ? parseInt(nodeIdMatch[0], 10) : 0; const fetchNodeInfo = useCallback(async (abortCheck?: { isMounted: boolean }) => { @@ -55,7 +55,7 @@ export default function NodeInfoPanel({ scope }: NodeInfoPanelProps) { queueMicrotask(() => { if (abortCheck.isMounted) { - fetchNodeInfo(abortCheck); + void fetchNodeInfo(abortCheck); } }); @@ -78,7 +78,12 @@ export default function NodeInfoPanel({ scope }: NodeInfoPanelProps) { if (!nodeInfo) { return ( - ); @@ -105,16 +110,16 @@ export default function NodeInfoPanel({ scope }: NodeInfoPanelProps) { - - - - @@ -125,14 +130,12 @@ export default function NodeInfoPanel({ scope }: NodeInfoPanelProps) { nodeId={nodeId} scope={scope} /> - {nodeInfo && ( - - )} + void; nodeInfo: NodeInfo | null; - onRefresh: () => void; + onRefresh: () => Promise; } export default function NodeInspectModal({ isOpen, onOpenChange, nodeInfo, onRefresh }: NodeInspectModalProps) { @@ -31,7 +31,7 @@ export default function NodeInspectModal({ isOpen, onOpenChange, nodeInfo, onRef // Handle refresh const handleRefreshClick = async () => { - if (!onRefresh || isRefreshing) return; + if (isRefreshing) return; setIsRefreshing(true); try { await onRefresh(); @@ -65,7 +65,7 @@ export default function NodeInspectModal({ isOpen, onOpenChange, nodeInfo, onRef size="1" variant="ghost" color="gray" - onClick={handleRefreshClick} + onClick={() => { void handleRefreshClick(); }} disabled={isRefreshing} title={isRefreshing ? 'Refreshing...' : 'Refresh'} style={{ cursor: isRefreshing ? 'not-allowed' : 'pointer' }} @@ -121,13 +121,13 @@ export default function NodeInspectModal({ isOpen, onOpenChange, nodeInfo, onRef Result Size Limit{formatBytes(nodeInfo.resultSizeLimit)} - Global Stored Change ID{nodeInfo.globalStoredChangeId?.toLocaleString()} - Global Committed Change ID{nodeInfo.globalCommittedChangeId?.toLocaleString()} - Local Stored Change ID{nodeInfo.localStoredChangeId?.toLocaleString()} - Local Committed Change ID{nodeInfo.localCommittedChangeId?.toLocaleString()} - DB Stored Change ID{nodeInfo.dbStoredChangeId?.toLocaleString()} - Next Change ID{nodeInfo.nextChangeId?.toLocaleString()} - Next Free ID{nodeInfo.nextFreeId?.toLocaleString()} + Global Stored Change ID{nodeInfo.globalStoredChangeId.toLocaleString()} + Global Committed Change ID{nodeInfo.globalCommittedChangeId.toLocaleString()} + Local Stored Change ID{nodeInfo.localStoredChangeId.toLocaleString()} + Local Committed Change ID{nodeInfo.localCommittedChangeId.toLocaleString()} + DB Stored Change ID{nodeInfo.dbStoredChangeId.toLocaleString()} + Next Change ID{nodeInfo.nextChangeId.toLocaleString()} + Next Free ID{nodeInfo.nextFreeId.toLocaleString()} Changes In Queue{nodeInfo.changesInQueue} Syntax Version{nodeInfo.syntaxVersion} Scheduled Backups{nodeInfo.scheduledBackups} diff --git a/src/components/studio/NodeLogLevelModal.tsx b/src/components/studio/NodeLogLevelModal.tsx index 0c2b969..6007590 100644 --- a/src/components/studio/NodeLogLevelModal.tsx +++ b/src/components/studio/NodeLogLevelModal.tsx @@ -58,7 +58,7 @@ export default function NodeLogLevelModal({ Select log verbosity for {scope} - + { void handleLogLevelSubmit(elem); }}> diff --git a/src/components/studio/NodeShutdownModal.tsx b/src/components/studio/NodeShutdownModal.tsx index 5a59780..b1dff50 100644 --- a/src/components/studio/NodeShutdownModal.tsx +++ b/src/components/studio/NodeShutdownModal.tsx @@ -58,11 +58,11 @@ export default function NodeShutdownModal({ You are about to power down this node. To confirm this action, please type the Node ID ({nodeId}) below: - + { void handleShutdownExecute(elem); }}> setConfirmInput(e.target.value)} + onChange={(e) => { setConfirmInput(e.target.value); }} size="2" mb="4" autoComplete="off" diff --git a/src/components/studio/ProcedureModal.tsx b/src/components/studio/ProcedureModal.tsx index 5042614..54a43b8 100644 --- a/src/components/studio/ProcedureModal.tsx +++ b/src/components/studio/ProcedureModal.tsx @@ -29,22 +29,18 @@ export default function ProcedureModal({ const [executionResult, setExecutionResult] = useState(null); useEffect(() => { - if (procedure && Array.isArray(procedure.arguments)) { - const initialArgs: Record = {}; - procedure.arguments.forEach((argName) => { - initialArgs[argName] = null; - }); - queueMicrotask(() => { - setJsonArgs(JSON.stringify(initialArgs, null, 2)); - setJsonError(null); - setExecutionResult(null); - setActiveTab('definition'); - }); - } + const initialArgs: Record = {}; + procedure.arguments.forEach((argName) => { + initialArgs[argName] = null; + }); + queueMicrotask(() => { + setJsonArgs(JSON.stringify(initialArgs, null, 2)); + setJsonError(null); + setExecutionResult(null); + setActiveTab('definition'); + }); }, [procedure]); - if (procedure === null) return null; - const handleJsonChange = (val: string | undefined) => { if (val === undefined) { return; @@ -55,7 +51,7 @@ export default function ProcedureModal({ return; } try { - const parsed = JSON.parse(val); + const parsed: unknown = JSON.parse(val); if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { setJsonError('Root element must be a valid JSON Object { ... }'); } else { @@ -79,14 +75,14 @@ export default function ProcedureModal({ setExecutionResult(null); try { - const response = await emit('RUN_PROCEDURE', { + const result: Result = await emit('RUN_PROCEDURE', { id: activeId, scope, name: procedure.name, args: parsedArgs - }, true) as Result; + }, true); - setExecutionResult(response); + setExecutionResult(result); } catch (err: unknown) { setExecutionResult({ error: errStr(err, "An unknown network error occurred during procedure execution."), @@ -238,7 +234,7 @@ export default function ProcedureModal({ variant="solid" loading={isRunning} disabled={jsonError !== null} - onClick={handleExecuteProcedure} + onClick={() => { void handleExecuteProcedure(); }} style={{ cursor: jsonError ? 'not-allowed' : 'pointer' }} > diff --git a/src/components/studio/ProceduresPanel.tsx b/src/components/studio/ProceduresPanel.tsx index f1fac79..bce4ce6 100644 --- a/src/components/studio/ProceduresPanel.tsx +++ b/src/components/studio/ProceduresPanel.tsx @@ -23,8 +23,8 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { setIsLoading(true); setFetchError(null); try { - const response = await emit('FETCH_PROCEDURES', { id: activeId, scope }) as Procedure[]; - setProcedures(response || []); + const response: Procedure[] = await emit('FETCH_PROCEDURES', { id: activeId, scope }); + setProcedures(response); } catch (err: unknown) { console.error("Failed to fetch procedures:", err); setFetchError(errStr(err, "Failed to fetch procedures.")); @@ -34,7 +34,7 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { }, [activeId, emit, scope]); useEffect(() => { - queueMicrotask(fetchProcedures); + queueMicrotask(() => { void fetchProcedures(); }); }, [fetchProcedures]); const filtered = useMemo(() => { @@ -44,8 +44,8 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { } return procedures.filter((proc) => { - const nameMatch = proc.name?.toLowerCase().includes(cleanedQuery); - const docString = proc.doc || ''; + const nameMatch = proc.name.toLowerCase().includes(cleanedQuery); + const docString = proc.doc ?? ''; const docMatch = docString.toLowerCase().includes(cleanedQuery); return nameMatch || docMatch; @@ -64,7 +64,7 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { variant="soft" color="gray" disabled={isLoading} - onClick={fetchProcedures} + onClick={() => { void fetchProcedures(); }} style={{ cursor: isLoading ? 'not-allowed' : 'pointer' }} > @@ -76,7 +76,7 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" > @@ -88,7 +88,7 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -160,7 +160,7 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { borderColor: 'var(--gray-4)', cursor: 'pointer', }} - onClick={() => setViewProcedure(procedure)} + onClick={() => { setViewProcedure(procedure); }} > @@ -227,7 +227,7 @@ export default function ProceduresPanel({ scope }: ProceduresPanelProps) { {viewProcedure && ( setViewProcedure(null)} + onClose={() => { setViewProcedure(null); }} scope={scope} procedure={viewProcedure} /> diff --git a/src/components/studio/QueryVarsDialog.tsx b/src/components/studio/QueryVarsDialog.tsx index 37c77e0..7b6666c 100644 --- a/src/components/studio/QueryVarsDialog.tsx +++ b/src/components/studio/QueryVarsDialog.tsx @@ -19,7 +19,7 @@ export default function QueryVarsDialog({ const [isValid, setIsValid] = useState(true); const handleEditorChange = (val: string | undefined) => { - const content = val || ''; + const content = val ?? ''; setLocalJson(content); // Quick inline validation check to disable the save button if the user types broken JSON diff --git a/src/components/studio/RenameFileDialog.tsx b/src/components/studio/RenameFileDialog.tsx index e82d7a8..e8106dd 100644 --- a/src/components/studio/RenameFileDialog.tsx +++ b/src/components/studio/RenameFileDialog.tsx @@ -11,7 +11,6 @@ interface RenameFileDialogProps { } export default function RenameFileDialog({ - isOpen, onOpenChange, filename, existingFiles, @@ -21,20 +20,18 @@ export default function RenameFileDialog({ const inputRef = useRef(null); useEffect(() => { - if (isOpen) { - setTimeout(() => { - if (inputRef.current) { - inputRef.current.focus(); - const dotIndex = filename.lastIndexOf('.'); - if (dotIndex > 0) { - inputRef.current.setSelectionRange(0, dotIndex); - } else { - inputRef.current.select(); - } + setTimeout(() => { + if (inputRef.current) { + inputRef.current.focus(); + const dotIndex = filename.lastIndexOf('.'); + if (dotIndex > 0) { + inputRef.current.setSelectionRange(0, dotIndex); + } else { + inputRef.current.select(); } - }, 50); - } - }, [isOpen, filename]); + } + }, 50); + }, [filename]); // Validation rules const trimmedName = newName.trim(); @@ -55,7 +52,7 @@ export default function RenameFileDialog({ }; return ( - + Rename File @@ -68,7 +65,7 @@ export default function RenameFileDialog({ setNewName(e.target.value)} + onChange={(e) => { setNewName(e.target.value); }} placeholder="filename.ti" size="2" mb="4" diff --git a/src/components/studio/RoomJoinModal.tsx b/src/components/studio/RoomJoinModal.tsx index 84031be..9dc6171 100644 --- a/src/components/studio/RoomJoinModal.tsx +++ b/src/components/studio/RoomJoinModal.tsx @@ -64,7 +64,7 @@ export default function RoomJoinModal({ if (mode === 'view') { return; // Safety guard } - const rawCode = val || ''; + const rawCode = val ?? ''; setCode(rawCode); validate(name, rawCode); }; diff --git a/src/components/studio/RoomsPanel.tsx b/src/components/studio/RoomsPanel.tsx index 2ac1d8c..130df14 100644 --- a/src/components/studio/RoomsPanel.tsx +++ b/src/components/studio/RoomsPanel.tsx @@ -44,7 +44,7 @@ export default function RoomsPanel({ scope }: RoomsPanelProps) { await refreshRooms(); setIsRefreshing(false); }; - refresh(); + void refresh(); }; const handleJoinRoomClick = (room: Room | null) => { @@ -69,9 +69,9 @@ export default function RoomsPanel({ scope }: RoomsPanelProps) { variant="soft" color="gray" onClick={handleRefreshClick} - style={{ cursor: isRefreshing ? 'not-allowed' : 'pointer' }} + className="cursor-pointer" > - +
@@ -79,7 +79,7 @@ export default function RoomsPanel({ scope }: RoomsPanelProps) { size="1" variant="soft" color="iris" - onClick={() => handleJoinRoomClick(null)} + onClick={() => { handleJoinRoomClick(null); }} className="cursor-pointer" > @@ -120,7 +120,7 @@ export default function RoomsPanel({ scope }: RoomsPanelProps) { align="center" gap="2" style={{ cursor: 'pointer', flexGrow: 1, minWidth: 0 }} - onClick={() => handleJoinRoomClick(room)} + onClick={() => { handleJoinRoomClick(room); }} > {isWorking ? ( @@ -149,7 +149,7 @@ export default function RoomsPanel({ scope }: RoomsPanelProps) { variant="ghost" color="gray" highContrast - onClick={() => handleLeaveRoom(room.name)} + onClick={() => { void handleLeaveRoom(room.name); }} style={{ cursor: 'pointer', flexShrink: 0 }} > @@ -185,7 +185,7 @@ export default function RoomsPanel({ scope }: RoomsPanelProps) { onOpenChange={setIsRoomJoinOpen} scope={scope} existingRooms={filteredRooms} - onJoin={selectedRoom === null ? handleOnJoin : handleOnUpdate} + onJoin={(name, code) => { void (selectedRoom === null ? handleOnJoin : handleOnUpdate)(name, code); }} room={selectedRoom} /> )} diff --git a/src/components/studio/ScopeSelector.tsx b/src/components/studio/ScopeSelector.tsx index 8e3baef..1263890 100644 --- a/src/components/studio/ScopeSelector.tsx +++ b/src/components/studio/ScopeSelector.tsx @@ -15,7 +15,7 @@ export default function ScopeSelector({ disabled }: ScopeSelectorProps) { (() => { - return activeFile?.result || null; + return activeFile?.result ?? null; }, [activeFile?.result]); return ( @@ -50,7 +50,7 @@ export default function StudioConsoleHeader({ setConsoleTab(value as StudioTab)} + onValueChange={(value) => { setConsoleTab(value as StudioTab); }} className="cursor-pointer" > diff --git a/src/components/studio/StudioEditor.tsx b/src/components/studio/StudioEditor.tsx index bf55554..4088a8b 100644 --- a/src/components/studio/StudioEditor.tsx +++ b/src/components/studio/StudioEditor.tsx @@ -14,7 +14,7 @@ export default function StudioEditor({ onCreateFile }: StudioEditorProps) { const { appearance } = useTheme(); const { isExecuting, execCode, activeScope, activeFile, activeContent, setActiveContent, storeFileContent } = useActiveWorkspace(); - const currentFilename = activeFile?.filename || ''; + const currentFilename = activeFile?.filename ?? ''; const fileContent = activeFile?.content ?? ''; const [localCode, setLocalCode] = useState(fileContent); @@ -33,7 +33,7 @@ export default function StudioEditor({ onCreateFile }: StudioEditorProps) { if (fileLeaving && fileLeaving !== 'unknown') { queueMicrotask(() => { console.log(`[Tab Switch Save] Safely deferred force-saving edits for ${fileLeaving}...`); - storeFileContent(fileLeaving, codeToSave); + void storeFileContent(fileLeaving, codeToSave); }); } @@ -88,14 +88,14 @@ export default function StudioEditor({ onCreateFile }: StudioEditorProps) { editorInstance.addAction({ id: 'thingsdb-execute-query', label: 'Execute ThingsDB Query', - keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter], - precondition: 'editorTextFocus', // 🌟 CRITICAL: Only triggers if the cursor is active inside THIS editor + keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode?.Enter], + precondition: 'editorTextFocus', run: () => { const currentCode = editorInstance.getValue(); const { filename: freshFilename, activeScope: freshScope, queryVars: freshVars } = executionContextRef.current; if (freshFilename && freshFilename.endsWith('.ti') && freshScope !== null) { - execCode(freshFilename, freshScope, currentCode, freshVars || null); + void execCode(freshFilename, freshScope, currentCode, freshVars ?? null); } } }); @@ -104,7 +104,7 @@ export default function StudioEditor({ onCreateFile }: StudioEditorProps) { id: 'thingsdb-export-file', label: 'Export Query File', keybindings: [monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS], - precondition: 'editorTextFocus', // 🌟 CRITICAL: Stops background instances from stealing this modal's hotkey + precondition: 'editorTextFocus', run: () => { const currentCode = editorInstance.getValue(); const { filename } = executionContextRef.current; @@ -148,7 +148,7 @@ export default function StudioEditor({ onCreateFile }: StudioEditorProps) { path={currentFilename} theme={appearance === 'dark' ? 'ticode-dark' : 'ticode-light'} value={localCode} - onChange={(val) => setLocalCode(val || '')} + onChange={(val) => { setLocalCode(val ?? ''); }} beforeMount={handleEditorWillMount} onMount={handleEditorDidMount} options={{ diff --git a/src/components/studio/StudioEventView.tsx b/src/components/studio/StudioEventView.tsx index ff042d7..aed8923 100644 --- a/src/components/studio/StudioEventView.tsx +++ b/src/components/studio/StudioEventView.tsx @@ -7,7 +7,7 @@ import EventArgsModal from './EventArgsModal'; export default function StudioEventView() { - const { emitEvents = [], clearEmitEvents } = useEvent(); + const { emitEvents, clearEmitEvents } = useEvent(); const [sortNewestFirst, setSortNewestFirst] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [isArgsModalOpen, setIsArgsModalOpen] = useState(false); @@ -90,7 +90,7 @@ export default function StudioEventView() { size="1" variant="soft" color="gray" - onClick={() => setSortNewestFirst((prev) => !prev)} + onClick={() => { setSortNewestFirst((prev) => !prev); }} className="cursor-pointer" > {sortNewestFirst ? ( @@ -116,7 +116,7 @@ export default function StudioEventView() { setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" > @@ -128,7 +128,7 @@ export default function StudioEventView() { size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -176,7 +176,7 @@ export default function StudioEventView() { size="1" variant="soft" color={item.args.length > 0 ? 'iris' : 'gray'} - onClick={() => handleViewArgs(item)} + onClick={() => { handleViewArgs(item); }} style={{ cursor: 'pointer', fontVariantNumeric: 'tabular-nums' }} > ARGS {item.args.length} diff --git a/src/components/studio/StudioLayout.tsx b/src/components/studio/StudioLayout.tsx index 82aa98b..e762e61 100644 --- a/src/components/studio/StudioLayout.tsx +++ b/src/components/studio/StudioLayout.tsx @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import { Box, Flex, Text } from '@radix-ui/themes'; +import { Box, Flex } from '@radix-ui/themes'; import { Group, Panel, Separator, useDefaultLayout, type Layout } from 'react-resizable-panels'; import { type PanelImperativeHandle } from 'react-resizable-panels'; import StudioTopBar from './StudioTopBar'; @@ -30,14 +30,12 @@ export default function StudioLayout() { storage: localStorage, }); - const [currentLayout, setCurrentLayout] = useState(verticalLayout.defaultLayout || ([] as unknown as Layout)); + const [currentLayout, setCurrentLayout] = useState(verticalLayout.defaultLayout ?? ([] as unknown as Layout)); const handleLayoutChange = (sizes: Layout) => { setCurrentLayout(sizes); - if (verticalLayout.onLayoutChanged) { - verticalLayout.onLayoutChanged(sizes); - } + verticalLayout.onLayoutChanged(sizes); if (sizes['editor-canvas-panel'] > 10 && isConsoleMaximized) { setIsConsoleMaximized(false); @@ -50,7 +48,7 @@ export default function StudioLayout() { if (isConsoleMaximized) { // RESTORE_SIZE - const restoreSize = cachedEditorSize !== null ? cachedEditorSize : 60; + const restoreSize = cachedEditorSize ?? 60; editorPanel.resize(`${restoreSize}%`); setIsConsoleMaximized(false); } else { @@ -130,10 +128,8 @@ export default function StudioLayout() { ) : consoleTab === 'events' ? ( - ) : consoleTab === 'log' ? ( - ) : ( - {"..."} + )}
diff --git a/src/components/studio/StudioLeftPanel.tsx b/src/components/studio/StudioLeftPanel.tsx index 1dca5f8..2d9c46d 100644 --- a/src/components/studio/StudioLeftPanel.tsx +++ b/src/components/studio/StudioLeftPanel.tsx @@ -32,7 +32,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi return ( file.filename.toLowerCase().includes(query) || - (file.content && file.content.toLowerCase().includes(query)) + file.content.toLowerCase().includes(query) ); }); @@ -48,12 +48,12 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi await refreshFiles(); setIsRefreshing(false); }; - refresh(); + void refresh(); }; const handleCreateConfirm = (filename: string) => { console.log(`Create new file request for ${filename}`); - createFile(filename); + void createFile(filename); }; const handleRenameClick = (e: React.MouseEvent, filename: string) => { @@ -64,7 +64,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi const handleRenameConfirm = (oldName: string, newName: string) => { console.log(`Rename file request from ${oldName} to ${newName}`); - renameFile(oldName, newName); + void renameFile(oldName, newName); }; const handleDeleteClick = (e: React.MouseEvent, filename: string) => { @@ -75,7 +75,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi const handleDeleteConfirm = (filename: string) => { console.log(`Delete file request for ${filename}`); - deleteFile(filename); + void deleteFile(filename); }; const handleSelectFile = (_: React.MouseEvent, filename: string) => { @@ -128,7 +128,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi setSearchQuery(e.target.value)} + onChange={(e) => { setSearchQuery(e.target.value); }} size="1" > @@ -140,7 +140,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi size="1" variant="ghost" color="gray" - onClick={() => setSearchQuery('')} + onClick={() => { setSearchQuery(''); }} style={{ cursor: 'pointer', height: '16px', width: '16px' }} > @@ -185,7 +185,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi width: '100%', transition: 'background-color 0.2s ease, color 0.2s ease', }} - onClick={(e) => handleSelectFile(e, file.filename)} + onClick={(e) => { handleSelectFile(e, file.filename); }} > @@ -220,7 +220,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi variant="ghost" color="gray" title="Rename File" - onClick={(e) => handleRenameClick(e, file.filename)} + onClick={(e) => { handleRenameClick(e, file.filename); }} style={{ cursor: 'pointer', height: '18px', width: '18px' }} > @@ -230,7 +230,7 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi variant="ghost" color="red" title="Delete File" - onClick={(e) => handleDeleteClick(e, file.filename)} + onClick={(e) => { handleDeleteClick(e, file.filename); }} style={{ cursor: 'pointer', height: '18px', width: '18px' }} > @@ -264,24 +264,25 @@ export default function StudioLeftPanel({ isCreateOpen, setIsCreateOpen }: Studi onCreate={handleCreateConfirm} /> )} - file.filename)} - onRename={handleRenameConfirm} - /> - handleDeleteConfirm(fileToDelete)} - /> + {isRenameOpen && ( + file.filename)} + onRename={handleRenameConfirm} + /> + )} + {isDeleteOpen && ( + { handleDeleteConfirm(fileToDelete); }} + /> + )} ); } \ No newline at end of file diff --git a/src/components/studio/StudioLogView.tsx b/src/components/studio/StudioLogView.tsx index 9cbe17a..d5c39b0 100644 --- a/src/components/studio/StudioLogView.tsx +++ b/src/components/studio/StudioLogView.tsx @@ -13,7 +13,7 @@ const getLogTypeMeta = (code: number) => { }; export default function StudioLogView() { - const { warnings = [], clearWarnings } = useEvent(); + const { warnings, clearWarnings } = useEvent(); const [sortNewestFirst, setSortNewestFirst] = useState(true); // Parse, format, and sort the logs based on user selection @@ -74,7 +74,7 @@ export default function StudioLogView() { size="1" variant="soft" color="gray" - onClick={() => setSortNewestFirst((prev) => !prev)} + onClick={() => { setSortNewestFirst((prev) => !prev); }} className="cursor-pointer" > {sortNewestFirst ? ( diff --git a/src/components/studio/StudioResultView.tsx b/src/components/studio/StudioResultView.tsx index a227ef6..9e236d8 100644 --- a/src/components/studio/StudioResultView.tsx +++ b/src/components/studio/StudioResultView.tsx @@ -13,7 +13,7 @@ export default function StudioResultView() { const [viewRawString, setViewRawString] = useState(false); const result = useMemo(() => { - return activeFile?.result || null; + return activeFile?.result ?? null; }, [activeFile?.result]); const [prevResult, setPrevResult] = useState(result); @@ -64,7 +64,7 @@ export default function StudioResultView() { try { await navigator.clipboard.writeText(outputContent); setCopied(true); - setTimeout(() => setCopied(false), 2000); // Reset icon after 2 seconds + setTimeout(() => { setCopied(false); }, 2000); // Reset icon after 2 seconds } catch (err) { console.error('Failed to copy execution result payload:', err); } @@ -150,7 +150,7 @@ export default function StudioResultView() { size="1" variant={viewRawString ? "solid" : "soft"} color="iris" - onClick={() => setViewRawString(!viewRawString)} + onClick={() => { setViewRawString(!viewRawString); }} style={{ cursor: 'pointer', boxShadow: 'var(--shadow-1)', pointerEvents: 'auto' }} > {viewRawString ? : } @@ -187,7 +187,7 @@ export default function StudioResultView() { variant="soft" color={copied ? "green" : "gray"} highContrast={!copied} - onClick={handleCopyToClipboard} + onClick={() => { void handleCopyToClipboard(); }} style={{ cursor: 'pointer', boxShadow: 'var(--shadow-1)', diff --git a/src/components/studio/StudioTopBar.tsx b/src/components/studio/StudioTopBar.tsx index e5f5bd1..291534c 100644 --- a/src/components/studio/StudioTopBar.tsx +++ b/src/components/studio/StudioTopBar.tsx @@ -10,6 +10,9 @@ import { Search } from '..'; import { SearchIndexType, type SearchRecord } from '../../types'; import ThingExplorerModal from '../ThingExplorerModal'; import MyUserModal from './MyUserModal'; +import DiagramLauncher from '../diagram/DiagramLauncher'; +import { isDialogOpen } from '../../utils'; + export default function StudioTopBar() { const { status, emit } = useWebSocket(); @@ -22,8 +25,8 @@ export default function StudioTopBar() { const [isExplorerOpen, setIsExplorerOpen] = useState(false); const [isMyUserOpen, setIsMyUserOpen] = useState(false); - const isTiCode = activeFilename && activeFilename.endsWith('.ti'); - const isCollectionScope = activeScope && activeScope.startsWith('@collection:'); + const isTiCode = activeFilename?.endsWith('.ti') ?? false; + const isCollectionScope = activeScope?.startsWith('@collection:') ?? false; const handleLogout = async () => { if (status === 'connected') { @@ -39,7 +42,7 @@ export default function StudioTopBar() { const handleExecuteCode = () => { if (!activeFile || !activeScope || activeContent === null) return; - execCode(activeFile.filename, activeScope, activeContent, activeFile.queryVars || null); + void execCode(activeFile.filename, activeScope, activeContent, activeFile.queryVars ?? null); }; const handleRefreshScopes = async () => { @@ -54,28 +57,33 @@ export default function StudioTopBar() { const handleSearchSelect = (selection: SearchRecord) => { if (selection.type === SearchIndexType.File) { setActiveFile(selection.name); - } else if (selection.type === SearchIndexType.Scope) { - setActiveScopeState(selection.name); + } else { + setActiveScopeState(selection.name); // Scope } }; + + useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.ctrlKey && event.key === 'p') { event.preventDefault(); - const radixDialogExists = document.querySelector('[data-state="open"][class*="DialogContent"]'); - const radixOverlayExists = document.querySelector('[class*="DialogOverlay"]'); - - if (!isSearchOpen && !radixDialogExists && !radixOverlayExists) { + if (!isSearchOpen && !isDialogOpen()) { setIsSearchOpen(true); } } + if (event.ctrlKey && event.key === 'e') { + event.preventDefault(); + if (!isExplorerOpen && !isDialogOpen()) { + setIsExplorerOpen(true); + } + } }; window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('keydown', handleKeyDown); }; - }, [isSearchOpen]); + }, [isSearchOpen, isExplorerOpen]); return ( setIsAboutOpen(true)} + onClick={() => { setIsAboutOpen(true); }} className="cursor-pointer rounded-[var(--radius-2)] p-1 -m-1 inline-flex items-center select-none" title="About ThingsCode" > @@ -135,7 +143,7 @@ export default function StudioTopBar() { color="gray" size="2" disabled={!isTiCode || isRefreshing} - onClick={() => setIsConfigOpen(true)} + onClick={() => { setIsConfigOpen(true); }} title="Edit Runtime Arguments" className={!isTiCode || isRefreshing ? 'cursor-not-allowed' : 'cursor-pointer'} > @@ -149,7 +157,7 @@ export default function StudioTopBar() { color="gray" size="2" disabled={isRefreshing || loading} - onClick={() => handleRefreshScopes()} + onClick={() => { void handleRefreshScopes(); }} title="Refresh Scopes" className={isRefreshing || loading ? 'cursor-not-allowed' : 'cursor-pointer'} > @@ -163,13 +171,20 @@ export default function StudioTopBar() { color="gray" size="2" disabled={!isCollectionScope || isRefreshing || loading || isExplorerOpen} - onClick={() => setIsExplorerOpen(true)} - title="Open Thing Explorer" + onClick={() => { setIsExplorerOpen(true); }} + title="Open Thing Explorer (Ctrl+e)" className={!isCollectionScope || isRefreshing || loading || isExplorerOpen ? 'cursor-not-allowed' : 'cursor-pointer'} > + + + + {workspace.type && } {workspace.type === 'production' && ( @@ -196,8 +211,8 @@ export default function StudioTopBar() { {isConfigOpen && ( updateQueryVars(activeFile?.filename || 'unknown.ti', validJson)} + configJson={activeFile?.queryVars ?? "{ }"} + onSave={(validJson) => { void updateQueryVars(activeFile?.filename ?? 'unknown.ti', validJson); }} /> )} @@ -246,7 +261,7 @@ export default function StudioTopBar() {