@rendiv/studio 0.1.3 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/scaffold-project.d.ts +10 -0
- package/dist/scaffold-project.d.ts.map +1 -0
- package/dist/scaffold-project.js +181 -0
- package/dist/scaffold-project.js.map +1 -0
- package/dist/start-studio-workspace.d.ts +16 -0
- package/dist/start-studio-workspace.d.ts.map +1 -0
- package/dist/start-studio-workspace.js +110 -0
- package/dist/start-studio-workspace.js.map +1 -0
- package/dist/start-studio.d.ts +5 -0
- package/dist/start-studio.d.ts.map +1 -1
- package/dist/start-studio.js +8 -5
- package/dist/start-studio.js.map +1 -1
- package/dist/studio-entry-code.d.ts +7 -2
- package/dist/studio-entry-code.d.ts.map +1 -1
- package/dist/studio-entry-code.js +19 -4
- package/dist/studio-entry-code.js.map +1 -1
- package/dist/vite-plugin-studio.d.ts +4 -0
- package/dist/vite-plugin-studio.d.ts.map +1 -1
- package/dist/vite-plugin-studio.js +135 -28
- package/dist/vite-plugin-studio.js.map +1 -1
- package/dist/workspace-entry-code.d.ts +12 -0
- package/dist/workspace-entry-code.d.ts.map +1 -0
- package/dist/workspace-entry-code.js +38 -0
- package/dist/workspace-entry-code.js.map +1 -0
- package/dist/workspace-picker-server.d.ts +27 -0
- package/dist/workspace-picker-server.d.ts.map +1 -0
- package/dist/workspace-picker-server.js +199 -0
- package/dist/workspace-picker-server.js.map +1 -0
- package/package.json +8 -3
- package/ui/RenderQueue.tsx +15 -55
- package/ui/StudioApp.tsx +163 -18
- package/ui/Terminal.tsx +266 -0
- package/ui/TopBar.tsx +40 -10
- package/ui/WorkspacePicker.tsx +423 -0
package/ui/RenderQueue.tsx
CHANGED
|
@@ -19,8 +19,6 @@ export interface RenderJob {
|
|
|
19
19
|
|
|
20
20
|
interface RenderQueueProps {
|
|
21
21
|
jobs: RenderJob[];
|
|
22
|
-
open: boolean;
|
|
23
|
-
onToggle: () => void;
|
|
24
22
|
onCancel: (jobId: string) => void;
|
|
25
23
|
onRemove: (jobId: string) => void;
|
|
26
24
|
onClear: () => void;
|
|
@@ -77,13 +75,10 @@ function overallProgress(job: RenderJob): number {
|
|
|
77
75
|
|
|
78
76
|
export const RenderQueue: React.FC<RenderQueueProps> = ({
|
|
79
77
|
jobs,
|
|
80
|
-
open,
|
|
81
|
-
onToggle,
|
|
82
78
|
onCancel,
|
|
83
79
|
onRemove,
|
|
84
80
|
onClear,
|
|
85
81
|
}) => {
|
|
86
|
-
const activeCount = jobs.filter((j) => j.status === 'queued' || j.status === 'bundling' || j.status === 'rendering' || j.status === 'encoding').length;
|
|
87
82
|
const hasFinished = jobs.some((j) => j.status === 'done' || j.status === 'error' || j.status === 'cancelled');
|
|
88
83
|
|
|
89
84
|
const handleCancel = useCallback((e: React.MouseEvent, jobId: string) => {
|
|
@@ -96,41 +91,21 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
|
|
96
91
|
onRemove(jobId);
|
|
97
92
|
}, [onRemove]);
|
|
98
93
|
|
|
99
|
-
if (!open) return null;
|
|
100
|
-
|
|
101
94
|
return (
|
|
102
|
-
<div style={
|
|
103
|
-
{/*
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
Render Queue
|
|
107
|
-
{activeCount > 0 && (
|
|
108
|
-
<span style={{ marginLeft: 6, color: colors.accent, fontWeight: 600 }}>
|
|
109
|
-
({activeCount})
|
|
110
|
-
</span>
|
|
111
|
-
)}
|
|
112
|
-
</span>
|
|
113
|
-
<div style={{ display: 'flex', gap: 8 }}>
|
|
114
|
-
{hasFinished && (
|
|
115
|
-
<button
|
|
116
|
-
type="button"
|
|
117
|
-
style={clearBtnStyle}
|
|
118
|
-
onClick={onClear}
|
|
119
|
-
title="Clear finished jobs"
|
|
120
|
-
>
|
|
121
|
-
Clear
|
|
122
|
-
</button>
|
|
123
|
-
)}
|
|
95
|
+
<div style={contentStyle}>
|
|
96
|
+
{/* Actions bar */}
|
|
97
|
+
{hasFinished && (
|
|
98
|
+
<div style={actionsBarStyle}>
|
|
124
99
|
<button
|
|
125
100
|
type="button"
|
|
126
|
-
style={
|
|
127
|
-
onClick={
|
|
128
|
-
title="
|
|
101
|
+
style={clearBtnStyle}
|
|
102
|
+
onClick={onClear}
|
|
103
|
+
title="Clear finished jobs"
|
|
129
104
|
>
|
|
130
|
-
|
|
105
|
+
Clear finished
|
|
131
106
|
</button>
|
|
132
107
|
</div>
|
|
133
|
-
|
|
108
|
+
)}
|
|
134
109
|
|
|
135
110
|
{/* Job list */}
|
|
136
111
|
<div style={listStyle}>
|
|
@@ -221,23 +196,17 @@ export const RenderQueue: React.FC<RenderQueueProps> = ({
|
|
|
221
196
|
|
|
222
197
|
// Styles
|
|
223
198
|
|
|
224
|
-
const
|
|
225
|
-
width: 300,
|
|
226
|
-
minWidth: 300,
|
|
227
|
-
height: '100%',
|
|
228
|
-
backgroundColor: colors.surface,
|
|
229
|
-
borderLeft: `1px solid ${colors.border}`,
|
|
199
|
+
const contentStyle: React.CSSProperties = {
|
|
230
200
|
display: 'flex',
|
|
231
201
|
flexDirection: 'column',
|
|
232
|
-
|
|
202
|
+
flex: 1,
|
|
203
|
+
overflow: 'hidden',
|
|
233
204
|
};
|
|
234
205
|
|
|
235
|
-
const
|
|
206
|
+
const actionsBarStyle: React.CSSProperties = {
|
|
236
207
|
display: 'flex',
|
|
237
|
-
justifyContent: '
|
|
238
|
-
|
|
239
|
-
padding: '12px 16px',
|
|
240
|
-
color: colors.textSecondary,
|
|
208
|
+
justifyContent: 'flex-end',
|
|
209
|
+
padding: '6px 8px',
|
|
241
210
|
borderBottom: `1px solid ${colors.border}`,
|
|
242
211
|
flexShrink: 0,
|
|
243
212
|
};
|
|
@@ -277,15 +246,6 @@ const progressBarStyle: React.CSSProperties = {
|
|
|
277
246
|
transition: 'width 0.3s ease',
|
|
278
247
|
};
|
|
279
248
|
|
|
280
|
-
const closeBtnStyle: React.CSSProperties = {
|
|
281
|
-
background: 'none',
|
|
282
|
-
border: 'none',
|
|
283
|
-
color: colors.textSecondary,
|
|
284
|
-
cursor: 'pointer',
|
|
285
|
-
fontSize: 12,
|
|
286
|
-
padding: '2px 4px',
|
|
287
|
-
};
|
|
288
|
-
|
|
289
249
|
const clearBtnStyle: React.CSSProperties = {
|
|
290
250
|
background: 'none',
|
|
291
251
|
border: `1px solid ${colors.border}`,
|
package/ui/StudioApp.tsx
CHANGED
|
@@ -13,10 +13,13 @@ import { TopBar } from './TopBar';
|
|
|
13
13
|
import { Timeline } from './Timeline';
|
|
14
14
|
import { TimelineEditor } from './TimelineEditor';
|
|
15
15
|
import { RenderQueue, type RenderJob } from './RenderQueue';
|
|
16
|
-
import {
|
|
16
|
+
import { Terminal } from './Terminal';
|
|
17
|
+
import { layoutStyles, scrollbarCSS, colors, fonts } from './styles';
|
|
17
18
|
|
|
18
19
|
// Read the entry point from the generated code's data attribute (set by studio-entry-code)
|
|
19
20
|
const ENTRY_POINT = (window as Record<string, unknown>).__RENDIV_STUDIO_ENTRY__ as string ?? 'src/index.tsx';
|
|
21
|
+
// When set, Studio was launched from a workspace and can navigate back
|
|
22
|
+
const WORKSPACE_DIR = (window as unknown as Record<string, unknown>).__RENDIV_WORKSPACE_DIR__ as string | undefined;
|
|
20
23
|
|
|
21
24
|
const ViewToggle: React.FC<{ view: 'editor' | 'tree'; onChange: (v: 'editor' | 'tree') => void }> = ({ view, onChange }) => (
|
|
22
25
|
<div style={{ display: 'flex', gap: 2, padding: '2px', backgroundColor: '#0d1117', borderRadius: 6 }}>
|
|
@@ -73,9 +76,22 @@ const StudioApp: React.FC = () => {
|
|
|
73
76
|
|
|
74
77
|
// Render queue state (server-driven)
|
|
75
78
|
const [renderJobs, setRenderJobs] = useState<RenderJob[]>([]);
|
|
76
|
-
const [queueOpen, setQueueOpen] = useState(false);
|
|
77
79
|
const hasActiveRef = useRef(false);
|
|
78
80
|
|
|
81
|
+
// Right panel state — tabbed panel for Queue and Agent
|
|
82
|
+
const [rightPanel, setRightPanel] = useState<'queue' | 'agent' | null>(() => {
|
|
83
|
+
const stored = localStorage.getItem('rendiv-studio:right-panel');
|
|
84
|
+
if (stored === 'queue' || stored === 'agent') return stored;
|
|
85
|
+
return null;
|
|
86
|
+
});
|
|
87
|
+
const [rightPanelWidth, setRightPanelWidth] = useState(() => {
|
|
88
|
+
const stored = localStorage.getItem('rendiv-studio:right-panel-width');
|
|
89
|
+
return stored ? Number(stored) : 360;
|
|
90
|
+
});
|
|
91
|
+
const isDraggingPanel = useRef(false);
|
|
92
|
+
const panelDragStartX = useRef(0);
|
|
93
|
+
const panelDragStartWidth = useRef(0);
|
|
94
|
+
|
|
79
95
|
const handleResizeMouseDown = useCallback((e: React.MouseEvent) => {
|
|
80
96
|
e.preventDefault();
|
|
81
97
|
isDraggingTimeline.current = true;
|
|
@@ -83,11 +99,23 @@ const StudioApp: React.FC = () => {
|
|
|
83
99
|
dragStartHeight.current = timelineHeight;
|
|
84
100
|
}, [timelineHeight]);
|
|
85
101
|
|
|
102
|
+
const handlePanelResizeMouseDown = useCallback((e: React.MouseEvent) => {
|
|
103
|
+
e.preventDefault();
|
|
104
|
+
isDraggingPanel.current = true;
|
|
105
|
+
panelDragStartX.current = e.clientX;
|
|
106
|
+
panelDragStartWidth.current = rightPanelWidth;
|
|
107
|
+
}, [rightPanelWidth]);
|
|
108
|
+
|
|
86
109
|
useEffect(() => {
|
|
87
110
|
const handleMouseMove = (e: MouseEvent) => {
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
111
|
+
if (isDraggingTimeline.current) {
|
|
112
|
+
const delta = dragStartY.current - e.clientY;
|
|
113
|
+
setTimelineHeight(Math.max(120, dragStartHeight.current + delta));
|
|
114
|
+
}
|
|
115
|
+
if (isDraggingPanel.current) {
|
|
116
|
+
const delta = panelDragStartX.current - e.clientX;
|
|
117
|
+
setRightPanelWidth(Math.max(280, Math.min(800, panelDragStartWidth.current + delta)));
|
|
118
|
+
}
|
|
91
119
|
};
|
|
92
120
|
const handleMouseUp = () => {
|
|
93
121
|
if (isDraggingTimeline.current) {
|
|
@@ -97,6 +125,13 @@ const StudioApp: React.FC = () => {
|
|
|
97
125
|
return h;
|
|
98
126
|
});
|
|
99
127
|
}
|
|
128
|
+
if (isDraggingPanel.current) {
|
|
129
|
+
isDraggingPanel.current = false;
|
|
130
|
+
setRightPanelWidth((w) => {
|
|
131
|
+
localStorage.setItem('rendiv-studio:right-panel-width', String(w));
|
|
132
|
+
return w;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
100
135
|
};
|
|
101
136
|
window.addEventListener('mousemove', handleMouseMove);
|
|
102
137
|
window.addEventListener('mouseup', handleMouseUp);
|
|
@@ -351,7 +386,8 @@ const StudioApp: React.FC = () => {
|
|
|
351
386
|
totalFrames: selectedComposition.durationInFrames,
|
|
352
387
|
}),
|
|
353
388
|
});
|
|
354
|
-
|
|
389
|
+
setRightPanel('queue');
|
|
390
|
+
localStorage.setItem('rendiv-studio:right-panel-tab', 'queue');
|
|
355
391
|
}, [selectedComposition, inputProps]);
|
|
356
392
|
|
|
357
393
|
const handleCancelJob = useCallback((jobId: string) => {
|
|
@@ -366,8 +402,24 @@ const StudioApp: React.FC = () => {
|
|
|
366
402
|
fetch('/__rendiv_api__/render/queue/clear', { method: 'POST' });
|
|
367
403
|
}, []);
|
|
368
404
|
|
|
369
|
-
const
|
|
370
|
-
|
|
405
|
+
const handleBackToWorkspace = useCallback(() => {
|
|
406
|
+
if (!WORKSPACE_DIR) return;
|
|
407
|
+
fetch('/__rendiv_api__/workspace/back', { method: 'POST' }).catch(() => {});
|
|
408
|
+
// Server will restart in workspace picker mode; Vite HMR will reconnect + reload
|
|
409
|
+
}, []);
|
|
410
|
+
|
|
411
|
+
const handleTogglePanel = useCallback(() => {
|
|
412
|
+
setRightPanel((prev) => {
|
|
413
|
+
if (prev !== null) {
|
|
414
|
+
// Close panel
|
|
415
|
+
localStorage.setItem('rendiv-studio:right-panel', '');
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
// Open to last active tab (default 'queue')
|
|
419
|
+
const tab = (localStorage.getItem('rendiv-studio:right-panel-tab') as 'queue' | 'agent') || 'queue';
|
|
420
|
+
localStorage.setItem('rendiv-studio:right-panel', tab);
|
|
421
|
+
return tab;
|
|
422
|
+
});
|
|
371
423
|
}, []);
|
|
372
424
|
|
|
373
425
|
const queueCount = renderJobs.filter((j) =>
|
|
@@ -384,8 +436,10 @@ const StudioApp: React.FC = () => {
|
|
|
384
436
|
entryPoint={ENTRY_POINT}
|
|
385
437
|
onRender={handleAddRender}
|
|
386
438
|
queueCount={queueCount}
|
|
387
|
-
|
|
388
|
-
|
|
439
|
+
panelOpen={rightPanel !== null}
|
|
440
|
+
onTogglePanel={handleTogglePanel}
|
|
441
|
+
workspaceDir={WORKSPACE_DIR}
|
|
442
|
+
onBackToWorkspace={handleBackToWorkspace}
|
|
389
443
|
/>
|
|
390
444
|
|
|
391
445
|
<div style={layoutStyles.body}>
|
|
@@ -411,14 +465,67 @@ const StudioApp: React.FC = () => {
|
|
|
411
465
|
</div>
|
|
412
466
|
)}
|
|
413
467
|
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
468
|
+
{/* Right panel — tabbed: Queue / Agent */}
|
|
469
|
+
{rightPanel !== null && (
|
|
470
|
+
<div style={{ ...rightPanelStyle, width: rightPanelWidth, minWidth: 280 }}>
|
|
471
|
+
<div
|
|
472
|
+
style={panelResizeHandleStyle}
|
|
473
|
+
onMouseDown={handlePanelResizeMouseDown}
|
|
474
|
+
/>
|
|
475
|
+
<div style={tabBarStyle}>
|
|
476
|
+
<div style={{ display: 'flex', gap: 2, padding: 2, backgroundColor: colors.bg, borderRadius: 6 }}>
|
|
477
|
+
{(['queue', 'agent'] as const).map((tab) => (
|
|
478
|
+
<button
|
|
479
|
+
key={tab}
|
|
480
|
+
onClick={() => {
|
|
481
|
+
setRightPanel(tab);
|
|
482
|
+
localStorage.setItem('rendiv-studio:right-panel', tab);
|
|
483
|
+
localStorage.setItem('rendiv-studio:right-panel-tab', tab);
|
|
484
|
+
}}
|
|
485
|
+
style={{
|
|
486
|
+
padding: '3px 10px',
|
|
487
|
+
fontSize: 11,
|
|
488
|
+
fontWeight: 500,
|
|
489
|
+
border: 'none',
|
|
490
|
+
borderRadius: 4,
|
|
491
|
+
cursor: 'pointer',
|
|
492
|
+
backgroundColor: rightPanel === tab ? colors.border : 'transparent',
|
|
493
|
+
color: rightPanel === tab ? colors.textPrimary : colors.textSecondary,
|
|
494
|
+
fontFamily: fonts.sans,
|
|
495
|
+
}}
|
|
496
|
+
>
|
|
497
|
+
{tab === 'queue' ? 'Queue' : 'Agent'}
|
|
498
|
+
{tab === 'queue' && queueCount > 0 && (
|
|
499
|
+
<span style={{ marginLeft: 4, color: colors.accent, fontWeight: 600 }}>({queueCount})</span>
|
|
500
|
+
)}
|
|
501
|
+
</button>
|
|
502
|
+
))}
|
|
503
|
+
</div>
|
|
504
|
+
<button
|
|
505
|
+
type="button"
|
|
506
|
+
onClick={() => {
|
|
507
|
+
setRightPanel(null);
|
|
508
|
+
localStorage.setItem('rendiv-studio:right-panel', '');
|
|
509
|
+
}}
|
|
510
|
+
style={tabCloseStyle}
|
|
511
|
+
title="Close panel"
|
|
512
|
+
>
|
|
513
|
+
{'\u2715'}
|
|
514
|
+
</button>
|
|
515
|
+
</div>
|
|
516
|
+
<div style={{ display: rightPanel === 'queue' ? 'flex' : 'none', flex: 1, flexDirection: 'column' as const, overflow: 'hidden' }}>
|
|
517
|
+
<RenderQueue
|
|
518
|
+
jobs={renderJobs}
|
|
519
|
+
onCancel={handleCancelJob}
|
|
520
|
+
onRemove={handleRemoveJob}
|
|
521
|
+
onClear={handleClearFinished}
|
|
522
|
+
/>
|
|
523
|
+
</div>
|
|
524
|
+
<div style={{ display: rightPanel === 'agent' ? 'flex' : 'none', flex: 1, flexDirection: 'column' as const, overflow: 'hidden' }}>
|
|
525
|
+
<Terminal open={rightPanel === 'agent'} />
|
|
526
|
+
</div>
|
|
527
|
+
</div>
|
|
528
|
+
)}
|
|
422
529
|
</div>
|
|
423
530
|
|
|
424
531
|
{/* Timeline — full-width resizable row */}
|
|
@@ -475,6 +582,44 @@ const StudioApp: React.FC = () => {
|
|
|
475
582
|
);
|
|
476
583
|
};
|
|
477
584
|
|
|
585
|
+
const rightPanelStyle: React.CSSProperties = {
|
|
586
|
+
position: 'relative',
|
|
587
|
+
height: '100%',
|
|
588
|
+
backgroundColor: colors.surface,
|
|
589
|
+
borderLeft: `1px solid ${colors.border}`,
|
|
590
|
+
display: 'flex',
|
|
591
|
+
flexDirection: 'column',
|
|
592
|
+
flexShrink: 0,
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
const panelResizeHandleStyle: React.CSSProperties = {
|
|
596
|
+
position: 'absolute',
|
|
597
|
+
top: 0,
|
|
598
|
+
left: 0,
|
|
599
|
+
width: 4,
|
|
600
|
+
height: '100%',
|
|
601
|
+
cursor: 'col-resize',
|
|
602
|
+
zIndex: 10,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
const tabBarStyle: React.CSSProperties = {
|
|
606
|
+
display: 'flex',
|
|
607
|
+
justifyContent: 'space-between',
|
|
608
|
+
alignItems: 'center',
|
|
609
|
+
padding: '6px 8px',
|
|
610
|
+
borderBottom: `1px solid ${colors.border}`,
|
|
611
|
+
flexShrink: 0,
|
|
612
|
+
};
|
|
613
|
+
|
|
614
|
+
const tabCloseStyle: React.CSSProperties = {
|
|
615
|
+
background: 'none',
|
|
616
|
+
border: 'none',
|
|
617
|
+
color: colors.textSecondary,
|
|
618
|
+
cursor: 'pointer',
|
|
619
|
+
fontSize: 12,
|
|
620
|
+
padding: '2px 4px',
|
|
621
|
+
};
|
|
622
|
+
|
|
478
623
|
export function createStudioApp(container: HTMLElement | null): void {
|
|
479
624
|
if (!container) {
|
|
480
625
|
throw new Error('Rendiv Studio: Could not find #root element');
|
package/ui/Terminal.tsx
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import React, { useRef, useEffect, useCallback, useState } from 'react';
|
|
2
|
+
import { Terminal as XTerm } from '@xterm/xterm';
|
|
3
|
+
import { FitAddon } from '@xterm/addon-fit';
|
|
4
|
+
import '@xterm/xterm/css/xterm.css';
|
|
5
|
+
import { colors, fonts } from './styles';
|
|
6
|
+
|
|
7
|
+
export interface TerminalProps {
|
|
8
|
+
open: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error';
|
|
12
|
+
|
|
13
|
+
export const Terminal: React.FC<TerminalProps> = ({ open }) => {
|
|
14
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const xtermRef = useRef<XTerm | null>(null);
|
|
16
|
+
const fitAddonRef = useRef<FitAddon | null>(null);
|
|
17
|
+
const [status, setStatus] = useState<TerminalStatus>('idle');
|
|
18
|
+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
19
|
+
const attachedRef = useRef(false);
|
|
20
|
+
|
|
21
|
+
// Create xterm instance once on mount
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
const term = new XTerm({
|
|
24
|
+
cursorBlink: true,
|
|
25
|
+
fontSize: 11,
|
|
26
|
+
fontFamily: fonts.mono,
|
|
27
|
+
theme: {
|
|
28
|
+
background: colors.bg,
|
|
29
|
+
foreground: colors.textPrimary,
|
|
30
|
+
cursor: colors.accent,
|
|
31
|
+
cursorAccent: colors.bg,
|
|
32
|
+
selectionBackground: 'rgba(88, 166, 255, 0.3)',
|
|
33
|
+
black: '#484f58',
|
|
34
|
+
red: '#ff7b72',
|
|
35
|
+
green: '#3fb950',
|
|
36
|
+
yellow: '#d29922',
|
|
37
|
+
blue: '#58a6ff',
|
|
38
|
+
magenta: '#bc8cff',
|
|
39
|
+
cyan: '#39c5cf',
|
|
40
|
+
white: '#b1bac4',
|
|
41
|
+
brightBlack: '#6e7681',
|
|
42
|
+
brightRed: '#ffa198',
|
|
43
|
+
brightGreen: '#56d364',
|
|
44
|
+
brightYellow: '#e3b341',
|
|
45
|
+
brightBlue: '#79c0ff',
|
|
46
|
+
brightMagenta: '#d2a8ff',
|
|
47
|
+
brightCyan: '#56d4dd',
|
|
48
|
+
brightWhite: '#f0f6fc',
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const fitAddon = new FitAddon();
|
|
53
|
+
term.loadAddon(fitAddon);
|
|
54
|
+
|
|
55
|
+
// Forward user keystrokes to server
|
|
56
|
+
term.onData((data) => {
|
|
57
|
+
const hot = (import.meta as any).hot;
|
|
58
|
+
hot?.send('rendiv:terminal-input', { data });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
xtermRef.current = term;
|
|
62
|
+
fitAddonRef.current = fitAddon;
|
|
63
|
+
|
|
64
|
+
return () => {
|
|
65
|
+
term.dispose();
|
|
66
|
+
xtermRef.current = null;
|
|
67
|
+
fitAddonRef.current = null;
|
|
68
|
+
attachedRef.current = false;
|
|
69
|
+
};
|
|
70
|
+
}, []);
|
|
71
|
+
|
|
72
|
+
// Listen for server events (once, independent of open state)
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const hot = (import.meta as any).hot;
|
|
75
|
+
if (!hot) return;
|
|
76
|
+
|
|
77
|
+
const onOutput = (data: { data: string }) => {
|
|
78
|
+
xtermRef.current?.write(data.data);
|
|
79
|
+
};
|
|
80
|
+
const onStarted = () => {
|
|
81
|
+
setStatus('running');
|
|
82
|
+
setErrorMessage(null);
|
|
83
|
+
};
|
|
84
|
+
const onExited = () => {
|
|
85
|
+
setStatus('exited');
|
|
86
|
+
};
|
|
87
|
+
const onError = (data: { message: string }) => {
|
|
88
|
+
setStatus('error');
|
|
89
|
+
setErrorMessage(data.message);
|
|
90
|
+
};
|
|
91
|
+
const onStatusResponse = (data: { running: boolean }) => {
|
|
92
|
+
if (data.running) setStatus('running');
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
hot.on('rendiv:terminal-output', onOutput);
|
|
96
|
+
hot.on('rendiv:terminal-started', onStarted);
|
|
97
|
+
hot.on('rendiv:terminal-exited', onExited);
|
|
98
|
+
hot.on('rendiv:terminal-error', onError);
|
|
99
|
+
hot.on('rendiv:terminal-status-response', onStatusResponse);
|
|
100
|
+
|
|
101
|
+
// Check if terminal is already running (re-attach after page refresh)
|
|
102
|
+
hot.send('rendiv:terminal-status', {});
|
|
103
|
+
|
|
104
|
+
return () => {
|
|
105
|
+
hot.off?.('rendiv:terminal-output', onOutput);
|
|
106
|
+
hot.off?.('rendiv:terminal-started', onStarted);
|
|
107
|
+
hot.off?.('rendiv:terminal-exited', onExited);
|
|
108
|
+
hot.off?.('rendiv:terminal-error', onError);
|
|
109
|
+
hot.off?.('rendiv:terminal-status-response', onStatusResponse);
|
|
110
|
+
};
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
// Attach/detach xterm to DOM when panel opens/closes
|
|
114
|
+
useEffect(() => {
|
|
115
|
+
if (!open || !containerRef.current || !xtermRef.current) return;
|
|
116
|
+
|
|
117
|
+
if (!attachedRef.current) {
|
|
118
|
+
xtermRef.current.open(containerRef.current);
|
|
119
|
+
attachedRef.current = true;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fit after a frame to ensure container has layout dimensions
|
|
123
|
+
requestAnimationFrame(() => {
|
|
124
|
+
fitAddonRef.current?.fit();
|
|
125
|
+
});
|
|
126
|
+
}, [open]);
|
|
127
|
+
|
|
128
|
+
// Resize terminal when container size changes
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!open || !containerRef.current) return;
|
|
131
|
+
|
|
132
|
+
const observer = new ResizeObserver(() => {
|
|
133
|
+
const term = xtermRef.current;
|
|
134
|
+
const fitAddon = fitAddonRef.current;
|
|
135
|
+
if (!term || !fitAddon) return;
|
|
136
|
+
|
|
137
|
+
fitAddon.fit();
|
|
138
|
+
const hot = (import.meta as any).hot;
|
|
139
|
+
hot?.send('rendiv:terminal-resize', { cols: term.cols, rows: term.rows });
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
observer.observe(containerRef.current);
|
|
143
|
+
return () => observer.disconnect();
|
|
144
|
+
}, [open]);
|
|
145
|
+
|
|
146
|
+
const handleStart = useCallback(() => {
|
|
147
|
+
setStatus('starting');
|
|
148
|
+
const term = xtermRef.current;
|
|
149
|
+
const hot = (import.meta as any).hot;
|
|
150
|
+
hot?.send('rendiv:terminal-start', {
|
|
151
|
+
cols: term?.cols ?? 80,
|
|
152
|
+
rows: term?.rows ?? 24,
|
|
153
|
+
});
|
|
154
|
+
}, []);
|
|
155
|
+
|
|
156
|
+
const handleRestart = useCallback(() => {
|
|
157
|
+
const hot = (import.meta as any).hot;
|
|
158
|
+
hot?.send('rendiv:terminal-stop', {});
|
|
159
|
+
xtermRef.current?.clear();
|
|
160
|
+
setTimeout(() => {
|
|
161
|
+
setStatus('starting');
|
|
162
|
+
const term = xtermRef.current;
|
|
163
|
+
hot?.send('rendiv:terminal-start', {
|
|
164
|
+
cols: term?.cols ?? 80,
|
|
165
|
+
rows: term?.rows ?? 24,
|
|
166
|
+
});
|
|
167
|
+
}, 300);
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
const showOverlay = status === 'idle' || status === 'error' || status === 'exited';
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div style={panelStyle}>
|
|
174
|
+
{/* Actions bar — only when there's something to act on */}
|
|
175
|
+
{(status === 'running' || status === 'exited') && (
|
|
176
|
+
<div style={actionsBarStyle}>
|
|
177
|
+
<button style={actionBtnStyle} onClick={handleRestart}>Restart</button>
|
|
178
|
+
</div>
|
|
179
|
+
)}
|
|
180
|
+
|
|
181
|
+
<div style={terminalBodyStyle}>
|
|
182
|
+
{showOverlay && (
|
|
183
|
+
<div style={overlayStyle}>
|
|
184
|
+
{status === 'error' && errorMessage && (
|
|
185
|
+
<div style={{ color: colors.error, fontSize: 13, marginBottom: 12, textAlign: 'center' as const }}>
|
|
186
|
+
{errorMessage}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
{status === 'exited' && (
|
|
190
|
+
<div style={{ color: colors.textSecondary, fontSize: 13, marginBottom: 12 }}>
|
|
191
|
+
Claude Code has exited
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
194
|
+
<button style={startBtnStyle} onClick={status === 'exited' ? handleRestart : handleStart}>
|
|
195
|
+
{status === 'exited' ? 'Restart Claude Code' : status === 'error' ? 'Retry' : 'Launch Claude Code'}
|
|
196
|
+
</button>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
<div ref={containerRef} style={xtermContainerStyle} />
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
const panelStyle: React.CSSProperties = {
|
|
206
|
+
display: 'flex',
|
|
207
|
+
flexDirection: 'column',
|
|
208
|
+
flex: 1,
|
|
209
|
+
overflow: 'hidden',
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const actionsBarStyle: React.CSSProperties = {
|
|
213
|
+
display: 'flex',
|
|
214
|
+
justifyContent: 'flex-end',
|
|
215
|
+
padding: '4px 8px',
|
|
216
|
+
borderBottom: `1px solid ${colors.border}`,
|
|
217
|
+
flexShrink: 0,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const terminalBodyStyle: React.CSSProperties = {
|
|
221
|
+
flex: 1,
|
|
222
|
+
position: 'relative',
|
|
223
|
+
backgroundColor: colors.bg,
|
|
224
|
+
overflow: 'hidden',
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
const xtermContainerStyle: React.CSSProperties = {
|
|
228
|
+
width: '100%',
|
|
229
|
+
height: '100%',
|
|
230
|
+
padding: '4px 0 4px 4px',
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
const overlayStyle: React.CSSProperties = {
|
|
234
|
+
position: 'absolute',
|
|
235
|
+
inset: 0,
|
|
236
|
+
display: 'flex',
|
|
237
|
+
flexDirection: 'column',
|
|
238
|
+
alignItems: 'center',
|
|
239
|
+
justifyContent: 'center',
|
|
240
|
+
zIndex: 10,
|
|
241
|
+
backgroundColor: colors.bg,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const startBtnStyle: React.CSSProperties = {
|
|
245
|
+
padding: '8px 20px',
|
|
246
|
+
fontSize: 13,
|
|
247
|
+
fontWeight: 600,
|
|
248
|
+
color: '#fff',
|
|
249
|
+
backgroundColor: colors.accentMuted,
|
|
250
|
+
border: 'none',
|
|
251
|
+
borderRadius: 6,
|
|
252
|
+
cursor: 'pointer',
|
|
253
|
+
fontFamily: fonts.sans,
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const actionBtnStyle: React.CSSProperties = {
|
|
257
|
+
background: 'none',
|
|
258
|
+
border: `1px solid ${colors.border}`,
|
|
259
|
+
color: colors.textSecondary,
|
|
260
|
+
cursor: 'pointer',
|
|
261
|
+
fontSize: 10,
|
|
262
|
+
padding: '2px 8px',
|
|
263
|
+
borderRadius: 4,
|
|
264
|
+
fontFamily: fonts.sans,
|
|
265
|
+
};
|
|
266
|
+
|