feat: add configurable VDO.Ninja overview and floating window focus
This commit is contained in:
+5
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.29",
|
"version": "1.1.30",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
@@ -18,6 +18,10 @@
|
|||||||
"directories": {
|
"directories": {
|
||||||
"output": "dist"
|
"output": "dist"
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"out/**/*",
|
||||||
|
"package.json"
|
||||||
|
],
|
||||||
"extraResources": [
|
"extraResources": [
|
||||||
{
|
{
|
||||||
"from": "resources/icon.png",
|
"from": "resources/icon.png",
|
||||||
|
|||||||
@@ -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/)
|
||||||
|
})
|
||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||||
import { checkDockerStatus } from './dockerStatus'
|
import { checkDockerStatus } from './dockerStatus'
|
||||||
|
import { resolveContainerTerminal } from './containerTerminal'
|
||||||
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
|
||||||
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
|
||||||
import { moduleProgress, reportModuleProgress, createModuleProgressParser } from './moduleProgress'
|
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 {
|
function attachTerminalProcess(sender: WebContents, sessionId: string, terminalKey: string, proc: IPty): void {
|
||||||
|
intentionalTerminalStops.delete(terminalKey)
|
||||||
terminalProcesses.set(terminalKey, proc)
|
terminalProcesses.set(terminalKey, proc)
|
||||||
proc.onData((data) => {
|
proc.onData((data) => {
|
||||||
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data)
|
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, data)
|
||||||
})
|
})
|
||||||
proc.onExit(({ exitCode }) => {
|
proc.onExit(({ exitCode }) => {
|
||||||
|
if (terminalProcesses.get(terminalKey) !== proc) return
|
||||||
terminalProcesses.delete(terminalKey)
|
terminalProcesses.delete(terminalKey)
|
||||||
if (intentionalTerminalStops.delete(terminalKey)) return
|
if (intentionalTerminalStops.delete(terminalKey)) return
|
||||||
if (!sender.isDestroyed()) sender.send('terminal-data', sessionId, `\r\n[terminal exited: ${exitCode}]\r\n`)
|
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 {
|
function registerTerminalHandlers(): void {
|
||||||
ipcMain.handle('terminal-start', async (event, moduleName?: string, cols?: number, rows?: number, sessionId = 'default', workspaceRoot?: string | null, folderName?: string | null) => {
|
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
|
const webContentsId = event.sender.id
|
||||||
@@ -6006,7 +6021,7 @@ function registerTerminalHandlers(): void {
|
|||||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||||
return true
|
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 webContentsId = event.sender.id
|
||||||
const terminalKey = getTerminalKey(webContentsId, sessionId)
|
const terminalKey = getTerminalKey(webContentsId, sessionId)
|
||||||
cancelStopTerminal(terminalKey)
|
cancelStopTerminal(terminalKey)
|
||||||
@@ -6024,7 +6039,10 @@ function registerTerminalHandlers(): void {
|
|||||||
|
|
||||||
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
|
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 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,
|
cwd: terminalCwd,
|
||||||
name: 'xterm-256color',
|
name: 'xterm-256color',
|
||||||
cols: safeCols,
|
cols: safeCols,
|
||||||
@@ -6036,7 +6054,7 @@ function registerTerminalHandlers(): void {
|
|||||||
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
event.sender.once('destroyed', () => stopTerminalsForWebContents(webContentsId))
|
||||||
return true
|
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 webContentsId = event.sender.id
|
||||||
const terminalKey = getTerminalKey(webContentsId, sessionId)
|
const terminalKey = getTerminalKey(webContentsId, sessionId)
|
||||||
cancelStopTerminal(terminalKey)
|
cancelStopTerminal(terminalKey)
|
||||||
@@ -6068,7 +6086,9 @@ function registerTerminalHandlers(): void {
|
|||||||
'-i',
|
'-i',
|
||||||
target.identityFile,
|
target.identityFile,
|
||||||
`${target.user}@${target.host}`,
|
`${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',
|
name: 'xterm-256color',
|
||||||
cols: safeCols,
|
cols: safeCols,
|
||||||
|
|||||||
@@ -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)
|
commitDiff: (moduleName: string, workspaceRoot?: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('local-workspace:git:commit-diff', moduleName, workspaceRoot, folderName, commitHash)
|
||||||
},
|
},
|
||||||
terminal: {
|
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),
|
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),
|
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),
|
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)
|
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ipcRenderer.invoke('remote-workspace:git:commit-diff', target, moduleName, folderName, commitHash)
|
||||||
},
|
},
|
||||||
terminal: {
|
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),
|
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),
|
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),
|
write: (data: string, sessionId?: string) => ipcRenderer.send('remote-workspace:terminal:write', data, sessionId),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import type { DiffViewLine, GitChangedFile } from './features/workspace/gitUtils
|
|||||||
import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs'
|
import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs'
|
||||||
import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav'
|
import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav'
|
||||||
import WorkspaceTopBar from './features/workspace/WorkspaceTopBar'
|
import WorkspaceTopBar from './features/workspace/WorkspaceTopBar'
|
||||||
|
import WorkspaceCameraPanel from './features/workspace/WorkspaceCameraPanel'
|
||||||
import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel'
|
import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel'
|
||||||
import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals'
|
import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals'
|
||||||
|
|
||||||
@@ -520,6 +521,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const visualizationHostInstanceId = useRef(`${platformId}:${Math.random().toString(36).slice(2)}`).current
|
const visualizationHostInstanceId = useRef(`${platformId}:${Math.random().toString(36).slice(2)}`).current
|
||||||
const [powerActionModalOpen, setPowerActionModalOpen] = useState(false)
|
const [powerActionModalOpen, setPowerActionModalOpen] = useState(false)
|
||||||
|
const [cameraOpen, setCameraOpen] = useState(false)
|
||||||
|
useEffect(() => { setCameraOpen(false) }, [platformId, workspaceVisible])
|
||||||
const [controlCountdownNow, setControlCountdownNow] = useState(Date.now())
|
const [controlCountdownNow, setControlCountdownNow] = useState(Date.now())
|
||||||
const {
|
const {
|
||||||
qaMode,
|
qaMode,
|
||||||
@@ -2112,11 +2115,15 @@ function LocalPcWorkspaceMockup({
|
|||||||
copyTerminalSelection,
|
copyTerminalSelection,
|
||||||
pasteTerminalClipboard,
|
pasteTerminalClipboard,
|
||||||
startTerminalResize,
|
startTerminalResize,
|
||||||
stopAllTerminalSessions
|
stopAllTerminalSessions,
|
||||||
|
terminalMode,
|
||||||
|
setTerminalMode,
|
||||||
|
containerAvailable
|
||||||
} = useWorkspaceTerminal({
|
} = useWorkspaceTerminal({
|
||||||
moduleName: selectedModule?.name,
|
moduleName: selectedModule?.name,
|
||||||
moduleFolderName: selectedModule?.folderName,
|
moduleFolderName: selectedModule?.folderName,
|
||||||
openModuleNames: moduleTabs.map((module) => module.name),
|
openModuleNames: moduleTabs.map((module) => module.name),
|
||||||
|
runningModuleNames: availableModules.filter((module) => module.state === 'running').map((module) => module.name),
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
remoteWorkspace,
|
remoteWorkspace,
|
||||||
getRemoteWorkspaceTarget,
|
getRemoteWorkspaceTarget,
|
||||||
@@ -3416,7 +3423,14 @@ function LocalPcWorkspaceMockup({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
|
<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
|
<WorkspaceTopBar
|
||||||
|
cameraOpen={cameraOpen}
|
||||||
|
onToggleCamera={() => setCameraOpen((open) => !open)}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
textStrong={textStrong}
|
textStrong={textStrong}
|
||||||
textMid={textMid}
|
textMid={textMid}
|
||||||
@@ -3924,6 +3938,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
|
|
||||||
{!exampleActive && (
|
{!exampleActive && (
|
||||||
<WorkspaceTerminalPanel
|
<WorkspaceTerminalPanel
|
||||||
|
terminalMode={terminalMode}
|
||||||
|
onModeChange={setTerminalMode}
|
||||||
|
containerAvailable={containerAvailable}
|
||||||
dark={dark}
|
dark={dark}
|
||||||
borderSoft={borderSoft}
|
borderSoft={borderSoft}
|
||||||
textDim={textDim}
|
textDim={textDim}
|
||||||
|
|||||||
Vendored
+2
-2
@@ -229,7 +229,7 @@ interface Window {
|
|||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
terminal: {
|
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>
|
resize: (cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
||||||
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
changeDirectory: (moduleName: string, sessionId?: string, workspaceRoot?: string, folderName?: string) => Promise<boolean>
|
||||||
write: (data: string, sessionId?: string) => void
|
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']>
|
commitDiff: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, commitHash?: string) => ReturnType<Window['git']['commitDiff']>
|
||||||
}
|
}
|
||||||
terminal: {
|
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>
|
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
|
||||||
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
|
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
|
||||||
write: (data: string, sessionId?: string) => void
|
write: (data: string, sessionId?: string) => void
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export type VehicleRuntimeProfile = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PlatformOption = {
|
export type PlatformOption = {
|
||||||
|
camera?: { url: string; password: string } | null
|
||||||
id: string
|
id: string
|
||||||
platformKey: string
|
platformKey: string
|
||||||
title: 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'
|
import { Terminal } from 'lucide-react'
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
terminalMode: 'host' | 'container'
|
||||||
|
onModeChange: (mode: 'host' | 'container') => void
|
||||||
|
containerAvailable: boolean
|
||||||
dark: boolean
|
dark: boolean
|
||||||
borderSoft: string
|
borderSoft: string
|
||||||
textDim: string
|
textDim: string
|
||||||
@@ -11,6 +14,9 @@ type Props = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceTerminalPanel({
|
function WorkspaceTerminalPanel({
|
||||||
|
terminalMode,
|
||||||
|
onModeChange,
|
||||||
|
containerAvailable,
|
||||||
dark,
|
dark,
|
||||||
borderSoft,
|
borderSoft,
|
||||||
textDim,
|
textDim,
|
||||||
@@ -19,6 +25,7 @@ function WorkspaceTerminalPanel({
|
|||||||
terminalHostRef,
|
terminalHostRef,
|
||||||
onStartResize
|
onStartResize
|
||||||
}: Props): React.JSX.Element {
|
}: Props): React.JSX.Element {
|
||||||
|
const modes: ('host' | 'container')[] = containerAvailable ? ['host', 'container'] : ['host']
|
||||||
return (
|
return (
|
||||||
<footer className={['relative border-t', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#e9edf4]'].join(' ')}>
|
<footer className={['relative border-t', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#e9edf4]'].join(' ')}>
|
||||||
<div
|
<div
|
||||||
@@ -31,7 +38,21 @@ function WorkspaceTerminalPanel({
|
|||||||
</div>
|
</div>
|
||||||
<div className={['flex h-10 items-center gap-2 border-b px-3', borderSoft].join(' ')}>
|
<div className={['flex h-10 items-center gap-2 border-b px-3', borderSoft].join(' ')}>
|
||||||
<Terminal size={14} className={textDim} />
|
<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>
|
||||||
<div
|
<div
|
||||||
ref={terminalHostRef}
|
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 type React from 'react'
|
||||||
import Tooltip from '../../components/Tooltip'
|
import Tooltip from '../../components/Tooltip'
|
||||||
|
|
||||||
type WorkspaceTopBarProps = {
|
type WorkspaceTopBarProps = {
|
||||||
|
cameraOpen: boolean
|
||||||
|
onToggleCamera: () => void
|
||||||
dark: boolean
|
dark: boolean
|
||||||
textStrong: string
|
textStrong: string
|
||||||
textMid: string
|
textMid: string
|
||||||
@@ -34,6 +36,8 @@ type WorkspaceTopBarProps = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function WorkspaceTopBar({
|
export default function WorkspaceTopBar({
|
||||||
|
cameraOpen,
|
||||||
|
onToggleCamera,
|
||||||
dark,
|
dark,
|
||||||
textStrong,
|
textStrong,
|
||||||
textMid,
|
textMid,
|
||||||
@@ -93,6 +97,9 @@ export default function WorkspaceTopBar({
|
|||||||
<PanelLeft size={14} />
|
<PanelLeft size={14} />
|
||||||
시각화
|
시각화
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
|
||||||
|
<Camera size={14} />오버뷰
|
||||||
|
</button>
|
||||||
{showControlCountdown && (
|
{showControlCountdown && (
|
||||||
<>
|
<>
|
||||||
<span className={['ml-2 h-4 w-px', dark ? 'bg-white/20' : 'bg-[#cfd6e4]'].join(' ')} />
|
<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 { CirclePlay, LoaderCircle, Minus, PanelLeft, Power, Square, X } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useWorkspaceWindowFocus } from './useWorkspaceWindowFocus'
|
||||||
|
|
||||||
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
|
export type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
|
||||||
|
|
||||||
@@ -50,6 +51,7 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
onResizeStart
|
onResizeStart
|
||||||
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
|
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
|
||||||
const [terminateConfirmOpen, setTerminateConfirmOpen] = useState(false)
|
const [terminateConfirmOpen, setTerminateConfirmOpen] = useState(false)
|
||||||
|
const { frameRef, bringToFront, zIndex } = useWorkspaceWindowFocus('visualization', !minimized)
|
||||||
const [activeViewerUrl, setActiveViewerUrl] = useState<string | null>(() => (running ? viewerUrl : null))
|
const [activeViewerUrl, setActiveViewerUrl] = useState<string | null>(() => (running ? viewerUrl : null))
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -60,12 +62,15 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<section
|
<section
|
||||||
|
onPointerDownCapture={bringToFront}
|
||||||
|
onFocusCapture={bringToFront}
|
||||||
className={[
|
className={[
|
||||||
'absolute z-40 overflow-hidden rounded-lg border shadow-[0_18px_48px_rgba(0,0,0,0.32)]',
|
'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'
|
dark ? 'border-white/12 bg-[#1c1c1c]' : 'border-[#cfd6e4] bg-white'
|
||||||
].join(' ')}
|
].join(' ')}
|
||||||
style={large
|
style={large
|
||||||
? {
|
? {
|
||||||
|
zIndex,
|
||||||
left: 0,
|
left: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
top: 40,
|
top: 40,
|
||||||
@@ -73,6 +78,7 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
display: minimized ? 'none' : undefined
|
display: minimized ? 'none' : undefined
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
zIndex,
|
||||||
left: position.x,
|
left: position.x,
|
||||||
top: position.y,
|
top: position.y,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
@@ -142,6 +148,7 @@ export default function WorkspaceVisualizationPanel({
|
|||||||
>
|
>
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative min-h-0 flex-1">
|
||||||
<iframe
|
<iframe
|
||||||
|
ref={frameRef}
|
||||||
title="Autonomy Studio Visualization"
|
title="Autonomy Studio Visualization"
|
||||||
src={activeViewerUrl ?? 'about:blank'}
|
src={activeViewerUrl ?? 'about:blank'}
|
||||||
className="h-full w-full border-0"
|
className="h-full w-full border-0"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ type TerminalContextMenu = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ManagedTerminalSession = {
|
type ManagedTerminalSession = {
|
||||||
|
mode: 'host' | 'container'
|
||||||
moduleName: string
|
moduleName: string
|
||||||
remoteWorkspace: boolean
|
remoteWorkspace: boolean
|
||||||
attachedTerminal: XTerm | null
|
attachedTerminal: XTerm | null
|
||||||
@@ -23,6 +24,7 @@ type UseWorkspaceTerminalOptions = {
|
|||||||
moduleName?: string | null
|
moduleName?: string | null
|
||||||
moduleFolderName?: string | null
|
moduleFolderName?: string | null
|
||||||
openModuleNames: string[]
|
openModuleNames: string[]
|
||||||
|
runningModuleNames: string[]
|
||||||
clonePathPreview: string
|
clonePathPreview: string
|
||||||
remoteWorkspace: boolean
|
remoteWorkspace: boolean
|
||||||
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
|
||||||
@@ -33,6 +35,9 @@ type UseWorkspaceTerminalOptions = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UseWorkspaceTerminalResult = {
|
type UseWorkspaceTerminalResult = {
|
||||||
|
terminalMode: 'host' | 'container'
|
||||||
|
setTerminalMode: (mode: 'host' | 'container') => void
|
||||||
|
containerAvailable: boolean
|
||||||
terminalContextMenu: TerminalContextMenu | null
|
terminalContextMenu: TerminalContextMenu | null
|
||||||
setTerminalContextMenu: React.Dispatch<React.SetStateAction<TerminalContextMenu | null>>
|
setTerminalContextMenu: React.Dispatch<React.SetStateAction<TerminalContextMenu | null>>
|
||||||
terminalContextMenuRef: React.RefObject<HTMLDivElement | null>
|
terminalContextMenuRef: React.RefObject<HTMLDivElement | null>
|
||||||
@@ -49,6 +54,7 @@ export function useWorkspaceTerminal({
|
|||||||
moduleName,
|
moduleName,
|
||||||
moduleFolderName,
|
moduleFolderName,
|
||||||
openModuleNames,
|
openModuleNames,
|
||||||
|
runningModuleNames,
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
remoteWorkspace,
|
remoteWorkspace,
|
||||||
getRemoteWorkspaceTarget,
|
getRemoteWorkspaceTarget,
|
||||||
@@ -68,6 +74,13 @@ export function useWorkspaceTerminal({
|
|||||||
const terminalSessionsRef = useRef<Map<string, ManagedTerminalSession>>(new Map())
|
const terminalSessionsRef = useRef<Map<string, ManagedTerminalSession>>(new Map())
|
||||||
const resetKeyRef = useRef(resetKey)
|
const resetKeyRef = useRef(resetKey)
|
||||||
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
|
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
|
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
|
||||||
|
|
||||||
@@ -126,13 +139,15 @@ export function useWorkspaceTerminal({
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const startY = event.clientY
|
const startY = event.clientY
|
||||||
const startHeight = terminalPanelHeight
|
const startHeight = terminalPanelHeight
|
||||||
|
const workspaceHeight = event.currentTarget.closest('section')?.getBoundingClientRect().height ?? window.innerHeight
|
||||||
|
const maxHeight = Math.max(150, workspaceHeight - 140)
|
||||||
|
|
||||||
const relayoutWorkspace = (): void => {
|
const relayoutWorkspace = (): void => {
|
||||||
fitTerminal()
|
fitTerminal()
|
||||||
layoutEditorToPane()
|
layoutEditorToPane()
|
||||||
}
|
}
|
||||||
const onMouseMove = (moveEvent: MouseEvent): void => {
|
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)
|
setTerminalPanelHeight(nextHeight)
|
||||||
window.requestAnimationFrame(relayoutWorkspace)
|
window.requestAnimationFrame(relayoutWorkspace)
|
||||||
}
|
}
|
||||||
@@ -147,6 +162,19 @@ export function useWorkspaceTerminal({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const openModuleNamesKey = openModuleNames.join('\u0000')
|
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(() => {
|
useEffect(() => {
|
||||||
const openModuleNameSet = new Set(openModuleNames)
|
const openModuleNameSet = new Set(openModuleNames)
|
||||||
@@ -158,12 +186,13 @@ export function useWorkspaceTerminal({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (resetKeyRef.current === resetKey) return
|
if (resetKeyRef.current === resetKey) return
|
||||||
resetKeyRef.current = resetKey
|
resetKeyRef.current = resetKey
|
||||||
|
setModuleTerminalModes({})
|
||||||
stopAllTerminalSessions()
|
stopAllTerminalSessions()
|
||||||
}, [resetKey])
|
}, [resetKey])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
|
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
|
||||||
const terminalSessionId = getModuleTerminalSessionId(moduleName)
|
const terminalSessionId = getModuleTerminalSessionId(moduleName) + (terminalMode === 'container' ? ':container' : '')
|
||||||
terminalSessionIdRef.current = terminalSessionId
|
terminalSessionIdRef.current = terminalSessionId
|
||||||
|
|
||||||
const term = new XTerm({
|
const term = new XTerm({
|
||||||
@@ -230,6 +259,7 @@ export function useWorkspaceTerminal({
|
|||||||
suppressedData.push(data)
|
suppressedData.push(data)
|
||||||
}, terminalSessionId)
|
}, terminalSessionId)
|
||||||
terminalSession = {
|
terminalSession = {
|
||||||
|
mode: terminalMode,
|
||||||
moduleName,
|
moduleName,
|
||||||
remoteWorkspace,
|
remoteWorkspace,
|
||||||
attachedTerminal: null,
|
attachedTerminal: null,
|
||||||
@@ -259,6 +289,7 @@ export function useWorkspaceTerminal({
|
|||||||
term.rows,
|
term.rows,
|
||||||
terminalSessionId,
|
terminalSessionId,
|
||||||
moduleFolderName ?? undefined,
|
moduleFolderName ?? undefined,
|
||||||
|
terminalMode,
|
||||||
)
|
)
|
||||||
: window.localWorkspace.terminal.start(
|
: window.localWorkspace.terminal.start(
|
||||||
moduleName,
|
moduleName,
|
||||||
@@ -267,10 +298,14 @@ export function useWorkspaceTerminal({
|
|||||||
terminalSessionId,
|
terminalSessionId,
|
||||||
clonePathPreview,
|
clonePathPreview,
|
||||||
moduleFolderName ?? undefined,
|
moduleFolderName ?? undefined,
|
||||||
|
terminalMode,
|
||||||
)
|
)
|
||||||
void start
|
void start
|
||||||
.then(resolvePromise)
|
.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
|
terminalFitRef.current = null
|
||||||
terminalSessionIdRef.current = null
|
terminalSessionIdRef.current = null
|
||||||
}
|
}
|
||||||
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
|
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive, terminalMode])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
terminalMode,
|
||||||
|
setTerminalMode,
|
||||||
|
containerAvailable,
|
||||||
terminalContextMenu,
|
terminalContextMenu,
|
||||||
setTerminalContextMenu,
|
setTerminalContextMenu,
|
||||||
terminalContextMenuRef,
|
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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user