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
+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,