@dfosco/storyboard-react 4.2.0-beta.1 → 4.2.0-beta.17
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/package.json +5 -4
- package/src/AuthModal/AuthModal.jsx +6 -2
- package/src/BranchBar/BranchBar.jsx +17 -5
- package/src/BranchBar/BranchBar.module.css +11 -2
- package/src/CommandPalette/CommandPalette.jsx +267 -164
- package/src/CommandPalette/command-palette.css +130 -78
- package/src/Icon.jsx +112 -48
- package/src/Viewfinder.jsx +511 -61
- package/src/Viewfinder.module.css +414 -2
- package/src/canvas/CanvasPage.bridge.test.jsx +14 -6
- package/src/canvas/CanvasPage.dragdrop.test.jsx +10 -6
- package/src/canvas/CanvasPage.jsx +157 -174
- package/src/canvas/CanvasPage.module.css +0 -15
- package/src/canvas/CanvasPage.multiselect.test.jsx +10 -6
- package/src/canvas/ConnectorLayer.jsx +5 -5
- package/src/canvas/PageSelector.test.jsx +15 -6
- package/src/canvas/useCanvas.js +1 -1
- package/src/canvas/widgets/ActionWidget.jsx +200 -0
- package/src/canvas/widgets/ActionWidget.module.css +122 -0
- package/src/canvas/widgets/FigmaEmbed.jsx +97 -29
- package/src/canvas/widgets/FigmaEmbed.module.css +61 -0
- package/src/canvas/widgets/ImageWidget.jsx +1 -1
- package/src/canvas/widgets/LinkPreview.jsx +64 -5
- package/src/canvas/widgets/LinkPreview.module.css +127 -0
- package/src/canvas/widgets/MarkdownBlock.jsx +39 -17
- package/src/canvas/widgets/MarkdownBlock.module.css +123 -0
- package/src/canvas/widgets/PrototypeEmbed.jsx +183 -20
- package/src/canvas/widgets/PrototypeEmbed.module.css +117 -0
- package/src/canvas/widgets/PrototypeEmbed.test.jsx +2 -2
- package/src/canvas/widgets/SplitExpandModal.jsx +234 -0
- package/src/canvas/widgets/SplitExpandModal.module.css +335 -0
- package/src/canvas/widgets/SplitScreenTopBar.jsx +30 -0
- package/src/canvas/widgets/SplitScreenTopBar.module.css +58 -0
- package/src/canvas/widgets/StoryWidget.jsx +7 -4
- package/src/canvas/widgets/TerminalReadWidget.jsx +140 -0
- package/src/canvas/widgets/TerminalReadWidget.module.css +92 -0
- package/src/canvas/widgets/TerminalWidget.jsx +299 -49
- package/src/canvas/widgets/TerminalWidget.module.css +155 -1
- package/src/canvas/widgets/WidgetChrome.jsx +19 -14
- package/src/canvas/widgets/WidgetChrome.module.css +10 -0
- package/src/canvas/widgets/embedInteraction.test.jsx +24 -26
- package/src/canvas/widgets/expandUtils.js +188 -0
- package/src/canvas/widgets/index.js +5 -0
- package/src/canvas/widgets/snapshotDisplay.test.jsx +23 -71
- package/src/canvas/widgets/widgetConfig.js +19 -1
- package/src/hooks/useConfig.js +14 -0
- package/src/index.js +4 -0
- package/src/vite/data-plugin.js +264 -14
|
@@ -1,55 +1,98 @@
|
|
|
1
|
-
import { useRef, useEffect, useCallback, useState } from 'react'
|
|
1
|
+
import { useRef, useEffect, useCallback, useState, useMemo, forwardRef, useImperativeHandle } from 'react'
|
|
2
|
+
import { createPortal } from 'react-dom'
|
|
2
3
|
import { readProp } from './widgetProps.js'
|
|
3
4
|
import { schemas } from './widgetProps.js'
|
|
4
5
|
import { getTerminalConfig } from '@dfosco/storyboard-core'
|
|
6
|
+
import { ScreenNormalIcon } from '@primer/octicons-react'
|
|
7
|
+
import { useOverride } from '../../hooks/useOverride.js'
|
|
8
|
+
import { getSplitPaneLabel, findConnectedSplitTarget, buildSecondaryIframeUrl as buildSplitUrl, getPaneOrder } from './expandUtils.js'
|
|
9
|
+
import SplitScreenTopBar from './SplitScreenTopBar.jsx'
|
|
5
10
|
import ResizeHandle from './ResizeHandle.jsx'
|
|
6
11
|
import styles from './TerminalWidget.module.css'
|
|
7
12
|
import overlayStyles from './embedOverlay.module.css'
|
|
8
13
|
|
|
9
14
|
const terminalSchema = schemas['terminal']
|
|
10
15
|
|
|
11
|
-
/**
|
|
12
|
-
* Lazy-load ghostty-web to avoid bundling WASM in prod.
|
|
13
|
-
*/
|
|
14
16
|
let ghosttyPromise = null
|
|
15
17
|
function loadGhostty() {
|
|
16
18
|
if (!ghosttyPromise) {
|
|
17
|
-
ghosttyPromise = import('ghostty-web')
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
ghosttyPromise = import('ghostty-web')
|
|
20
|
+
.then(async (mod) => {
|
|
21
|
+
if (mod.init) await mod.init()
|
|
22
|
+
return mod
|
|
23
|
+
})
|
|
24
|
+
.catch((err) => {
|
|
25
|
+
ghosttyPromise = null
|
|
26
|
+
console.warn('[TerminalWidget] ghostty-web not available:', err.message)
|
|
27
|
+
return null
|
|
28
|
+
})
|
|
21
29
|
}
|
|
22
30
|
return ghosttyPromise
|
|
23
31
|
}
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
* Build the WebSocket URL for the terminal backend.
|
|
27
|
-
* Includes the base path (e.g. /branch--4.2.0/) so the proxy routes correctly.
|
|
28
|
-
* Passes canvasId as a query parameter for session scoping.
|
|
29
|
-
*/
|
|
30
|
-
function getWsUrl(sessionId, prettyName) {
|
|
33
|
+
function getWsUrl(sessionId, prettyName, startupCommand) {
|
|
31
34
|
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
32
35
|
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
33
36
|
const baseClean = base.endsWith('/') ? base : base + '/'
|
|
34
37
|
const canvasId = window.__storyboardCanvasBridgeState?.canvasId || 'unknown'
|
|
35
38
|
let url = `${protocol}//${location.host}${baseClean}_storyboard/terminal/${sessionId}?canvas=${encodeURIComponent(canvasId)}`
|
|
36
39
|
if (prettyName) url += `&name=${encodeURIComponent(prettyName)}`
|
|
40
|
+
if (startupCommand) url += `&startupCommand=${encodeURIComponent(startupCommand)}`
|
|
37
41
|
return url
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const padding = 24 // 12px each side
|
|
44
|
+
function calcDimensions(widthPx, heightPx, fontSize = 13) {
|
|
45
|
+
// Cell dimensions scale proportionally with font size.
|
|
46
|
+
// Base measurements at 13px: ~7.8px wide, ~17px tall.
|
|
47
|
+
const scale = fontSize / 13
|
|
48
|
+
const cellWidth = 7.8 * scale
|
|
49
|
+
const cellHeight = 17 * scale
|
|
50
|
+
const padding = 24
|
|
48
51
|
const cols = Math.max(10, Math.floor((widthPx - padding) / cellWidth))
|
|
49
52
|
const rows = Math.max(4, Math.floor((heightPx - padding) / cellHeight))
|
|
50
53
|
return { cols, rows }
|
|
51
54
|
}
|
|
52
55
|
|
|
56
|
+
const EMBED_TYPES = new Set(['prototype', 'story'])
|
|
57
|
+
|
|
58
|
+
function findConnectedEmbed(widgetId) {
|
|
59
|
+
const bridge = window.__storyboardCanvasBridgeState
|
|
60
|
+
if (!bridge?.connectors || !bridge?.widgets) return null
|
|
61
|
+
const connectedIds = new Set()
|
|
62
|
+
for (const c of bridge.connectors) {
|
|
63
|
+
if (c.start?.widgetId === widgetId) connectedIds.add(c.end?.widgetId)
|
|
64
|
+
if (c.end?.widgetId === widgetId) connectedIds.add(c.start?.widgetId)
|
|
65
|
+
}
|
|
66
|
+
for (const w of bridge.widgets) {
|
|
67
|
+
if (connectedIds.has(w.id) && EMBED_TYPES.has(w.type)) return w
|
|
68
|
+
}
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildEmbedUrl(widget) {
|
|
73
|
+
if (!widget) return null
|
|
74
|
+
const base = (typeof import.meta !== 'undefined' && import.meta.env?.BASE_URL) || '/'
|
|
75
|
+
const baseClean = base.endsWith('/') ? base.slice(0, -1) : base
|
|
76
|
+
if (widget.type === 'prototype') {
|
|
77
|
+
const src = widget.props?.src
|
|
78
|
+
if (!src) return null
|
|
79
|
+
if (/^https?:\/\//.test(src)) return src
|
|
80
|
+
return `${baseClean}${src.startsWith('/') ? '' : '/'}${src}?_sb_embed&_sb_hide_branch_bar`
|
|
81
|
+
}
|
|
82
|
+
if (widget.type === 'story') {
|
|
83
|
+
const storyId = widget.props?.storyId
|
|
84
|
+
const exportName = widget.props?.exportName
|
|
85
|
+
if (!storyId) return null
|
|
86
|
+
const storyData = typeof window !== 'undefined' && window.__storyboardStoryIndex?.[storyId]
|
|
87
|
+
if (storyData?._route) {
|
|
88
|
+
const route = exportName ? `${storyData._route}?export=${exportName}` : storyData._route
|
|
89
|
+
return `${baseClean}${route}`
|
|
90
|
+
}
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
|
|
53
96
|
const DEFAULT_THEME = {
|
|
54
97
|
background: '#0d1117',
|
|
55
98
|
foreground: '#e6edf3',
|
|
@@ -73,25 +116,60 @@ const DEFAULT_THEME = {
|
|
|
73
116
|
brightWhite: '#f0f6fc',
|
|
74
117
|
}
|
|
75
118
|
|
|
76
|
-
export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
77
|
-
const
|
|
78
|
-
const
|
|
119
|
+
export default forwardRef(function TerminalWidget({ id, props, onUpdate, resizable }, ref) {
|
|
120
|
+
const cfg = getTerminalConfig()
|
|
121
|
+
const fontSize = cfg.fontSize ?? 13
|
|
122
|
+
const width = props?.width ?? cfg.defaultWidth ?? readProp(props, 'width', terminalSchema)
|
|
123
|
+
const height = props?.height ?? cfg.defaultHeight ?? readProp(props, 'height', terminalSchema)
|
|
79
124
|
const prettyName = props?.prettyName || null
|
|
125
|
+
const startupCommand = props?.startupCommand || null
|
|
80
126
|
|
|
81
127
|
const containerRef = useRef(null)
|
|
82
128
|
const termRef = useRef(null)
|
|
83
129
|
const terminalRef = useRef(null)
|
|
84
130
|
const wsRef = useRef(null)
|
|
131
|
+
|
|
85
132
|
const [ready, setReady] = useState(false)
|
|
86
133
|
const [error, setError] = useState(null)
|
|
87
134
|
const [sessionEnded, setSessionEnded] = useState(false)
|
|
88
135
|
const [connectAttempt, setConnectAttempt] = useState(0)
|
|
136
|
+
const [interactive, setInteractive] = useState(false)
|
|
137
|
+
const [expandedOverride, setExpandedOverride, clearExpandedOverride] = useOverride(`_terminal_expanded_${id}`)
|
|
138
|
+
const expanded = expandedOverride === 'true'
|
|
139
|
+
const setExpanded = useCallback((val) => {
|
|
140
|
+
if (val) setExpandedOverride('true')
|
|
141
|
+
else clearExpandedOverride()
|
|
142
|
+
}, [setExpandedOverride, clearExpandedOverride])
|
|
143
|
+
const [waking, setWaking] = useState(false)
|
|
144
|
+
const [showDragHint, setShowDragHint] = useState(false)
|
|
145
|
+
const expandContainerRef = useRef(null)
|
|
146
|
+
const dragHintTimer = useRef(null)
|
|
147
|
+
|
|
148
|
+
useImperativeHandle(ref, () => ({
|
|
149
|
+
handleAction(actionId) {
|
|
150
|
+
if (actionId === 'expand' || actionId === 'split-screen') setExpanded(true)
|
|
151
|
+
},
|
|
152
|
+
}), [setExpanded])
|
|
153
|
+
|
|
154
|
+
// Exit interactive on click outside
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
if (!interactive) return
|
|
157
|
+
function handlePointerDown(e) {
|
|
158
|
+
if (terminalRef.current && !terminalRef.current.contains(e.target)) {
|
|
159
|
+
const chromeEl = e.target.closest(`[data-widget-id="${id}"]`)
|
|
160
|
+
if (chromeEl) return
|
|
161
|
+
setInteractive(false)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
document.addEventListener('pointerdown', handlePointerDown)
|
|
165
|
+
return () => document.removeEventListener('pointerdown', handlePointerDown)
|
|
166
|
+
}, [interactive, id])
|
|
89
167
|
|
|
90
168
|
const handleResize = useCallback((w, h) => {
|
|
91
169
|
onUpdate?.({ width: w, height: h })
|
|
92
170
|
}, [onUpdate])
|
|
93
171
|
|
|
94
|
-
//
|
|
172
|
+
// Connect terminal + WebSocket
|
|
95
173
|
useEffect(() => {
|
|
96
174
|
if (!containerRef.current) return
|
|
97
175
|
|
|
@@ -103,8 +181,12 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
103
181
|
try {
|
|
104
182
|
const ghostty = await loadGhostty()
|
|
105
183
|
if (disposed) return
|
|
184
|
+
if (!ghostty) {
|
|
185
|
+
setError('ghostty-web not installed — add it to your dependencies to enable terminal widgets')
|
|
186
|
+
return
|
|
187
|
+
}
|
|
106
188
|
|
|
107
|
-
const dims = calcDimensions(width, height)
|
|
189
|
+
const dims = calcDimensions(width, height, fontSize)
|
|
108
190
|
const cfg = getTerminalConfig()
|
|
109
191
|
|
|
110
192
|
term = new ghostty.Terminal({
|
|
@@ -120,31 +202,39 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
120
202
|
term.open(containerRef.current)
|
|
121
203
|
termRef.current = term
|
|
122
204
|
|
|
123
|
-
//
|
|
124
|
-
|
|
205
|
+
// SGR mouse wheel for tmux scroll in alternate screen
|
|
206
|
+
term.attachCustomWheelEventHandler((e) => {
|
|
207
|
+
if (!(term.wasmTerm?.isAlternateScreen?.() ?? false)) return false
|
|
208
|
+
const sock = wsRef.current
|
|
209
|
+
if (!sock || sock.readyState !== WebSocket.OPEN) return true
|
|
210
|
+
const btn = e.deltaY < 0 ? 64 : 65
|
|
211
|
+
const lines = Math.max(1, Math.min(5, Math.ceil(Math.abs(e.deltaY) / 33)))
|
|
212
|
+
for (let i = 0; i < lines; i++) {
|
|
213
|
+
sock.send(`\x1b[<${btn};1;1M`)
|
|
214
|
+
sock.send(`\x1b[<${btn};1;1m`)
|
|
215
|
+
}
|
|
216
|
+
return true
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const url = getWsUrl(id, prettyName, startupCommand)
|
|
125
220
|
ws = new WebSocket(url)
|
|
126
221
|
wsRef.current = ws
|
|
127
222
|
|
|
128
223
|
ws.onopen = () => {
|
|
129
224
|
if (disposed) return
|
|
130
225
|
setReady(true)
|
|
226
|
+
setInteractive(true)
|
|
131
227
|
ws.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
132
228
|
}
|
|
133
229
|
|
|
134
230
|
ws.onmessage = (e) => {
|
|
135
231
|
if (disposed) return
|
|
136
232
|
const data = typeof e.data === 'string' ? e.data : null
|
|
137
|
-
// Intercept JSON control messages from the server
|
|
138
233
|
if (data && data.startsWith('{')) {
|
|
139
234
|
try {
|
|
140
235
|
const msg = JSON.parse(data)
|
|
141
|
-
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached')
|
|
142
|
-
|
|
143
|
-
return
|
|
144
|
-
}
|
|
145
|
-
} catch {
|
|
146
|
-
// Not valid JSON — pass through as terminal data
|
|
147
|
-
}
|
|
236
|
+
if (msg.type === 'session-info' || msg.type === 'conflict' || msg.type === 'detached') return
|
|
237
|
+
} catch { /* not JSON */ }
|
|
148
238
|
}
|
|
149
239
|
term.write(typeof e.data === 'string' ? e.data : new Uint8Array(e.data))
|
|
150
240
|
}
|
|
@@ -161,11 +251,8 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
161
251
|
setSessionEnded(true)
|
|
162
252
|
}
|
|
163
253
|
|
|
164
|
-
// Terminal input → WebSocket
|
|
165
254
|
term.onData((data) => {
|
|
166
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
167
|
-
ws.send(data)
|
|
168
|
-
}
|
|
255
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(data)
|
|
169
256
|
})
|
|
170
257
|
} catch (err) {
|
|
171
258
|
if (!disposed) setError(err.message || 'Failed to load terminal')
|
|
@@ -187,7 +274,7 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
187
274
|
useEffect(() => {
|
|
188
275
|
if (!termRef.current) return
|
|
189
276
|
const timer = setTimeout(() => {
|
|
190
|
-
const dims = calcDimensions(width, height)
|
|
277
|
+
const dims = calcDimensions(width, height, fontSize)
|
|
191
278
|
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
192
279
|
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
193
280
|
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
@@ -196,12 +283,84 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
196
283
|
return () => clearTimeout(timer)
|
|
197
284
|
}, [width, height])
|
|
198
285
|
|
|
286
|
+
// Resize for expand
|
|
287
|
+
useEffect(() => {
|
|
288
|
+
if (!expanded || !termRef.current || !expandContainerRef.current) return
|
|
289
|
+
const timer = setTimeout(() => {
|
|
290
|
+
const el = expandContainerRef.current
|
|
291
|
+
if (!el) return
|
|
292
|
+
const dims = calcDimensions(el.clientWidth, el.clientHeight - 40, fontSize)
|
|
293
|
+
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
294
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
295
|
+
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
296
|
+
}
|
|
297
|
+
setInteractive(true)
|
|
298
|
+
termRef.current?.focus?.()
|
|
299
|
+
}, 100)
|
|
300
|
+
return () => clearTimeout(timer)
|
|
301
|
+
}, [expanded])
|
|
302
|
+
|
|
303
|
+
// Restore size on collapse
|
|
304
|
+
useEffect(() => {
|
|
305
|
+
if (expanded || !termRef.current) return
|
|
306
|
+
const timer = setTimeout(() => {
|
|
307
|
+
const dims = calcDimensions(width, height, fontSize)
|
|
308
|
+
termRef.current?.resize?.(dims.cols, dims.rows)
|
|
309
|
+
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
|
310
|
+
wsRef.current.send(JSON.stringify({ type: 'resize', cols: dims.cols, rows: dims.rows }))
|
|
311
|
+
}
|
|
312
|
+
}, 100)
|
|
313
|
+
return () => clearTimeout(timer)
|
|
314
|
+
}, [expanded, width, height])
|
|
315
|
+
|
|
316
|
+
// Reparent terminal DOM between inline and expand
|
|
317
|
+
useEffect(() => {
|
|
318
|
+
const xtermEl = containerRef.current
|
|
319
|
+
if (!xtermEl) return
|
|
320
|
+
if (expanded && expandContainerRef.current) {
|
|
321
|
+
expandContainerRef.current.appendChild(xtermEl)
|
|
322
|
+
} else if (!expanded && terminalRef.current) {
|
|
323
|
+
terminalRef.current.appendChild(xtermEl)
|
|
324
|
+
}
|
|
325
|
+
}, [expanded])
|
|
326
|
+
|
|
199
327
|
const handleClick = useCallback(() => {
|
|
200
328
|
if (sessionEnded) return
|
|
201
|
-
|
|
202
|
-
|
|
329
|
+
if (ready) {
|
|
330
|
+
setInteractive(true)
|
|
331
|
+
const scrollEl = terminalRef.current?.closest('[class*="canvasScroll"]')
|
|
332
|
+
const scrollTop = scrollEl?.scrollTop
|
|
333
|
+
const scrollLeft = scrollEl?.scrollLeft
|
|
334
|
+
termRef.current?.focus({ preventScroll: true })
|
|
335
|
+
if (scrollEl && (scrollEl.scrollTop !== scrollTop || scrollEl.scrollLeft !== scrollLeft)) {
|
|
336
|
+
scrollEl.scrollTop = scrollTop
|
|
337
|
+
scrollEl.scrollLeft = scrollLeft
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}, [sessionEnded, ready])
|
|
203
341
|
|
|
204
|
-
const
|
|
342
|
+
const handleTerminalPointerDown = useCallback((e) => {
|
|
343
|
+
if (!interactive) return
|
|
344
|
+
if (e.target.closest('.tc-drag-handle')) return
|
|
345
|
+
e.stopPropagation()
|
|
346
|
+
const startX = e.clientX
|
|
347
|
+
const startY = e.clientY
|
|
348
|
+
let moved = false
|
|
349
|
+
function onMove(me) {
|
|
350
|
+
if (!moved && (Math.abs(me.clientX - startX) > 5 || Math.abs(me.clientY - startY) > 5)) {
|
|
351
|
+
moved = true
|
|
352
|
+
setShowDragHint(true)
|
|
353
|
+
clearTimeout(dragHintTimer.current)
|
|
354
|
+
dragHintTimer.current = setTimeout(() => setShowDragHint(false), 2000)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function onUp() {
|
|
358
|
+
document.removeEventListener('pointermove', onMove)
|
|
359
|
+
document.removeEventListener('pointerup', onUp)
|
|
360
|
+
}
|
|
361
|
+
document.addEventListener('pointermove', onMove)
|
|
362
|
+
document.addEventListener('pointerup', onUp)
|
|
363
|
+
}, [interactive])
|
|
205
364
|
|
|
206
365
|
const handleStartSession = useCallback(() => {
|
|
207
366
|
setWaking(true)
|
|
@@ -213,11 +372,24 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
213
372
|
}, 1500)
|
|
214
373
|
}, [])
|
|
215
374
|
|
|
216
|
-
|
|
375
|
+
const titleLabel = `Terminal · ${prettyName || '…'}`
|
|
376
|
+
const connectedEmbed = expanded ? findConnectedSplitTarget(id) : null
|
|
377
|
+
const embedUrl = expanded ? buildSplitUrl(connectedEmbed) : null
|
|
378
|
+
const hasSplit = Boolean(connectedEmbed)
|
|
379
|
+
const [activePane, setActivePane] = useState('left')
|
|
217
380
|
|
|
218
|
-
const
|
|
381
|
+
const paneOrder = useMemo(
|
|
382
|
+
() => (hasSplit ? getPaneOrder(id, connectedEmbed) : { primaryIsLeft: true }),
|
|
383
|
+
[hasSplit, id, connectedEmbed],
|
|
384
|
+
)
|
|
385
|
+
const primaryWidget = useMemo(() => ({ type: 'terminal', props: { prettyName } }), [prettyName])
|
|
386
|
+
const primaryLabel = useMemo(() => getSplitPaneLabel(primaryWidget), [primaryWidget])
|
|
387
|
+
const secondaryLabel = useMemo(() => getSplitPaneLabel(connectedEmbed), [connectedEmbed])
|
|
388
|
+
const leftLabel = paneOrder.primaryIsLeft ? primaryLabel : secondaryLabel
|
|
389
|
+
const rightLabel = paneOrder.primaryIsLeft ? secondaryLabel : primaryLabel
|
|
219
390
|
|
|
220
391
|
return (
|
|
392
|
+
<>
|
|
221
393
|
<div className={styles.container}>
|
|
222
394
|
<div className={styles.titleBar}>{titleLabel}</div>
|
|
223
395
|
<div
|
|
@@ -228,13 +400,41 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
228
400
|
...(typeof height === 'number' ? { height: `${height}px` } : undefined),
|
|
229
401
|
}}
|
|
230
402
|
onClick={handleClick}
|
|
403
|
+
onPointerDown={handleTerminalPointerDown}
|
|
404
|
+
onKeyDown={interactive ? (e) => e.stopPropagation() : undefined}
|
|
231
405
|
>
|
|
406
|
+
{showDragHint && (
|
|
407
|
+
<div className={styles.dragHint}>
|
|
408
|
+
<span className={styles.dragHintArrow}>←</span> Drag here to move widget
|
|
409
|
+
</div>
|
|
410
|
+
)}
|
|
232
411
|
{error && !sessionEnded && (
|
|
233
412
|
<div className={styles.error}>
|
|
234
413
|
<span>⚠ {error}</span>
|
|
235
414
|
</div>
|
|
236
415
|
)}
|
|
237
416
|
<div ref={containerRef} className={styles.xtermContainer} />
|
|
417
|
+
|
|
418
|
+
{/* Live but not interactive */}
|
|
419
|
+
{ready && !interactive && !sessionEnded && (
|
|
420
|
+
<div
|
|
421
|
+
className={overlayStyles.interactOverlay}
|
|
422
|
+
style={{ backgroundColor: 'transparent' }}
|
|
423
|
+
onClick={(e) => {
|
|
424
|
+
if (e.shiftKey || e.metaKey || e.ctrlKey || e.altKey) return
|
|
425
|
+
setInteractive(true)
|
|
426
|
+
termRef.current?.focus({ preventScroll: true })
|
|
427
|
+
}}
|
|
428
|
+
role="button"
|
|
429
|
+
tabIndex={0}
|
|
430
|
+
onKeyDown={(e) => { if (e.key === 'Enter') { setInteractive(true); termRef.current?.focus({ preventScroll: true }) } }}
|
|
431
|
+
aria-label="Click to interact"
|
|
432
|
+
>
|
|
433
|
+
<span className={overlayStyles.interactHint}>Click to interact</span>
|
|
434
|
+
</div>
|
|
435
|
+
)}
|
|
436
|
+
|
|
437
|
+
{/* Session ended */}
|
|
238
438
|
{sessionEnded && (
|
|
239
439
|
<div
|
|
240
440
|
className={overlayStyles.interactOverlay}
|
|
@@ -253,10 +453,12 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
253
453
|
</div>
|
|
254
454
|
)}
|
|
255
455
|
<span className={overlayStyles.interactHint}>
|
|
256
|
-
{waking ? 'Waking up...' : 'Start terminal session'}
|
|
456
|
+
{waking ? 'Waking up...' : connectAttempt > 0 ? 'Continue terminal session' : 'Start terminal session'}
|
|
257
457
|
</span>
|
|
258
458
|
</div>
|
|
259
459
|
)}
|
|
460
|
+
|
|
461
|
+
{/* Connecting */}
|
|
260
462
|
{!ready && !error && !sessionEnded && (
|
|
261
463
|
<div className={styles.loading}>Connecting…</div>
|
|
262
464
|
)}
|
|
@@ -270,5 +472,53 @@ export default function TerminalWidget({ id, props, onUpdate, resizable }) {
|
|
|
270
472
|
/>
|
|
271
473
|
)}
|
|
272
474
|
</div>
|
|
475
|
+
{createPortal(
|
|
476
|
+
<div
|
|
477
|
+
className={styles.expandBackdrop}
|
|
478
|
+
style={expanded ? undefined : { display: 'none' }}
|
|
479
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
480
|
+
onKeyDown={(e) => { if (e.key === 'Escape') setExpanded(false) }}
|
|
481
|
+
onWheel={(e) => e.stopPropagation()}
|
|
482
|
+
>
|
|
483
|
+
{hasSplit ? (
|
|
484
|
+
<SplitScreenTopBar
|
|
485
|
+
leftLabel={leftLabel}
|
|
486
|
+
rightLabel={rightLabel}
|
|
487
|
+
activePane={activePane}
|
|
488
|
+
onClose={() => setExpanded(false)}
|
|
489
|
+
/>
|
|
490
|
+
) : (
|
|
491
|
+
<div className={styles.expandTopBar}>
|
|
492
|
+
<span className={styles.expandTitle}>{titleLabel}</span>
|
|
493
|
+
<button className={styles.expandClose} onClick={() => setExpanded(false)} aria-label="Close expanded view" autoFocus><ScreenNormalIcon size={16} /></button>
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
<div className={`${styles.expandBody}${hasSplit ? ` ${styles.expandSplit}` : ''}`}>
|
|
497
|
+
{hasSplit ? (
|
|
498
|
+
<>
|
|
499
|
+
{paneOrder.primaryIsLeft ? (
|
|
500
|
+
<>
|
|
501
|
+
<div ref={expandContainerRef} className={styles.expandTerminal} onPointerDown={() => setActivePane('left')} />
|
|
502
|
+
<div className={styles.expandEmbed} onPointerDown={() => setActivePane('right')}>
|
|
503
|
+
{embedUrl ? <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" /> : null}
|
|
504
|
+
</div>
|
|
505
|
+
</>
|
|
506
|
+
) : (
|
|
507
|
+
<>
|
|
508
|
+
<div className={styles.expandEmbed} onPointerDown={() => setActivePane('left')}>
|
|
509
|
+
{embedUrl ? <iframe src={embedUrl} className={styles.expandIframe} title="Connected embed" /> : null}
|
|
510
|
+
</div>
|
|
511
|
+
<div ref={expandContainerRef} className={styles.expandTerminal} onPointerDown={() => setActivePane('right')} />
|
|
512
|
+
</>
|
|
513
|
+
)}
|
|
514
|
+
</>
|
|
515
|
+
) : (
|
|
516
|
+
<div ref={expandContainerRef} className={styles.expandTerminal} />
|
|
517
|
+
)}
|
|
518
|
+
</div>
|
|
519
|
+
</div>,
|
|
520
|
+
document.body
|
|
521
|
+
)}
|
|
522
|
+
</>
|
|
273
523
|
)
|
|
274
|
-
}
|
|
524
|
+
})
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
background: #0d1117;
|
|
34
34
|
border: 1px solid var(--borderColor-default, #30363d);
|
|
35
35
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.24);
|
|
36
|
-
padding: 8px;
|
|
36
|
+
padding: var(--base-size-8, 8px);
|
|
37
|
+
padding-bottom: var(--base-size-16, 16px);
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
.xtermContainer {
|
|
@@ -49,6 +50,22 @@
|
|
|
49
50
|
padding: 12px;
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
/* Hide the native caret on ghostty-web's helper textarea —
|
|
54
|
+
without this it renders a visible blinking cursor at (0,0)
|
|
55
|
+
and triggers scrollIntoView on focus (scroll-to-top bug). */
|
|
56
|
+
.xtermContainer :global(.xterm-helper-textarea),
|
|
57
|
+
.xtermContainer textarea {
|
|
58
|
+
caret-color: transparent !important;
|
|
59
|
+
opacity: 0 !important;
|
|
60
|
+
position: absolute !important;
|
|
61
|
+
top: 0 !important;
|
|
62
|
+
left: 0 !important;
|
|
63
|
+
width: 1px !important;
|
|
64
|
+
height: 1px !important;
|
|
65
|
+
overflow: hidden !important;
|
|
66
|
+
pointer-events: none !important;
|
|
67
|
+
}
|
|
68
|
+
|
|
52
69
|
.xtermContainer :global(.xterm-viewport) {
|
|
53
70
|
overflow-y: auto;
|
|
54
71
|
}
|
|
@@ -156,3 +173,140 @@
|
|
|
156
173
|
transform: translateY(-24px);
|
|
157
174
|
}
|
|
158
175
|
}
|
|
176
|
+
|
|
177
|
+
/* Fullscreen expand */
|
|
178
|
+
|
|
179
|
+
/* Drag hint tooltip — appears when user tries to drag the terminal body */
|
|
180
|
+
.dragHint {
|
|
181
|
+
position: absolute;
|
|
182
|
+
bottom: -32px;
|
|
183
|
+
right: 0;
|
|
184
|
+
z-index: 10;
|
|
185
|
+
display: flex;
|
|
186
|
+
align-items: center;
|
|
187
|
+
gap: 4px;
|
|
188
|
+
padding: 4px 10px;
|
|
189
|
+
border-radius: 6px;
|
|
190
|
+
background: var(--bgColor-inverse, #1f2328);
|
|
191
|
+
color: var(--fgColor-onInverse, #ffffff);
|
|
192
|
+
font-size: 12px;
|
|
193
|
+
font-weight: 500;
|
|
194
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
195
|
+
white-space: nowrap;
|
|
196
|
+
pointer-events: none;
|
|
197
|
+
animation: dragHintIn 150ms ease;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
.dragHintArrow {
|
|
201
|
+
font-size: 14px;
|
|
202
|
+
opacity: 0.7;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
@keyframes dragHintIn {
|
|
206
|
+
from { opacity: 0; transform: translateY(-4px); }
|
|
207
|
+
to { opacity: 1; transform: translateY(0); }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
.expandBackdrop {
|
|
211
|
+
position: fixed;
|
|
212
|
+
inset: 0;
|
|
213
|
+
z-index: 100000;
|
|
214
|
+
background: #0d1117;
|
|
215
|
+
display: flex;
|
|
216
|
+
flex-direction: column;
|
|
217
|
+
animation: expandFadeIn 0.15s ease;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
@keyframes expandFadeIn {
|
|
221
|
+
from { opacity: 0; }
|
|
222
|
+
to { opacity: 1; }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
.expandTopBar {
|
|
226
|
+
display: flex;
|
|
227
|
+
align-items: center;
|
|
228
|
+
height: 40px;
|
|
229
|
+
padding: 0 12px;
|
|
230
|
+
background: #161b22;
|
|
231
|
+
border-bottom: 1px solid #30363d;
|
|
232
|
+
flex-shrink: 0;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.expandTitle {
|
|
236
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
237
|
+
font-size: 12px;
|
|
238
|
+
font-weight: 500;
|
|
239
|
+
color: #e6edf3;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.expandEmbedLabel {
|
|
243
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
244
|
+
font-size: 12px;
|
|
245
|
+
color: #8b949e;
|
|
246
|
+
margin-left: auto;
|
|
247
|
+
margin-right: 12px;
|
|
248
|
+
overflow: hidden;
|
|
249
|
+
text-overflow: ellipsis;
|
|
250
|
+
white-space: nowrap;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.expandClose {
|
|
254
|
+
all: unset;
|
|
255
|
+
cursor: pointer;
|
|
256
|
+
margin-left: auto;
|
|
257
|
+
width: 28px;
|
|
258
|
+
height: 28px;
|
|
259
|
+
display: flex;
|
|
260
|
+
align-items: center;
|
|
261
|
+
justify-content: center;
|
|
262
|
+
border-radius: 6px;
|
|
263
|
+
color: #8b949e;
|
|
264
|
+
font-size: 14px;
|
|
265
|
+
transition: background 100ms, color 100ms;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.expandClose:hover {
|
|
269
|
+
background: #30363d;
|
|
270
|
+
color: #e6edf3;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
.expandEmbedLabel + .expandClose {
|
|
274
|
+
margin-left: 0;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.expandBody {
|
|
278
|
+
flex: 1;
|
|
279
|
+
min-height: 0;
|
|
280
|
+
display: flex;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.expandTerminal {
|
|
284
|
+
flex: 1;
|
|
285
|
+
min-width: 0;
|
|
286
|
+
overflow: hidden;
|
|
287
|
+
background: #0d1117;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
.expandTerminal :global(.xterm) {
|
|
291
|
+
height: 100%;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.expandSplit .expandTerminal {
|
|
295
|
+
flex: 1;
|
|
296
|
+
border-right: 1px solid #30363d;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.expandSplit .expandEmbed {
|
|
300
|
+
flex: 1;
|
|
301
|
+
min-width: 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.expandEmbed {
|
|
305
|
+
display: flex;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.expandIframe {
|
|
309
|
+
border: none;
|
|
310
|
+
width: 100%;
|
|
311
|
+
height: 100%;
|
|
312
|
+
}
|