feat: add configurable VDO.Ninja overview and floating window focus

This commit is contained in:
2026-09-22 17:34:35 +09:00
parent 33aac68f16
commit 36806e4dc1
14 changed files with 304 additions and 16 deletions
+5 -1
View File
@@ -1,6 +1,6 @@
{
"name": "lms",
"version": "1.1.29",
"version": "1.1.30",
"description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js",
"author": {
@@ -18,6 +18,10 @@
"directories": {
"output": "dist"
},
"files": [
"out/**/*",
"package.json"
],
"extraResources": [
{
"from": "resources/icon.png",
+20
View File
@@ -0,0 +1,20 @@
import { strict as assert } from 'node:assert'
import { test } from 'node:test'
import { resolveContainerTerminal } from './containerTerminal'
const inspection = (running = true) => JSON.stringify([{
Id: 'container-id', State: { Running: running }, Mounts: [
{ Source: '/home/user/ws', Destination: '/workspace' },
{ Source: '/home/user/ws/module', Destination: '/module' }
]
}])
test('resolves module path from the most specific actual mount', () => {
assert.deepEqual(resolveContainerTerminal(inspection(), '/home/user/ws/module'), { id: 'container-id', cwd: '/module' })
assert.equal(resolveContainerTerminal(inspection(), '/home/user/ws/other').cwd, '/workspace/other')
})
test('rejects stopped containers and unrelated workspace paths', () => {
assert.throws(() => resolveContainerTerminal(inspection(false), '/home/user/ws/module'), /not running/)
assert.throws(() => resolveContainerTerminal(inspection(), '/home/user/ws-other/module'), /not mounted/)
})
+13
View File
@@ -0,0 +1,13 @@
import { posix } from 'node:path'
export function resolveContainerTerminal(inspectJson: string, moduleRoot: string): { id: string; cwd: string } {
const [container] = JSON.parse(inspectJson)
if (!container?.State?.Running) throw new Error('Module container is not running')
const root = posix.resolve(moduleRoot)
const mounts = (container.Mounts ?? []) as { Source: string; Destination: string }[]
const mount = mounts
.filter((item) => root === item.Source || root.startsWith(`${item.Source.replace(/\/$/, '')}/`))
.sort((a, b) => b.Source.length - a.Source.length)[0]
if (!mount) throw new Error('Module workspace is not mounted in this container')
return { id: container.Id, cwd: posix.join(mount.Destination, posix.relative(mount.Source, root)) }
}
+24 -4
View File
@@ -1,5 +1,6 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { checkDockerStatus } from './dockerStatus'
import { resolveContainerTerminal } from './containerTerminal'
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
@@ -5964,17 +5965,31 @@ function terminalEnvironment(cols: number, rows: number): Record<string, string>
}
function attachTerminalProcess(sender: WebContents, sessionId: string, terminalKey: string, proc: IPty): void {
intentionalTerminalStops.delete(terminalKey)
terminalProcesses.set(terminalKey, proc)
proc.onData((data) => {
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data)
})
proc.onExit(({ exitCode }) => {
if (terminalProcesses.get(terminalKey) !== proc) return
terminalProcesses.delete(terminalKey)
if (intentionalTerminalStops.delete(terminalKey)) return
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`)
})
}
async function containerTerminalCommand(moduleRoot: string, moduleName: string | undefined, cols: number, rows: number, target?: ReturnType<typeof normalizeRemoteTarget>): Promise<string> {
const info = parseModuleDockerInfo(basename(moduleRoot))
if (!info || !isAllowedContainerName(info.containerKey)) throw new Error('Invalid module container name')
const output = target
? await runSshOutput(target, `docker inspect ${quoteShellSingle(info.containerKey)}`, 10000)
: await runCommandOutput('docker', ['inspect', info.containerKey], undefined, undefined, 10000)
const container = resolveContainerTerminal(output, moduleRoot)
const shell = buildWorkspaceShellCommand(moduleName, container.cwd, cols, rows, false)
const init = 'if [ -n "$ROS_DISTRO" ] && [ -f "/opt/ros/$ROS_DISTRO/setup.bash" ]; then source "/opt/ros/$ROS_DISTRO/setup.bash"; fi; if [ -f ./install/setup.bash ]; then source ./install/setup.bash; fi; exec ' + shell
return `exec docker exec -it -e TERM=xterm-256color -w ${quoteShellSingle(container.cwd)} ${quoteShellSingle(container.id)} /bin/bash --noprofile --norc -c ${quoteShellSingle(init)}`
}
function registerTerminalHandlers(): void {
ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
const webContentsId = event.sender.id
@@ -6006,7 +6021,7 @@ function registerTerminalHandlers(): void {
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
return true
})
ipcMain.handle('local-workspace:terminal:start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
ipcMain.handle('local-workspace:terminal:start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null, mode: 'host' | 'container' = 'host') => {
const webContentsId = event.sender.id
const terminalKey = getTerminalKey(webContentsId, sessionId)
cancelStopTerminal(terminalKey)
@@ -6024,7 +6039,10 @@ function registerTerminalHandlers(): void {
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], {
const command = mode === 'container'
? await containerTerminalCommand(terminalCwd, moduleName, safeCols, safeRows)
: buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', command], {
cwd: terminalCwd,
name: 'xterm-256color',
cols: safeCols,
@@ -6036,7 +6054,7 @@ function registerTerminalHandlers(): void {
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
return true
})
ipcMain.handle('remote-workspace:terminal:start', async (event, targetInput: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', folderName?: string | null) => {
ipcMain.handle('remote-workspace:terminal:start', async (event, targetInput: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', folderName?: string | null, mode: 'host' | 'container' = 'host') => {
const webContentsId = event.sender.id
const terminalKey = getTerminalKey(webContentsId, sessionId)
cancelStopTerminal(terminalKey)
@@ -6068,7 +6086,9 @@ function registerTerminalHandlers(): void {
'-i',
target.identityFile,
`${target.user}@${target.host}`,
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true)
mode === 'container'
? await containerTerminalCommand(resolved.moduleRoot, moduleName, safeCols, safeRows, target)
: buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true)
], {
name: 'xterm-256color',
cols: safeCols,
+2 -2
View File
@@ -265,7 +265,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
},
terminal: {
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName),
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('local-workspace:terminal:start', moduleName, cols, rows, sessionId, workspaceRoot, folderName, mode),
resize: (cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('local-workspace:terminal:resize', cols, rows, sessionId),
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:terminal:change-directory', moduleName, sessionId, workspaceRoot, folderName),
write: (data: string, sessionId?: string) => ipcRenderer.send('local-workspace:terminal:write', data, sessionId),
@@ -336,7 +336,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
},
terminal: {
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName),
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string, mode?: 'host' | 'container') => ipcRenderer.invoke('remote-workspace:terminal:start', target, moduleName, cols, rows, sessionId, folderName, mode),
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => ipcRenderer.invoke('remote-workspace:terminal:resize', target, cols, rows, sessionId),
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:terminal:change-directory', target, moduleName, sessionId, folderName),
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
+18 -1
View File
@@ -81,6 +81,7 @@ import type { DiffViewLine, GitChangedFile } from './features/workspace/gitUtils
import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs'
import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav'
import WorkspaceTopBar from './features/workspace/WorkspaceTopBar'
import WorkspaceCameraPanel from './features/workspace/WorkspaceCameraPanel'
import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel'
import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals'
@@ -520,6 +521,8 @@ function LocalPcWorkspaceMockup({
}): React.JSX.Element {
const visualizationHostInstanceId = useRef(`${platformId}:${Math.random().toString(36).slice(2)}`).current
const [powerActionModalOpen, setPowerActionModalOpen] = useState(false)
const [cameraOpen, setCameraOpen] = useState(false)
useEffect(() => { setCameraOpen(false) }, [platformId, workspaceVisible])
const [controlCountdownNow, setControlCountdownNow] = useState(Date.now())
const {
qaMode,
@@ -2112,11 +2115,15 @@ function LocalPcWorkspaceMockup({
copyTerminalSelection,
pasteTerminalClipboard,
startTerminalResize,
stopAllTerminalSessions
stopAllTerminalSessions,
terminalMode,
setTerminalMode,
containerAvailable
} = useWorkspaceTerminal({
moduleName: selectedModule?.name,
moduleFolderName: selectedModule?.folderName,
openModuleNames: moduleTabs.map((module) => module.name),
runningModuleNames: availableModules.filter((module) => module.state === 'running').map((module) => module.name),
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
@@ -3416,7 +3423,14 @@ function LocalPcWorkspaceMockup({
return (
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
{cameraOpen && workspaceVisible && <WorkspaceCameraPanel
camera={platformOptions.find((platform) => platform.id === platformId)?.camera}
dark={dark}
onClose={() => setCameraOpen(false)}
/>}
<WorkspaceTopBar
cameraOpen={cameraOpen}
onToggleCamera={() => setCameraOpen((open) => !open)}
dark={dark}
textStrong={textStrong}
textMid={textMid}
@@ -3924,6 +3938,9 @@ function LocalPcWorkspaceMockup({
{!exampleActive && (
<WorkspaceTerminalPanel
terminalMode={terminalMode}
onModeChange={setTerminalMode}
containerAvailable={containerAvailable}
dark={dark}
borderSoft={borderSoft}
textDim={textDim}
+2 -2
View File
@@ -229,7 +229,7 @@ interface Window {
}>
}
terminal: {
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
start: (moduleName?: string, cols?: number, rows?: number, sessionId?: string, workspaceRoot?: string, folderName?: string, mode?: 'host' | 'container') => Promise<boolean>
resize: (cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
write: (data: string, sessionId?: string) => void
@@ -369,7 +369,7 @@ interface Window {
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']>
}
terminal: {
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string, mode?: 'host' | 'container') => Promise<boolean>
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
write: (data: string, sessionId?: string) => void
@@ -34,6 +34,7 @@ export type VehicleRuntimeProfile = {
}
export type PlatformOption = {
camera?: { url: string; password: string } | null
id: string
platformKey: string
title: string
@@ -0,0 +1,88 @@
import { Camera, CirclePlay, Maximize2, Minimize2, Square, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus'
export function cameraViewerUrl(camera: { url: string; password: string }): string {
const input = new URL(camera.url)
if (input.protocol !== 'https:' || input.hostname !== 'vdo.ninja' || input.port || input.username || input.password || input.pathname !== '/') throw new Error('카메라 시청 URL이 올바르지 않습니다.')
const room = input.searchParams.get('room')
const view = input.searchParams.get('view')
if (!room?.trim() || !view?.trim()) throw new Error('카메라 시청 URL에 room과 view가 필요합니다.')
const url = new URL('https://vdo.ninja/')
url.searchParams.set('room', room)
url.searchParams.set('view', view)
url.searchParams.set('scene', '')
if (camera.password) url.searchParams.set('password', camera.password)
return url.toString()
}
export default function WorkspaceCameraPanel({ camera, dark, onClose }: {
camera?: { url: string; password: string } | null
dark: boolean
onClose: () => void
}): React.JSX.Element {
const [viewerUrl, setViewerUrl] = useState('')
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('overview')
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
const [large, setLarge] = useState(false)
const [position, setPosition] = useState({ x: 32, y: 90 })
const panelRef = useRef<HTMLDivElement>(null)
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null)
useEffect(() => {
setViewerUrl('')
setLoading(false)
setError('')
}, [camera?.url, camera?.password])
const start = (): void => {
try {
if (!camera?.url) throw new Error('등록된 주행장 카메라가 없습니다.')
setViewerUrl(cameraViewerUrl(camera))
setLoading(true)
setError('')
} catch (reason) {
setError(reason instanceof Error ? reason.message : '카메라 연결에 실패했습니다.')
}
}
const buttonClass = 'grid h-7 w-7 shrink-0 cursor-pointer place-items-center rounded hover:bg-gray-500/20'
return createPortal(
<div ref={panelRef} role="dialog" aria-label="오버뷰"
onPointerDownCapture={bringToFront} onFocusCapture={bringToFront}
className={['fixed flex flex-col overflow-hidden rounded-md border shadow-xl', dark ? 'border-white/20 bg-[#242424] text-white' : 'border-gray-300 bg-white text-gray-800'].join(' ')}
style={large ? { inset: 24, zIndex } : { zIndex, left: position.x, top: position.y, width: 660, height: 420, minWidth: 280, minHeight: 220, maxWidth: `calc(100vw - ${position.x + 12}px)`, maxHeight: `calc(100vh - ${position.y + 12}px)`, resize: 'both' }}>
<div className="flex h-10 shrink-0 touch-none items-center gap-2 border-b border-gray-500/25 px-3 select-none"
style={{ cursor: large ? 'default' : 'move' }}
onPointerDown={(event) => {
if (large || (event.target as HTMLElement).closest('button')) return
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y }
event.currentTarget.setPointerCapture(event.pointerId)
}}
onPointerMove={(event) => {
if (!drag.current) return
const width = panelRef.current?.offsetWidth ?? 660
const height = panelRef.current?.offsetHeight ?? 420
setPosition({ x: Math.max(0, Math.min(window.innerWidth - width, drag.current.left + event.clientX - drag.current.x)), y: Math.max(0, Math.min(window.innerHeight - height, drag.current.top + event.clientY - drag.current.y)) })
}}
onPointerUp={() => { drag.current = null }} onLostPointerCapture={() => { drag.current = null }}>
<Camera size={15} /><strong className="min-w-0 flex-1 text-[13px]">오버뷰</strong>
{viewerUrl && <button type="button" title="영상 중지" aria-label="영상 중지" className={buttonClass} onClick={() => { setViewerUrl(''); setLoading(false) }}><Square size={14} /></button>}
<button type="button" title={large ? '원래 크기' : '확대'} aria-label={large ? '원래 크기' : '확대'} className={buttonClass} onClick={() => setLarge(!large)}>{large ? <Minimize2 size={15} /> : <Maximize2 size={15} />}</button>
<button type="button" title="닫기" aria-label="오버뷰 닫기" className={buttonClass} onClick={onClose}><X size={15} /></button>
</div>
<div className="relative min-h-0 flex-1 bg-black">
{viewerUrl ? <>
{loading && <div role="status" className="absolute inset-0 grid place-items-center text-sm text-white">영상 연결 중...</div>}
<iframe ref={frameRef} key={viewerUrl} src={viewerUrl} title="주행장 오버뷰 영상" className="relative h-full w-full border-0"
allow="autoplay; fullscreen" sandbox="allow-scripts allow-same-origin" referrerPolicy="no-referrer"
onLoad={() => setLoading(false)} />
</> : <div className="absolute inset-0 bg-black/34">
<button type="button" disabled={!camera?.url} onClick={start} className="absolute left-1/2 top-1/2 inline-flex min-h-28 w-64 max-w-full -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center justify-center gap-2 px-3 text-[15px] font-bold text-white/80 transition hover:text-white disabled:cursor-default disabled:opacity-40"><CirclePlay size={40} /><span>실행하기</span></button>
<div className="absolute inset-x-0 top-[70%] px-5 text-center">
{error && <p role="alert" className="text-[13px] font-bold text-red-300">{error}</p>}
{!camera?.url && <p className="text-[13px] font-bold text-white/72">등록된 주행장 카메라가 없습니다.</p>}
</div>
</div>}
</div>
</div>, document.body)
}
@@ -1,6 +1,9 @@
import { Terminal } from 'lucide-react'
type Props = {
terminalMode: 'host' | 'container'
onModeChange: (mode: 'host' | 'container') => void
containerAvailable: boolean
dark: boolean
borderSoft: string
textDim: string
@@ -11,6 +14,9 @@ type Props = {
}
function WorkspaceTerminalPanel({
terminalMode,
onModeChange,
containerAvailable,
dark,
borderSoft,
textDim,
@@ -19,6 +25,7 @@ function WorkspaceTerminalPanel({
terminalHostRef,
onStartResize
}: Props): React.JSX.Element {
const modes: ('host' | 'container')[] = containerAvailable ? ['host', 'container'] : ['host']
return (
<footer className={['relative border-t', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#e9edf4]'].join(' ')}>
<div
@@ -31,7 +38,21 @@ function WorkspaceTerminalPanel({
</div>
<div className={['flex h-10 items-center gap-2 border-b px-3', borderSoft].join(' ')}>
<Terminal size={14} className={textDim} />
<span className={['text-[12px] font-bold', textMid].join(' ')}>터미널</span>
<div role="group" aria-label="터미널 환경" className="flex items-center gap-1">
{modes.map((mode) => (
<button
key={mode}
type="button"
aria-pressed={terminalMode === mode}
onClick={() => onModeChange(mode)}
className={['h-6 cursor-pointer rounded px-2 text-[12px] font-bold transition-colors', terminalMode === mode
? dark ? 'bg-white/15 text-white' : 'bg-white text-[#253044]'
: `${textMid} ${dark ? 'hover:bg-white/5' : 'hover:bg-black/5'}`].join(' ')}
>
{mode === 'host' ? '호스트' : '컨테이너'}
</button>
))}
</div>
</div>
<div
ref={terminalHostRef}
@@ -1,8 +1,10 @@
import { BookOpen, Clock3, MessageCircle, PanelLeft, Power, RefreshCw, Upload, X } from 'lucide-react'
import { BookOpen, Camera, Clock3, MessageCircle, PanelLeft, Power, RefreshCw, Upload, X } from 'lucide-react'
import type React from 'react'
import Tooltip from '../../components/Tooltip'
type WorkspaceTopBarProps = {
cameraOpen: boolean
onToggleCamera: () => void
dark: boolean
textStrong: string
textMid: string
@@ -34,6 +36,8 @@ type WorkspaceTopBarProps = {
}
export default function WorkspaceTopBar({
cameraOpen,
onToggleCamera,
dark,
textStrong,
textMid,
@@ -93,6 +97,9 @@ export default function WorkspaceTopBar({
<PanelLeft size={14} />
시각화
</button>
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />오버뷰
</button>
{showControlCountdown && (
<>
<span className={['ml-2 h-4 w-px', dark ? 'bg-white/20' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -1,6 +1,7 @@
import { CirclePlay, LoaderCircle, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
import type React from 'react'
import { useEffect, useState } from 'react'
import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
@@ -50,6 +51,7 @@ export default function WorkspaceVisualizationPanel({
onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
const [terminateConfirmOpen, setTerminateConfirmOpen] = useState(false)
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('visualization', !minimized)
const [activeViewerUrl, setActiveViewerUrl] = useState<string | null>(() => (running ? viewerUrl : null))
useEffect(() => {
@@ -60,12 +62,15 @@ export default function WorkspaceVisualizationPanel({
return (
<>
<section
onPointerDownCapture={bringToFront}
onFocusCapture={bringToFront}
className={[
'absolute z-40 overflow-hidden rounded-lg border shadow-[0_18px_48px_rgba(0,0,0,0.32)]',
dark ? 'border-white/12 bg-[#1c1c1c]' : 'border-[#cfd6e4] bg-white'
].join(' ')}
style={large
? {
zIndex,
left: 0,
right: 0,
top: 40,
@@ -73,6 +78,7 @@ export default function WorkspaceVisualizationPanel({
display: minimized ? 'none' : undefined
}
: {
zIndex,
left: position.x,
top: position.y,
width: size.width,
@@ -142,6 +148,7 @@ export default function WorkspaceVisualizationPanel({
>
<div className="relative min-h-0 flex-1">
<iframe
ref={frameRef}
title="Autonomy Studio Visualization"
src={activeViewerUrl ?? 'about:blank'}
className="h-full w-full border-0"
@@ -11,6 +11,7 @@ type TerminalContextMenu = {
}
type ManagedTerminalSession = {
mode: 'host' | 'container'
moduleName: string
remoteWorkspace: boolean
attachedTerminal: XTerm | null
@@ -23,6 +24,7 @@ type UseWorkspaceTerminalOptions = {
moduleName?: string | null
moduleFolderName?: string | null
openModuleNames: string[]
runningModuleNames: string[]
clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
@@ -33,6 +35,9 @@ type UseWorkspaceTerminalOptions = {
}
type UseWorkspaceTerminalResult = {
terminalMode: 'host' | 'container'
setTerminalMode: (mode: 'host' | 'container') => void
containerAvailable: boolean
terminalContextMenu: TerminalContextMenu | null
setTerminalContextMenu: React.Dispatch<React.SetStateAction<TerminalContextMenu | null>>
terminalContextMenuRef: React.RefObject<HTMLDivElement | null>
@@ -49,6 +54,7 @@ export function useWorkspaceTerminal({
moduleName,
moduleFolderName,
openModuleNames,
runningModuleNames,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
@@ -68,6 +74,13 @@ export function useWorkspaceTerminal({
const terminalSessionsRef = useRef<Map<string, ManagedTerminalSession>>(new Map())
const resetKeyRef = useRef(resetKey)
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
const [moduleTerminalModes, setModuleTerminalModes] = useState<Record<string, 'host' | 'container'>>({})
const containerAvailable = Boolean(moduleName && runningModuleNames.includes(moduleName))
const terminalMode = containerAvailable && moduleName ? moduleTerminalModes[moduleName] ?? 'host' : 'host'
const setTerminalMode = (mode: 'host' | 'container'): void => {
if (!moduleName) return
setModuleTerminalModes((current) => ({ ...current, [moduleName]: mode }))
}
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
@@ -126,13 +139,15 @@ export function useWorkspaceTerminal({
event.preventDefault()
const startY = event.clientY
const startHeight = terminalPanelHeight
const workspaceHeight = event.currentTarget.closest('section')?.getBoundingClientRect().height ?? window.innerHeight
const maxHeight = Math.max(150, workspaceHeight - 140)
const relayoutWorkspace = (): void => {
fitTerminal()
layoutEditorToPane()
}
const onMouseMove = (moveEvent: MouseEvent): void => {
const nextHeight = Math.max(150, Math.min(460, startHeight - (moveEvent.clientY - startY)))
const nextHeight = Math.max(150, Math.min(maxHeight, startHeight - (moveEvent.clientY - startY)))
setTerminalPanelHeight(nextHeight)
window.requestAnimationFrame(relayoutWorkspace)
}
@@ -147,6 +162,19 @@ export function useWorkspaceTerminal({
}
const openModuleNamesKey = openModuleNames.join('\u0000')
const runningModuleNamesKey = runningModuleNames.join('\u0000')
useEffect(() => {
setModuleTerminalModes((current) => {
const entries = Object.entries(current).filter(([name, mode]) => (
openModuleNames.includes(name) && (mode !== 'container' || runningModuleNames.includes(name))
))
return entries.length === Object.keys(current).length ? current : Object.fromEntries(entries)
})
Array.from(terminalSessionsRef.current.entries()).forEach(([id, session]) => {
if (session.mode === 'container' && !runningModuleNames.includes(session.moduleName)) stopTerminalSession(id)
})
}, [openModuleNamesKey, runningModuleNamesKey])
useEffect(() => {
const openModuleNameSet = new Set(openModuleNames)
@@ -158,12 +186,13 @@ export function useWorkspaceTerminal({
useEffect(() => {
if (resetKeyRef.current === resetKey) return
resetKeyRef.current = resetKey
setModuleTerminalModes({})
stopAllTerminalSessions()
}, [resetKey])
useEffect(() => {
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
const terminalSessionId = getModuleTerminalSessionId(moduleName)
const terminalSessionId = getModuleTerminalSessionId(moduleName) + (terminalMode === 'container' ? ':container' : '')
terminalSessionIdRef.current = terminalSessionId
const term = new XTerm({
@@ -230,6 +259,7 @@ export function useWorkspaceTerminal({
suppressedData.push(data)
}, terminalSessionId)
terminalSession = {
mode: terminalMode,
moduleName,
remoteWorkspace,
attachedTerminal: null,
@@ -259,6 +289,7 @@ export function useWorkspaceTerminal({
term.rows,
terminalSessionId,
moduleFolderName ?? undefined,
terminalMode,
)
: window.localWorkspace.terminal.start(
moduleName,
@@ -267,10 +298,14 @@ export function useWorkspaceTerminal({
terminalSessionId,
clonePathPreview,
moduleFolderName ?? undefined,
terminalMode,
)
void start
.then(resolvePromise)
.catch(() => resolvePromise(false))
.catch((error) => {
if (!disposed) term.write(`\r\n[terminal error: ${String(error)}]\r\n`)
resolvePromise(false)
})
})
})
})
@@ -307,9 +342,12 @@ export function useWorkspaceTerminal({
terminalFitRef.current = null
terminalSessionIdRef.current = null
}
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive, terminalMode])
return {
terminalMode,
setTerminalMode,
containerAvailable,
terminalContextMenu,
setTerminalContextMenu,
terminalContextMenuRef,
@@ -0,0 +1,52 @@
import { useCallback, useEffect, useRef, useSyncExternalStore } from 'react'
type WindowId = 'visualization' | 'overview'
let activeWindow: WindowId | null = null
const listeners = new Set<() => void>()
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener)
return () => { listeners.delete(listener) }
}
export function useWorkspaceWindowFocus(id: WindowId, visible = true) {
const frameRef = useRef<HTMLIFrameElement>(null)
const active = useSyncExternalStore(subscribe, () => activeWindow)
const bringToFront = useCallback(() => {
if (activeWindow === id) return
activeWindow = id
listeners.forEach((listener) => listener())
}, [id])
useEffect(() => {
if (visible) bringToFront()
}, [visible, bringToFront])
useEffect(() => {
if (!visible) return
let timer: ReturnType<typeof setTimeout> | undefined
let previousElement = document.activeElement
const checkFrameFocus = (): void => {
const element = document.activeElement
if (element === previousElement) return
previousElement = element
if (frameRef.current && element === frameRef.current) bringToFront()
}
// Pointer events inside a cross-origin viewer do not bubble to its parent.
const onBlur = (): void => {
clearTimeout(timer)
timer = setTimeout(() => {
if (frameRef.current && document.activeElement === frameRef.current) bringToFront()
}, 0)
}
window.addEventListener('blur', onBlur)
// Moving directly between two iframe windows may not blur the parent again.
const focusTimer = setInterval(checkFrameFocus, 100)
return () => {
clearTimeout(timer)
clearInterval(focusTimer)
window.removeEventListener('blur', onBlur)
}
}, [visible, bringToFront])
return { frameRef, bringToFront, zIndex: active === id ? 41 : 40 }
}