@rendiv/studio 0.1.2 → 0.1.4
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/apply-overrides.d.ts +32 -0
- package/dist/apply-overrides.d.ts.map +1 -0
- package/dist/apply-overrides.js +388 -0
- package/dist/apply-overrides.js.map +1 -0
- package/dist/vite-plugin-studio.d.ts.map +1 -1
- package/dist/vite-plugin-studio.js +179 -0
- package/dist/vite-plugin-studio.js.map +1 -1
- package/package.json +6 -3
- package/ui/Preview.tsx +1 -0
- package/ui/RenderQueue.tsx +15 -55
- package/ui/StudioApp.tsx +368 -32
- package/ui/Terminal.tsx +266 -0
- package/ui/TimelineEditor.tsx +573 -0
- package/ui/TopBar.tsx +9 -9
- package/ui/timeline/track-layout.ts +94 -0
- package/ui/timeline/types.ts +39 -0
- package/ui/timeline/use-timeline-drag.ts +98 -0
- package/ui/timeline/use-timeline-zoom.ts +65 -0
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
|
+
|