feat: add weekly curriculum cover image upload and display

This commit is contained in:
2026-09-23 16:03:33 +09:00
parent a7c9cdc16e
commit 656b1bf62b
23 changed files with 723 additions and 95 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.32", "version": "1.2.1",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+11
View File
@@ -0,0 +1,11 @@
const image = 'future-nav2-camera-test:humble-jetson_orin'
export function cameraRuntimeArtifacts<T extends { image?: string | null }>(
folder: string,
configured: T[] | null | undefined,
): T[] | null | undefined {
if (!configured) return configured
const camera = folder.includes('lms-nav2-camera-test')
// The optional camera image must not be downloaded for other Nav2 modules.
return configured.filter((item) => (item.image?.trim() === image) === camera)
}
+43 -41
View File
@@ -1,5 +1,7 @@
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 { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles'
import { resolveContainerTerminal } from './containerTerminal' 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'
@@ -142,6 +144,7 @@ function getBundledZstdPath(): string | null {
} }
type CachedModuleFiles = { type CachedModuleFiles = {
filePaths?: string[]
root: string | null root: string | null
absoluteRoot: string | null absoluteRoot: string | null
files: Record<string, string> files: Record<string, string>
@@ -4598,6 +4601,7 @@ function buildNav2DockerRunScript(): string {
'else', 'else',
'case "$MODULE_FOLDER" in', 'case "$MODULE_FOLDER" in',
...yahboomModuleCases(), ...yahboomModuleCases(),
' *lms-nav2-camera-test*) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py ;;',
' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;', ' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;', ' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
@@ -4635,6 +4639,12 @@ function buildNav2DockerRunScript(): string {
'', '',
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }', 'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
...yahboomDevicePreflight(), ...yahboomDevicePreflight(),
'CAMERA_DEVICE=""',
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
' CAMERA_DEVICE=/dev/video0',
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }',
'fi',
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true', 'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'', '',
'args=(', 'args=(',
@@ -4652,6 +4662,12 @@ function buildNav2DockerRunScript(): string {
' -v /etc/localtime:/etc/localtime:ro', ' -v /etc/localtime:/etc/localtime:ro',
')', ')',
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi', 'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
'if [ -n "$CAMERA_DEVICE" ]; then',
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")',
' for capture_device in /dev/capture-vi-channel*; do',
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
' done',
'fi',
'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then', 'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then',
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)', ' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
'fi', 'fi',
@@ -4695,6 +4711,7 @@ function buildNav2DockerBuildScript(): string {
'else', 'else',
'case "$MODULE_FOLDER" in', 'case "$MODULE_FOLDER" in',
...yahboomModuleCases(true), ...yahboomModuleCases(true),
' *lms-nav2-camera-test*) TARGET=camera_test_pkg ;;',
' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;', ' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;',
' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;', ' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;',
' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;', ' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;',
@@ -5051,6 +5068,7 @@ async function resolveLocalModuleRuntime(
rosParams: string, rosParams: string,
runtimeImages?: RuntimeImageArtifact[] | null, runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> { ): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> {
runtimeImages = cameraRuntimeArtifacts(folderName, runtimeImages)
if (yahboomSimulation(folderName, rosParams)) { if (yahboomSimulation(folderName, rosParams)) {
const artifact = runtimeImages?.find((item) => const artifact = runtimeImages?.find((item) =>
item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim()) item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim())
@@ -5062,7 +5080,9 @@ async function resolveLocalModuleRuntime(
) )
return { return {
image, image,
artifacts: yahboomRuntimeImage(folderName) artifacts: folderName.includes('lms-nav2-camera-test')
? (runtimeImages ?? []).filter((item) => item.image?.trim() === image)
: yahboomRuntimeImage(folderName)
? yahboomRuntimeArtifacts(folderName, image, runtimeImages) ? yahboomRuntimeArtifacts(folderName, image, runtimeImages)
: requiredRuntimeImages(runtimeImages, image), : requiredRuntimeImages(runtimeImages, image),
} }
@@ -5309,6 +5329,7 @@ async function startRemoteWorkspaceModule(
if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.') if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.')
const nav2Module = isNav2ModuleFolder(targetFolderName) const nav2Module = isNav2ModuleFolder(targetFolderName)
runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages)
const runtimeImage = nav2Module const runtimeImage = nav2Module
? await resolveRemoteCustomModuleRuntimeImage( ? await resolveRemoteCustomModuleRuntimeImage(
target, target,
@@ -5320,7 +5341,9 @@ async function startRemoteWorkspaceModule(
? await ensureRemoteNav2RuntimeAssets(target) ? await ensureRemoteNav2RuntimeAssets(target)
: await ensureRemoteStudioRuntimeAssets(target) : await ensureRemoteStudioRuntimeAssets(target)
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId) const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 실행 중...") reportModuleProgress("모듈 실행 중...")
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
@@ -5403,6 +5426,7 @@ async function buildRemoteWorkspaceModule(
): Promise<{ ok: boolean; message: string }> { ): Promise<{ ok: boolean; message: string }> {
const target = normalizeRemoteTarget(targetInput) const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName const targetFolderName = folderName?.trim() || moduleName
runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages)
const dockerInfo = parseModuleDockerInfo(targetFolderName) const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
@@ -5413,7 +5437,9 @@ async function buildRemoteWorkspaceModule(
targetFolderName, targetFolderName,
yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages), yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
) )
await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages)) await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 빌드 중...") reportModuleProgress("모듈 빌드 중...")
await runSsh(target, [ await runSsh(target, [
'set -e', 'set -e',
@@ -5723,44 +5749,11 @@ async function collectModuleFiles(moduleName: string, workspaceRoot?: string | n
async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null): Promise<CachedModuleFiles> { async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null): Promise<CachedModuleFiles> {
const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName) const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
const skippedNames = Array.from(skippedCacheDirs).map(quoteShellSingle).join(' ') const output = await runUnqueuedSshOutput(target, remoteModuleFilesScript(moduleRoot, { action: 'list' }), 30000)
const script = [ const listing = JSON.parse(output) as { filePaths: string[]; folders: string[] }
'set +e', const prefix = (path: string): string => `modules/${moduleName}/${path}`
`ROOT=${quoteShellSingle(moduleRoot)}`, return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot,
`SKIPPED="${skippedNames}"`, files: {}, filePaths: listing.filePaths.map(prefix), folders: listing.folders.map(prefix) }
'find "$ROOT" -mindepth 1 -maxdepth 10 \\( -name .git -o -name build -o -name install -o -name log -o -name node_modules -o -name docker \\) -prune -o -type d -printf "D\\t%P\\n" -o -type f -printf "F\\t%P\\t%s\\n" | while IFS="$(printf \'\\t\')" read -r KIND REL SIZE; do',
' [ -z "$REL" ] && continue',
' NAME="${REL##*/}"',
' case "$KIND" in',
' D) printf "D\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" ;;',
' F)',
' CONTENT="QmluYXJ5IG9yIGxhcmdlIGZpbGUgcHJldmlldyBpcyBub3QgYXZhaWxhYmxlLg=="',
' if [ "${SIZE:-0}" -le 1048576 ]; then',
' case "$NAME" in',
' *.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh|*.py|*.xml|*.urdf|*.xacro|*.yaml|*.yml|*.json|*.txt|*.md|*.cmake|*.launch|*.sh|*.bash|*.toml|*.ini|*.cfg|*.rviz) CONTENT="$(base64 -w0 "$ROOT/$REL" 2>/dev/null)" ;;',
' esac',
' fi',
' printf "F\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$CONTENT"',
' ;;',
' esac',
'done'
].join('\n')
// Source transfers must not hold up module start/stop on the control queue.
const output = await runUnqueuedSshOutput(target, script, 30000)
const files: Record<string, string> = {}
const folders: string[] = []
for (const line of output.split('\n')) {
if (!line.trim()) continue
const [kind, encodedPath = '', encodedContent = ''] = line.split('\t')
const relativePath = decodeRemoteField(encodedPath)
if (!relativePath) continue
const rendererPath = `modules/${moduleName}/${relativePath}`
if (kind === 'D') folders.push(rendererPath)
if (kind === 'F') files[rendererPath] = decodeRemoteField(encodedContent)
}
return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot, files, folders }
} }
async function collectRootFiles(rootPath: string, rendererRoot: string): Promise<CachedModuleFiles> { async function collectRootFiles(rootPath: string, rendererRoot: string): Promise<CachedModuleFiles> {
@@ -6447,6 +6440,15 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules)) ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules))
ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash)) ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash))
ipcMain.handle('remote-workspace:module-files:read', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => collectRemoteModuleFiles(target, moduleName, folderName)) ipcMain.handle('remote-workspace:module-files:read', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => collectRemoteModuleFiles(target, moduleName, folderName))
ipcMain.handle('remote-workspace:module-files:content', async (_event, target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string | null) => {
const resolved = await resolveRemoteRendererModulePath(target, moduleName, path, folderName)
return JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'content', path: resolved.relativePath }), 30000))
})
ipcMain.handle('remote-workspace:module-files:search', async (_event, target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string | null) => {
const resolved = await resolveRemoteGitModuleRoot(target, moduleName, folderName)
const results = JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'search', query }), 30000)) as Array<{ file: string; lineNumber: number; lineText: string }>
return results.map((result) => ({ ...result, file: `modules/${moduleName}/${result.file}` }))
})
ipcMain.handle('remote-workspace:module-files:image', (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => readRemoteWorkspaceModuleImage(target, moduleName, rendererPath, folderName)) ipcMain.handle('remote-workspace:module-files:image', (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => readRemoteWorkspaceModuleImage(target, moduleName, rendererPath, folderName))
ipcMain.handle('remote-workspace:module-files:create', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, kind: 'file' | 'folder', folderName?: string | null) => { ipcMain.handle('remote-workspace:module-files:create', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, kind: 'file' | 'folder', folderName?: string | null) => {
const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName) const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName)
+69
View File
@@ -0,0 +1,69 @@
type Request = { action: 'list' } | { action: 'content'; path: string } | { action: 'search'; query: string }
export function remoteModuleFilesScript(root: string, request: Request): string {
const input = Buffer.from(JSON.stringify({ root, ...request })).toString('base64')
return `python3 - '${input}' <<'PY'
import base64, json, os, stat, sys
args = json.loads(base64.b64decode(sys.argv[1]))
root = os.path.realpath(args['root'])
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
placeholder = 'Binary or large file preview is not available.'
def content(relative):
if os.path.isabs(relative) or '..' in relative.split('/'):
raise ValueError('Invalid module path')
path = os.path.realpath(os.path.join(root, relative))
if os.path.commonpath([root, path]) != root:
raise ValueError('Invalid module path')
info = os.stat(path)
if not stat.S_ISREG(info.st_mode):
raise ValueError('Not a regular file')
if os.path.splitext(path)[1].lower() not in extensions or info.st_size > 1048576:
return placeholder
with open(path, 'rb') as fp:
data = fp.read(1048577)
if len(data) > 1048576 or b'\\x00' in data:
return placeholder
return data.decode('utf-8', errors='replace')
def entries():
def fail(error):
raise error
for current, dirs, files in os.walk(root, onerror=fail, followlinks=False):
depth = len(os.path.relpath(current, root).split('/')) if current != root else 0
dirs[:] = sorted(d for d in dirs if d not in skip and not os.path.islink(os.path.join(current, d)))
if depth >= 10:
dirs[:] = []
continue
for name in dirs:
yield 'D', os.path.relpath(os.path.join(current, name), root)
for name in sorted(files):
path = os.path.join(current, name)
if not os.path.islink(path) and os.path.isfile(path):
yield 'F', os.path.relpath(path, root)
if args['action'] == 'content':
print(json.dumps({'content': content(args['path'])}))
elif args['action'] == 'list':
paths, folders = [], []
for kind, path in entries():
(folders if kind == 'D' else paths).append(path)
print(json.dumps({'filePaths': paths, 'folders': folders}))
else:
query = args['query'].strip().lower()
results = []
if len(query) >= 2:
for kind, path in entries():
if kind != 'F':
continue
text = content(path)
if text == placeholder:
continue
for number, line in enumerate(text.split('\\n'), 1):
if query in line.lower():
results.append({'file': path, 'lineNumber': number, 'lineText': (line.strip() or line)[:2000]})
if len(results) >= 200:
break
if len(results) >= 200:
break
print(json.dumps(results))
PY`
}
+2
View File
@@ -289,6 +289,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
contextBridge.exposeInMainWorld('remoteWorkspace', { contextBridge.exposeInMainWorld('remoteWorkspace', {
moduleFiles: { moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName), read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName),
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:search', target, moduleName, query, folderName),
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName), image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName),
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName), create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName), write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
+69 -20
View File
@@ -49,7 +49,7 @@
#cameraPreviewWrap { #cameraPreviewWrap {
position:absolute; position:absolute;
left:16px; left:16px;
top:48px; top:16px;
z-index:10; z-index:10;
display:none; display:none;
width:min(260px, calc(100vw - 32px)); width:min(260px, calc(100vw - 32px));
@@ -69,7 +69,7 @@
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; } #cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
#cameraPreviewToggle { #cameraPreviewToggle {
position:absolute; position:absolute;
right:0; left:0;
top:100%; top:100%;
display:grid; display:grid;
width:30px; width:30px;
@@ -110,6 +110,8 @@
bottom:16px; bottom:16px;
z-index:10; z-index:10;
padding:7px 9px; padding:7px 9px;
max-width:calc(100% - 32px);
box-sizing:border-box;
border:1px solid rgba(255,255,255,.24); border:1px solid rgba(255,255,255,.24);
background:rgba(17,24,39,.34); background:rgba(17,24,39,.34);
color:rgba(255,255,255,.82); color:rgba(255,255,255,.82);
@@ -369,8 +371,8 @@
.nav2-navigation #rosTopicStatusWrap { bottom:16px; } .nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.real-runtime #topLeftControls { display:none; } .real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; } .real-runtime #keyGuide { display:none; }
.real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; } .real-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; } .real-teleop #rosTopicStatusWrap { bottom:50px; }
</style> </style>
</head> </head>
<body> <body>
@@ -444,7 +446,8 @@ const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform. // Keep the prior real-platform default when an older LMS URL has no platform.
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME); const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom'; const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom';
const REAL_YAHBOOM_TELEOP = REAL_RUNTIME && USE_YAHBOOM_MODEL; const REAL_MKMINI_TELEOP = REAL_RUNTIME && USE_MKMINI_MODEL;
const REAL_TELEOP = REAL_RUNTIME && (USE_YAHBOOM_MODEL || USE_MKMINI_MODEL);
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1'; const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD; const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000; const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
@@ -495,8 +498,11 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (EDITOR_MODE) document.body.classList.add('editor-mode'); if (EDITOR_MODE) document.body.classList.add('editor-mode');
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`); if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
if (REAL_RUNTIME) document.body.classList.add('real-runtime'); if (REAL_RUNTIME) document.body.classList.add('real-runtime');
if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) { if (REAL_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-yahboom-teleop'); document.body.classList.add('real-teleop');
if (REAL_MKMINI_TELEOP) {
document.querySelector('#keyGuide div').textContent = 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지';
}
} }
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden'); if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
@@ -895,6 +901,7 @@ let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false; let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null; let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false; let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null; let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null; let cameraFrameObjectUrl = null;
let pathVisible = false; let pathVisible = false;
@@ -2051,28 +2058,47 @@ function setCameraPreviewAvailable(available) {
if (cameraPreview) cameraPreview.removeAttribute('src'); if (cameraPreview) cameraPreview.removeAttribute('src');
} }
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() { async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return; if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true; cameraFrameRequestInFlight = true;
try { try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) }); const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) });
if (!response.ok) { if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
return; return;
} }
const nextUrl = URL.createObjectURL(await response.blob()); const frame = await response.blob();
if (!hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl; const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl; cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl; if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl); if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) { } catch (_) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false); setCameraPreviewAvailable(false);
} finally { } finally {
cameraFrameRequestInFlight = false; cameraFrameRequestInFlight = false;
} }
} }
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true)); cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false)); cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) { function drawMiniMap(state) {
@@ -3217,7 +3243,7 @@ function connectControlSocket() {
if (!CONTROL_WS) return; if (!CONTROL_WS) return;
controlSocket = new WebSocket(CONTROL_WS); controlSocket = new WebSocket(CONTROL_WS);
controlSocket.addEventListener('close', () => { controlSocket.addEventListener('close', () => {
if (REAL_YAHBOOM_TELEOP) { if (REAL_TELEOP) {
activeMoveKey = null; activeMoveKey = null;
stopRobot(); stopRobot();
} }
@@ -3228,8 +3254,16 @@ function connectControlSocket() {
}); });
} }
let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22; let maxLinear = REAL_TELEOP ? 0.1 : 0.22;
let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84; let maxAngular = REAL_TELEOP ? 0.5 : 2.84;
const mkMiniHeldKeys = new Set();
const MKMINI_REAL_ANGULAR_SPEED = 0.08;
function teleopKey(event) {
const key = event.key.toLowerCase();
return REAL_MKMINI_TELEOP
? ({ arrowup: 'i', arrowdown: ',', arrowleft: 'j', arrowright: 'l', ' ': 'k' }[key] ?? key)
: key;
}
let activeMoveKey = null; let activeMoveKey = null;
let teleopInterval = null; let teleopInterval = null;
let singleMoveStopTimer = null; let singleMoveStopTimer = null;
@@ -3253,7 +3287,7 @@ const speedBindings = {
}; };
function canUseTeleop(event) { function canUseTeleop(event) {
if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false; if (EDITOR_MODE || (REAL_RUNTIME && (!REAL_TELEOP || !realTeleopReady()))) return false;
if (!document.hasFocus()) return false; if (!document.hasFocus()) return false;
if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false; if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false;
const tag = event.target?.tagName?.toLowerCase(); const tag = event.target?.tagName?.toLowerCase();
@@ -3292,7 +3326,7 @@ function flushQueuedCmdVel() {
function sendCmdVel(linear, angular) { function sendCmdVel(linear, angular) {
const payload = { type:'cmd_vel', linear, angular }; const payload = { type:'cmd_vel', linear, angular };
if (REAL_YAHBOOM_TELEOP) { if (REAL_TELEOP) {
// Never queue physical motion for replay after a connection recovers. // Never queue physical motion for replay after a connection recovers.
if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return; if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return;
if ((linear || angular) && !realTeleopReady()) return; if ((linear || angular) && !realTeleopReady()) return;
@@ -3346,6 +3380,7 @@ function setSimulatorMkMiniSteering(angle) {
} }
function stopRobot() { function stopRobot() {
mkMiniHeldKeys.clear();
if (teleopInterval) { if (teleopInterval) {
window.clearInterval(teleopInterval); window.clearInterval(teleopInterval);
teleopInterval = null; teleopInterval = null;
@@ -3359,13 +3394,25 @@ function stopRobot() {
function sendActiveMoveCommand() { function sendActiveMoveCommand() {
if (!activeMoveKey) return; if (!activeMoveKey) return;
if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) { if (REAL_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
activeMoveKey = null; activeMoveKey = null;
stopRobot(); stopRobot();
return; return;
} }
const [linearSign, angularSign] = moveBindings[activeMoveKey]; const [linearSign, angularSign] = moveBindings[activeMoveKey];
if (linearSign === 0 && angularSign === 0) return; if (linearSign === 0 && angularSign === 0) return;
if (REAL_MKMINI_TELEOP) {
let drive = 0;
let steering = 0;
for (const key of mkMiniHeldKeys) {
drive += moveBindings[key][0];
steering += moveBindings[key][1];
}
// The TOD adapter uses abs(linear) for steering: keep wheel direction
// consistent in reverse, and never turn a steering-only key into motion.
sendCmdVel(Math.sign(drive) * maxLinear, drive ? Math.sign(steering) * MKMINI_REAL_ANGULAR_SPEED : 0);
return;
}
if (USE_MKMINI_MODEL && !REAL_RUNTIME) { if (USE_MKMINI_MODEL && !REAL_RUNTIME) {
if (activeMoveKey === 'j' || activeMoveKey === 'l') { if (activeMoveKey === 'j' || activeMoveKey === 'l') {
@@ -3389,6 +3436,7 @@ function sendActiveMoveCommand() {
function startActiveMoveCommand() { function startActiveMoveCommand() {
sendActiveMoveCommand(); sendActiveMoveCommand();
if (!activeMoveKey) return;
if (teleopInterval) return; if (teleopInterval) return;
teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS); teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS);
} }
@@ -3406,7 +3454,7 @@ function sendSingleMoveCommand(key) {
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if (!canUseTeleop(event)) return; if (!canUseTeleop(event)) return;
const key = event.key.toLowerCase(); const key = teleopKey(event);
if (event.repeat) { if (event.repeat) {
if (moveBindings[key]) event.preventDefault(); if (moveBindings[key]) event.preventDefault();
@@ -3415,7 +3463,7 @@ window.addEventListener('keydown', (event) => {
if (speedBindings[key]) { if (speedBindings[key]) {
event.preventDefault(); event.preventDefault();
if (REAL_YAHBOOM_TELEOP) return; if (REAL_TELEOP) return;
const [linearScale, angularScale] = speedBindings[key]; const [linearScale, angularScale] = speedBindings[key];
maxLinear = Math.max(0.01, maxLinear * linearScale); maxLinear = Math.max(0.01, maxLinear * linearScale);
maxAngular = Math.max(0.1, maxAngular * angularScale); maxAngular = Math.max(0.1, maxAngular * angularScale);
@@ -3431,7 +3479,8 @@ window.addEventListener('keydown', (event) => {
stopRobot(); stopRobot();
return; return;
} }
if (REAL_YAHBOOM_TELEOP) { if (REAL_TELEOP) {
if (REAL_MKMINI_TELEOP) mkMiniHeldKeys.add(key);
activeMoveKey = key; activeMoveKey = key;
startActiveMoveCommand(); startActiveMoveCommand();
return; return;
@@ -3451,7 +3500,7 @@ window.addEventListener('keydown', (event) => {
}); });
window.addEventListener('keyup', (event) => { window.addEventListener('keyup', (event) => {
if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) { if (REAL_TELEOP && (teleopKey(event) === activeMoveKey || (REAL_MKMINI_TELEOP && mkMiniHeldKeys.has(teleopKey(event))))) {
activeMoveKey = null; activeMoveKey = null;
stopRobot(); stopRobot();
return; return;
@@ -3468,7 +3517,7 @@ window.addEventListener('blur', () => {
}); });
function releaseRealTeleop() { function releaseRealTeleop() {
if (!REAL_YAHBOOM_TELEOP || !activeMoveKey) return; if (!REAL_TELEOP || !activeMoveKey) return;
activeMoveKey = null; activeMoveKey = null;
stopRobot(); stopRobot();
} }
+29 -3
View File
@@ -82,6 +82,7 @@ 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 WorkspaceCameraPanel from './features/workspace/WorkspaceCameraPanel'
import { useRemoteEditorFile } from './features/workspace/useRemoteEditorFile'
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'
@@ -1679,6 +1680,7 @@ function LocalPcWorkspaceMockup({
setOpenExampleEditorTabs, setOpenExampleEditorTabs,
loadedModuleFolders, loadedModuleFolders,
setLoadedModuleFolders, setLoadedModuleFolders,
loadedModuleFilePaths,
setLoadedModuleFilePaths, setLoadedModuleFilePaths,
setModuleFileLoadingByName, setModuleFileLoadingByName,
loadedModuleRoots, loadedModuleRoots,
@@ -1712,7 +1714,7 @@ function LocalPcWorkspaceMockup({
return nextDirtyFiles return nextDirtyFiles
}) })
setSelectedGitTarget(null) setSelectedGitTarget(null)
if (module) await loadModuleFiles(module) if (module) await loadModuleFiles(module, true)
await refreshGitStatuses(null) await refreshGitStatuses(null)
onTitlebarNotice('변경사항을 되돌렸습니다', 'info') onTitlebarNotice('변경사항을 되돌렸습니다', 'info')
} catch (error) { } catch (error) {
@@ -1786,7 +1788,7 @@ function LocalPcWorkspaceMockup({
: await window.localWorkspace.git.teamPull(module.name, clonePathPreview, module.folderName) : await window.localWorkspace.git.teamPull(module.name, clonePathPreview, module.folderName)
if (!result.ok) throw new Error(`[${module.name}] ${result.message}`) if (!result.ok) throw new Error(`[${module.name}] ${result.message}`)
} }
await Promise.all(installedGitModules.map((module) => loadModuleFiles(module))) await Promise.all(installedGitModules.map((module) => loadModuleFiles(module, true)))
await refreshGitStatuses(null) await refreshGitStatuses(null)
onTitlebarNotice('팀 변경사항을 받았습니다', 'info') onTitlebarNotice('팀 변경사항을 받았습니다', 'info')
} catch (error) { } catch (error) {
@@ -2321,6 +2323,17 @@ function LocalPcWorkspaceMockup({
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext' const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext'
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined
const remoteEditorFile = useRemoteEditorFile({
enabled: remoteWorkspace,
targetKey: remoteWorkspaceTargetKey,
getTarget: getRemoteWorkspaceTarget,
module: selectedModule,
path: currentActiveEditorFile,
files: editorFiles,
dirty: dirtyEditorFiles,
setFiles: setEditorFiles,
setSaved: setSavedEditorFiles
})
useEffect(() => { useEffect(() => {
if (!currentActiveEditorFile || !activeFileIsImage || currentActiveEditorFile.startsWith('examples/example-code/') || !selectedModule) return if (!currentActiveEditorFile || !activeFileIsImage || currentActiveEditorFile.startsWith('examples/example-code/') || !selectedModule) return
if (imagePreviewSources[currentActiveEditorFile]) return if (imagePreviewSources[currentActiveEditorFile]) return
@@ -3043,9 +3056,11 @@ function LocalPcWorkspaceMockup({
onClose() onClose()
} }
const revealSearchMatch = (lineNumber: number, query: string): boolean => { const revealSearchMatch = (lineNumber: number, query: string): boolean => {
if (remoteEditorFile.loading || remoteEditorFile.error) return false
if (!editorRef.current) return false if (!editorRef.current) return false
const editor = editorRef.current const editor = editorRef.current
const model = editor.getModel() const model = editor.getModel()
if (!model || model.isDisposed()) return false
const lineText = model?.getLineContent(lineNumber) ?? '' const lineText = model?.getLineContent(lineNumber) ?? ''
const matchIndex = query ? lineText.toLowerCase().indexOf(query.toLowerCase()) : -1 const matchIndex = query ? lineText.toLowerCase().indexOf(query.toLowerCase()) : -1
const startColumn = matchIndex >= 0 ? matchIndex + 1 : 1 const startColumn = matchIndex >= 0 ? matchIndex + 1 : 1
@@ -3227,9 +3242,14 @@ function LocalPcWorkspaceMockup({
setModuleSearchQuery, setModuleSearchQuery,
activeModuleSearchQuery, activeModuleSearchQuery,
moduleSearchRunning, moduleSearchRunning,
moduleSearchError,
moduleSearchResults, moduleSearchResults,
openSearchResult openSearchResult
} = useWorkspaceSearch({ } = useWorkspaceSearch({
remoteScopeKey: remoteWorkspace ? remoteWorkspaceTargetKey : undefined,
remoteSearch: remoteWorkspace && selectedModule
? (query) => window.remoteWorkspace.moduleFiles.search(getRemoteWorkspaceTarget(), selectedModule.name, query, selectedModule.folderName)
: undefined,
searchActive, searchActive,
selectedModule, selectedModule,
editorFiles, editorFiles,
@@ -3420,10 +3440,11 @@ function LocalPcWorkspaceMockup({
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null
const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60 const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60
const cameraAvailable = Boolean(curriculumWeek.detail.camera?.url?.trim())
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 {cameraAvailable && cameraOpen && workspaceVisible && <WorkspaceCameraPanel
key={`${courseKey}:${weekNumber}`} key={`${courseKey}:${weekNumber}`}
containerRef={visualizationContainerRef} containerRef={visualizationContainerRef}
camera={curriculumWeek.detail.camera} camera={curriculumWeek.detail.camera}
@@ -3431,6 +3452,7 @@ function LocalPcWorkspaceMockup({
onClose={() => setCameraOpen(false)} onClose={() => setCameraOpen(false)}
/>} />}
<WorkspaceTopBar <WorkspaceTopBar
cameraAvailable={cameraAvailable}
cameraOpen={cameraOpen} cameraOpen={cameraOpen}
onToggleCamera={() => setCameraOpen((open) => !open)} onToggleCamera={() => setCameraOpen((open) => !open)}
dark={dark} dark={dark}
@@ -3675,6 +3697,7 @@ function LocalPcWorkspaceMockup({
moduleSearchQuery={moduleSearchQuery} moduleSearchQuery={moduleSearchQuery}
activeModuleSearchQuery={activeModuleSearchQuery} activeModuleSearchQuery={activeModuleSearchQuery}
moduleSearchRunning={moduleSearchRunning} moduleSearchRunning={moduleSearchRunning}
moduleSearchError={moduleSearchError}
moduleSearchResults={moduleSearchResults} moduleSearchResults={moduleSearchResults}
onStartCreateFile={startCreateFile} onStartCreateFile={startCreateFile}
onRefreshModuleFiles={refreshModuleFiles} onRefreshModuleFiles={refreshModuleFiles}
@@ -3917,6 +3940,9 @@ function LocalPcWorkspaceMockup({
/> />
<WorkspaceEditorPane <WorkspaceEditorPane
fileLoading={remoteEditorFile.loading}
fileError={remoteEditorFile.error}
onRetryFile={remoteEditorFile.retry}
dark={dark} dark={dark}
textDim={textDim} textDim={textDim}
editorPaneRef={editorPaneRef} editorPaneRef={editorPaneRef}
+3
View File
@@ -81,6 +81,7 @@ interface Window {
root: string | null root: string | null
absoluteRoot: string | null absoluteRoot: string | null
files: Record<string, string> files: Record<string, string>
filePaths?: string[]
folders: string[] folders: string[]
}> }>
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }> image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
@@ -298,6 +299,8 @@ interface Window {
remoteWorkspace: { remoteWorkspace: {
moduleFiles: { moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']> read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => Promise<{ content: string }>
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => Promise<Array<{ file: string; lineNumber: number; lineText: string }>>
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']> image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']>
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']> create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']>
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']> write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']>
@@ -1,11 +1,13 @@
import { CheckCircle2, FileText } from 'lucide-react' import { CheckCircle2, FileText } from 'lucide-react'
import type React from 'react' import type React from 'react'
import { useState } from 'react'
type CurriculumDetailWeek = { type CurriculumDetailWeek = {
week: number week: number
title: string title: string
topics: string[] topics: string[]
detail: { detail: {
coverImage?: { downloadUrl: string } | null
overview: string overview: string
topicDetails: string[] topicDetails: string[]
practiceContents?: Array<{ practiceContents?: Array<{
@@ -31,16 +33,34 @@ export default function CurriculumDetailPanel({
allowLearningStepSelection = false, allowLearningStepSelection = false,
onSelectLearningStep onSelectLearningStep
}: CurriculumDetailPanelProps): React.JSX.Element { }: CurriculumDetailPanelProps): React.JSX.Element {
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(null)
const imageUrl = week.detail.coverImage?.downloadUrl
const hasCover = Boolean(imageUrl && failedImageUrl !== imageUrl)
return ( return (
<div data-help="curriculum-detail" className="min-w-0 px-7 pb-7 pt-5"> <div data-help="curriculum-detail" className="min-w-0 px-7 pb-7 pt-5">
<h2 className="text-[18px] font-bold leading-tight">상세정보</h2> <h2 className="text-[18px] font-bold leading-tight">상세정보</h2>
<div className="mt-5 min-h-[548px] pb-6"> <div className="mt-5 min-h-[548px] pb-6">
{selected ? ( {selected ? (
<> <>
<section className="rounded-lg border border-[#dde2ec] bg-[#fbfcff] p-5 shadow-[0_10px_24px_rgba(20,27,44,0.05)]"> <section className={`relative overflow-hidden rounded-lg border border-[#dde2ec] shadow-[0_10px_24px_rgba(20,27,44,0.05)] ${hasCover ? 'bg-white' : 'bg-[#fbfcff] p-5'}`}>
<div>
<div className={hasCover ? 'px-5 pb-3 pt-5' : undefined}>
<h3 className="text-[13px] font-semibold text-[#5f6878]">{week.week}주차 개요</h3> <h3 className="text-[13px] font-semibold text-[#5f6878]">{week.week}주차 개요</h3>
<h3 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{week.title}</h3> <h3 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{week.title}</h3>
</div>
{hasCover && (
<div className="relative h-[240px] overflow-hidden">
<img key={imageUrl} src={imageUrl} alt={`${week.week}주차 대표이미지`}
className="block h-full w-full object-cover object-center"
onError={() => setFailedImageUrl(imageUrl ?? null)} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0"
style={{ background: 'linear-gradient(to bottom, #fff 0%, transparent 24px, transparent calc(100% - 24px), #fff 100%)' }} />
</div>
)}
<div className={hasCover ? 'px-5 pb-5 pt-3' : undefined}>
<p className="mt-1 text-[14px] font-medium leading-[1.5] text-[#4f596b]">{week.detail.overview}</p> <p className="mt-1 text-[14px] font-medium leading-[1.5] text-[#4f596b]">{week.detail.overview}</p>
</div>
</div>
</section> </section>
<div className="mt-4 grid gap-3"> <div className="mt-4 grid gap-3">
@@ -41,6 +41,7 @@ export type CurriculumWeek = {
duration: string duration: string
github: string github: string
detail: { detail: {
coverImage?: DownloadResource | null
camera?: { url: string } | null camera?: { url: string } | null
overview: string overview: string
learningSteps?: LearningStep[] learningSteps?: LearningStep[]
@@ -752,6 +752,7 @@ function ModuleAddDialog({
<option value="">선택</option> <option value="">선택</option>
<option value="Vehicle">Vehicle</option> <option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option> <option value="Sensing">Sensing</option>
<option value="Camera">Camera</option>
<option value="Map">Map</option> <option value="Map">Map</option>
<option value="Perception">Perception</option> <option value="Perception">Perception</option>
<option value="Localization">Localization</option> <option value="Localization">Localization</option>
@@ -933,6 +934,7 @@ function ModuleImportDialog({
<option value="">선택</option> <option value="">선택</option>
<option value="Vehicle">Vehicle</option> <option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option> <option value="Sensing">Sensing</option>
<option value="Camera">Camera</option>
<option value="Map">Map</option> <option value="Map">Map</option>
<option value="Perception">Perception</option> <option value="Perception">Perception</option>
<option value="Localization">Localization</option> <option value="Localization">Localization</option>
@@ -1,7 +1,10 @@
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react' import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react'
import { FileText } from 'lucide-react' import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = { type Props = {
fileLoading?: boolean
fileError?: string
onRetryFile?: () => void
dark: boolean dark: boolean
textDim: string textDim: string
editorPaneRef: React.RefObject<HTMLDivElement | null> editorPaneRef: React.RefObject<HTMLDivElement | null>
@@ -21,6 +24,9 @@ type Props = {
} }
function WorkspaceEditorPane({ function WorkspaceEditorPane({
fileLoading,
fileError,
onRetryFile,
dark, dark,
textDim, textDim,
editorPaneRef, editorPaneRef,
@@ -70,6 +76,13 @@ function WorkspaceEditorPane({
)} )}
</div> </div>
</div> </div>
) : fileLoading || fileError ? (
<div className={['flex h-full min-w-0 flex-col items-center justify-center gap-3 p-5 text-[13px]', textDim].join(' ')}>
{fileError ? <>
<p role="alert" className="max-w-full break-all">파일을 불러오지 못했습니다.</p>
<button type="button" title="다시 불러오기" aria-label="다시 불러오기" onClick={onRetryFile} className="cursor-pointer p-2"><RotateCw size={18} /></button>
</> : <><Loader2 size={18} className="animate-spin" /><span role="status">파일 불러오는 중...</span></>}
</div>
) : ( ) : (
<Editor <Editor
height="100%" height="100%"
@@ -92,7 +105,7 @@ function WorkspaceEditorPane({
scrollBeyondLastLine: false, scrollBeyondLastLine: false,
tabSize, tabSize,
wordWrap: wordWrap ? 'on' : 'off', wordWrap: wordWrap ? 'on' : 'off',
readOnly: exampleActive readOnly: exampleActive || editorValue === 'Binary or large file preview is not available.'
}} }}
/> />
)} )}
@@ -57,6 +57,7 @@ type WorkspaceFilePanelProps = {
moduleSearchQuery: string moduleSearchQuery: string
activeModuleSearchQuery: string activeModuleSearchQuery: string
moduleSearchRunning: boolean moduleSearchRunning: boolean
moduleSearchError?: string | null
moduleSearchResults: SearchResult[] moduleSearchResults: SearchResult[]
onStartCreateFile: (kind: 'file' | 'folder') => void onStartCreateFile: (kind: 'file' | 'folder') => void
onRefreshModuleFiles: () => void onRefreshModuleFiles: () => void
@@ -171,6 +172,7 @@ export default function WorkspaceFilePanel({
moduleSearchQuery, moduleSearchQuery,
activeModuleSearchQuery, activeModuleSearchQuery,
moduleSearchRunning, moduleSearchRunning,
moduleSearchError,
moduleSearchResults, moduleSearchResults,
onStartCreateFile, onStartCreateFile,
onRefreshModuleFiles, onRefreshModuleFiles,
@@ -232,6 +234,7 @@ export default function WorkspaceFilePanel({
moduleSearchQuery={moduleSearchQuery} moduleSearchQuery={moduleSearchQuery}
activeModuleSearchQuery={activeModuleSearchQuery} activeModuleSearchQuery={activeModuleSearchQuery}
moduleSearchRunning={moduleSearchRunning} moduleSearchRunning={moduleSearchRunning}
moduleSearchError={moduleSearchError}
moduleSearchResults={moduleSearchResults} moduleSearchResults={moduleSearchResults}
onSearchChange={onSearchChange} onSearchChange={onSearchChange}
onOpenSearchResult={onOpenSearchResult} onOpenSearchResult={onOpenSearchResult}
@@ -284,12 +287,13 @@ function SearchPanel({
moduleSearchQuery, moduleSearchQuery,
activeModuleSearchQuery, activeModuleSearchQuery,
moduleSearchRunning, moduleSearchRunning,
moduleSearchError,
moduleSearchResults, moduleSearchResults,
onSearchChange, onSearchChange,
onOpenSearchResult onOpenSearchResult
}: Pick<WorkspaceFilePanelProps, }: Pick<WorkspaceFilePanelProps,
'dark' | 'textMid' | 'textDim' | 'selectedModuleName' | 'moduleSearchInputRef' | 'moduleSearchQuery' | 'dark' | 'textMid' | 'textDim' | 'selectedModuleName' | 'moduleSearchInputRef' | 'moduleSearchQuery' |
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchResults' | 'onSearchChange' | 'onOpenSearchResult' 'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchError' | 'moduleSearchResults' | 'onSearchChange' | 'onOpenSearchResult'
>): React.JSX.Element { >): React.JSX.Element {
return ( return (
<div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid].join(' ')}> <div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
@@ -318,6 +322,8 @@ function SearchPanel({
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p> <p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
) : moduleSearchRunning ? ( ) : moduleSearchRunning ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p> <p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
) : moduleSearchError ? (
<p role="alert" className="px-3 py-2 text-[12px] text-red-500">{moduleSearchError}</p>
) : moduleSearchResults.length === 0 ? ( ) : moduleSearchResults.length === 0 ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 결과가 없습니다.</p> <p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 결과가 없습니다.</p>
) : ( ) : (
@@ -3,6 +3,7 @@ import type React from 'react'
import Tooltip from '../../components/Tooltip' import Tooltip from '../../components/Tooltip'
type WorkspaceTopBarProps = { type WorkspaceTopBarProps = {
cameraAvailable: boolean
cameraOpen: boolean cameraOpen: boolean
onToggleCamera: () => void onToggleCamera: () => void
dark: boolean dark: boolean
@@ -36,6 +37,7 @@ type WorkspaceTopBarProps = {
} }
export default function WorkspaceTopBar({ export default function WorkspaceTopBar({
cameraAvailable,
cameraOpen, cameraOpen,
onToggleCamera, onToggleCamera,
dark, dark,
@@ -97,9 +99,11 @@ export default function WorkspaceTopBar({
<PanelLeft size={14} /> <PanelLeft size={14} />
시각화 시각화
</button> </button>
{cameraAvailable && (
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}> <button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />오버뷰 <Camera size={14} />오버뷰
</button> </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(' ')} />
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { ModuleRow } from './types'
export function useRemoteEditorFile(options: {
enabled: boolean
targetKey: string
getTarget: () => RemoteWorkspaceTarget
module: ModuleRow | null
path: string | null
files: Record<string, string>
dirty: Record<string, boolean>
setFiles: Dispatch<SetStateAction<Record<string, string>>>
setSaved: Dispatch<SetStateAction<Record<string, string>>>
}) {
const latest = useRef(options)
latest.current = options
const pending = useRef(new Map<string, Promise<{ content: string }>>())
const [failure, setFailure] = useState<{ key: string; message: string } | null>(null)
const [attempt, setAttempt] = useState(0)
const { enabled, path, module, targetKey, files } = options
const eligible = enabled && !!path && !!module && path.startsWith(`modules/${module.name}/`) && !/\.(png|jpe?g|webp|gif|svg)$/i.test(path)
const key = JSON.stringify([targetKey, module?.name, module?.folderName, path])
const cached = path ? files[path] : undefined
useEffect(() => {
if (!eligible || !path || !module || cached !== undefined) return
let cancelled = false
setFailure(null)
let request = pending.current.get(key)
if (!request) {
request = window.remoteWorkspace.moduleFiles.content(latest.current.getTarget(), module.name, path, module.folderName)
pending.current.set(key, request)
const cleanup = (): void => { pending.current.delete(key) }
void request.then(cleanup, cleanup)
}
void request.then(({ content }) => {
if (cancelled || latest.current.dirty[path] || latest.current.files[path] !== undefined) return
latest.current.setFiles((current) => current[path] === undefined ? { ...current, [path]: content } : current)
latest.current.setSaved((current) => ({ ...current, [path]: content }))
}).catch((error) => {
if (!cancelled) setFailure({ key, message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [eligible, key, cached, attempt])
return {
loading: eligible && cached === undefined && failure?.key !== key,
error: eligible && cached === undefined && failure?.key === key ? failure.message : undefined,
retry: () => { setFailure(null); setAttempt((value) => value + 1) }
}
}
@@ -27,6 +27,7 @@ type UseWorkspaceFileActionsOptions = {
setActiveExampleEditorFile: Dispatch<SetStateAction<EditorFilePath | null>> setActiveExampleEditorFile: Dispatch<SetStateAction<EditorFilePath | null>>
setOpenExampleEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>> setOpenExampleEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>>
loadedModuleFolders: Record<string, string[]> loadedModuleFolders: Record<string, string[]>
loadedModuleFilePaths: Record<string, string[]>
setLoadedModuleFolders: Dispatch<SetStateAction<Record<string, string[]>>> setLoadedModuleFolders: Dispatch<SetStateAction<Record<string, string[]>>>
setLoadedModuleFilePaths: Dispatch<SetStateAction<Record<string, string[]>>> setLoadedModuleFilePaths: Dispatch<SetStateAction<Record<string, string[]>>>
setModuleFileLoadingByName: Dispatch<SetStateAction<Record<string, boolean>>> setModuleFileLoadingByName: Dispatch<SetStateAction<Record<string, boolean>>>
@@ -52,7 +53,7 @@ type UseWorkspaceFileActionsOptions = {
} }
type UseWorkspaceFileActionsResult = { type UseWorkspaceFileActionsResult = {
loadModuleFiles: (module: ModuleRow) => Promise<void> loadModuleFiles: (module: ModuleRow, refreshContents?: boolean) => Promise<void>
startCreateFile: (kind: 'file' | 'folder') => void startCreateFile: (kind: 'file' | 'folder') => void
commitCreateFile: () => Promise<void> commitCreateFile: () => Promise<void>
getTreeDisplayPath: (path: string) => string getTreeDisplayPath: (path: string) => string
@@ -95,6 +96,7 @@ export function useWorkspaceFileActions({
setActiveExampleEditorFile, setActiveExampleEditorFile,
setOpenExampleEditorTabs, setOpenExampleEditorTabs,
loadedModuleFolders, loadedModuleFolders,
loadedModuleFilePaths,
setLoadedModuleFolders, setLoadedModuleFolders,
setLoadedModuleFilePaths, setLoadedModuleFilePaths,
setModuleFileLoadingByName, setModuleFileLoadingByName,
@@ -115,14 +117,16 @@ export function useWorkspaceFileActions({
refreshGitStatuses refreshGitStatuses
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult { }: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
const pendingFileReads = useRef(new Map<string, Promise<void>>()) const pendingFileReads = useRef(new Map<string, Promise<void>>())
const dirtyFilesRef = useRef(dirtyEditorFiles)
dirtyFilesRef.current = dirtyEditorFiles
const loadModuleFiles = async (module: ModuleRow): Promise<void> => { const loadModuleFiles = async (module: ModuleRow, refreshContents = false): Promise<void> => {
const target = remoteWorkspace ? getRemoteWorkspaceTarget() : null const target = remoteWorkspace ? getRemoteWorkspaceTarget() : null
const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName]) const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName, refreshContents])
const pending = pendingFileReads.current.get(key) const pending = pendingFileReads.current.get(key)
if (pending) return pending if (pending) return pending
const request = readModuleFiles(module, target) const request = readModuleFiles(module, target, refreshContents)
pendingFileReads.current.set(key, request) pendingFileReads.current.set(key, request)
try { try {
await request await request
@@ -131,7 +135,7 @@ export function useWorkspaceFileActions({
} }
} }
const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null): Promise<void> => { const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null, refreshContents: boolean): Promise<void> => {
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true })) setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
try { try {
const result = target const result = target
@@ -141,14 +145,20 @@ export function useWorkspaceFileActions({
const nextFolders = result.folders.length ? result.folders : [] const nextFolders = result.folders.length ? result.folders : []
setEditorFiles((files) => { setEditorFiles((files) => {
if (target && result.filePaths && !refreshContents) return files
const merged = { ...files } const merged = { ...files }
if (target && result.filePaths) {
Object.keys(merged).forEach((path) => {
if (path.startsWith(`modules/${module.name}/`) && !dirtyFilesRef.current[path]) delete merged[path]
})
}
Object.entries(nextFiles).forEach(([path, content]) => { Object.entries(nextFiles).forEach(([path, content]) => {
if (!dirtyEditorFiles[path]) merged[path] = content if (!dirtyFilesRef.current[path]) merged[path] = content
}) })
return merged return merged
}) })
setSavedEditorFiles((files) => ({ ...files, ...nextFiles })) setSavedEditorFiles((files) => ({ ...files, ...nextFiles }))
setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: Object.keys(nextFiles) })) setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: result.filePaths ?? Object.keys(nextFiles) }))
setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: nextFolders })) setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: nextFolders }))
setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: result.absoluteRoot })) setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: result.absoluteRoot }))
} catch (error) { } catch (error) {
@@ -165,7 +175,7 @@ export function useWorkspaceFileActions({
if (!selectedModule) return if (!selectedModule) return
const rootPath = `modules/${selectedModule.name}` const rootPath = `modules/${selectedModule.name}`
const selectedPathInModule = selectedTreePath?.startsWith(`${rootPath}/`) || selectedTreePath === rootPath ? selectedTreePath : null const selectedPathInModule = selectedTreePath?.startsWith(`${rootPath}/`) || selectedTreePath === rootPath ? selectedTreePath : null
const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined : false const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined || (loadedModuleFilePaths[selectedModule.name] ?? []).includes(selectedPathInModule) : false
const parent = selectedPathInModule const parent = selectedPathInModule
? selectedIsFile ? selectedIsFile
? selectedPathInModule.split('/').slice(0, -1).join('/') ? selectedPathInModule.split('/').slice(0, -1).join('/')
@@ -244,6 +254,7 @@ export function useWorkspaceFileActions({
const saveEditorFile = async (file: EditorFilePath): Promise<void> => { const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
if (!selectedModule || file.startsWith('examples/example-code/')) return if (!selectedModule || file.startsWith('examples/example-code/')) return
if (editorFiles[file] === undefined) throw new Error('파일 내용을 먼저 불러와야 합니다.')
if (remoteWorkspace) { if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName) await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
} else { } else {
@@ -403,7 +414,8 @@ export function useWorkspaceFileActions({
if (pasteParent === sourcePath || pasteParent.startsWith(`${sourcePath}/`)) return if (pasteParent === sourcePath || pasteParent.startsWith(`${sourcePath}/`)) return
const rootPath = `modules/${selectedModule.name}` const rootPath = `modules/${selectedModule.name}`
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))])) const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders) const knownFiles = Object.fromEntries((loadedModuleFilePaths[selectedModule.name] ?? []).map((path) => [path, '']))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, { ...knownFiles, ...editorFiles }, allFolders)
try { try {
if (remoteWorkspace) { if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName) await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
@@ -447,7 +459,7 @@ export function useWorkspaceFileActions({
const refreshModuleFiles = (): void => { const refreshModuleFiles = (): void => {
if (!selectedModule) return if (!selectedModule) return
setNewFileDraft(null) setNewFileDraft(null)
void loadModuleFiles(selectedModule) void loadModuleFiles(selectedModule, true)
} }
return { return {
@@ -14,6 +14,8 @@ type PendingReveal = {
} }
type UseWorkspaceSearchOptions = { type UseWorkspaceSearchOptions = {
remoteSearch?: (query: string) => Promise<ModuleSearchResult[]>
remoteScopeKey?: string
searchActive: boolean searchActive: boolean
selectedModule: ModuleRow | null selectedModule: ModuleRow | null
editorFiles: Record<string, string> editorFiles: Record<string, string>
@@ -24,6 +26,7 @@ type UseWorkspaceSearchOptions = {
} }
type UseWorkspaceSearchResult = { type UseWorkspaceSearchResult = {
moduleSearchError: string | null
moduleSearchInputRef: React.RefObject<HTMLInputElement | null> moduleSearchInputRef: React.RefObject<HTMLInputElement | null>
moduleSearchQuery: string moduleSearchQuery: string
setModuleSearchQuery: React.Dispatch<React.SetStateAction<string>> setModuleSearchQuery: React.Dispatch<React.SetStateAction<string>>
@@ -35,6 +38,8 @@ type UseWorkspaceSearchResult = {
} }
export function useWorkspaceSearch({ export function useWorkspaceSearch({
remoteSearch,
remoteScopeKey,
searchActive, searchActive,
selectedModule, selectedModule,
editorFiles, editorFiles,
@@ -47,6 +52,9 @@ export function useWorkspaceSearch({
const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('') const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('')
const [moduleSearchResults, setModuleSearchResults] = useState<ModuleSearchResult[]>([]) const [moduleSearchResults, setModuleSearchResults] = useState<ModuleSearchResult[]>([])
const [moduleSearchRunning, setModuleSearchRunning] = useState(false) const [moduleSearchRunning, setModuleSearchRunning] = useState(false)
const [moduleSearchError, setModuleSearchError] = useState<string | null>(null)
const remoteSearchRef = useRef(remoteSearch)
remoteSearchRef.current = remoteSearch
const [pendingReveal, setPendingReveal] = useState<PendingReveal | null>(null) const [pendingReveal, setPendingReveal] = useState<PendingReveal | null>(null)
const moduleSearchInputRef = useRef<HTMLInputElement>(null) const moduleSearchInputRef = useRef<HTMLInputElement>(null)
const searchRunIdRef = useRef(0) const searchRunIdRef = useRef(0)
@@ -64,8 +72,14 @@ export function useWorkspaceSearch({
useEffect(() => { useEffect(() => {
if (!pendingReveal) return if (!pendingReveal) return
const reveal = (): boolean => {
const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query) const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query)
if (revealed) setPendingReveal(null) if (revealed) setPendingReveal(null)
return revealed
}
if (reveal()) return
const timer = window.setInterval(() => { if (reveal()) window.clearInterval(timer) }, 100)
return () => window.clearInterval(timer)
}, [activeEditorFile, onRevealSearchMatch, pendingReveal]) }, [activeEditorFile, onRevealSearchMatch, pendingReveal])
useEffect(() => { useEffect(() => {
@@ -84,6 +98,7 @@ export function useWorkspaceSearch({
useEffect(() => { useEffect(() => {
const query = debouncedModuleSearchQuery.trim() const query = debouncedModuleSearchQuery.trim()
setModuleSearchError(null)
if (!searchActive || !selectedModule || query.length < 2) { if (!searchActive || !selectedModule || query.length < 2) {
setModuleSearchResults([]) setModuleSearchResults([])
setModuleSearchRunning(false) setModuleSearchRunning(false)
@@ -102,7 +117,7 @@ export function useWorkspaceSearch({
if (cancelled || searchRunIdRef.current !== searchRunId) return if (cancelled || searchRunIdRef.current !== searchRunId) return
const chunkEnd = Math.min(moduleFilePaths.length, fileIndex + 12) const chunkEnd = Math.min(moduleFilePaths.length, fileIndex + 12)
for (; fileIndex < chunkEnd; fileIndex += 1) { for (; fileIndex < chunkEnd && nextResults.length < 200; fileIndex += 1) {
const file = moduleFilePaths[fileIndex] const file = moduleFilePaths[fileIndex]
const content = editorFiles[file] ?? '' const content = editorFiles[file] ?? ''
const lines = content.split('\n') const lines = content.split('\n')
@@ -127,14 +142,29 @@ export function useWorkspaceSearch({
} }
} }
const timer = window.setTimeout(searchChunk, 0) const searchRemote = remoteSearchRef.current
const timer = window.setTimeout(() => {
if (!searchRemote) { searchChunk(); return }
void searchRemote(query).then((results) => {
if (cancelled) return
nextResults.push(...results.filter((result) => editorFiles[result.file] === undefined))
searchChunk()
}).catch((error) => {
if (cancelled) return
console.error('[module-files] search failed', error)
setModuleSearchResults([])
setModuleSearchError('검색하지 못했습니다. 다시 시도해주세요.')
setModuleSearchRunning(false)
})
}, 0)
return () => { return () => {
cancelled = true cancelled = true
window.clearTimeout(timer) window.clearTimeout(timer)
} }
}, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule]) }, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule, remoteScopeKey])
return { return {
moduleSearchError,
moduleSearchInputRef, moduleSearchInputRef,
moduleSearchQuery, moduleSearchQuery,
setModuleSearchQuery, setModuleSearchQuery,
+55
View File
@@ -0,0 +1,55 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const ts = require('typescript');
const root = path.join(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'src/main/index.ts'), 'utf8');
const ast = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
const ctx = { exports: {} };
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/main/yahboomRuntime.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText, ctx);
Object.assign(ctx, ctx.exports);
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/main/cameraRuntime.ts'), 'utf8'), {
compilerOptions: { module: ts.ModuleKind.CommonJS },
}).outputText, ctx);
const images = [{ image: 'future-nav2-camera-test:humble-jetson_orin' },
{ image: 'future-nav2:humble-jetson_orin' }, { image: 'slam-ros-bridge:jetson_orin' }];
assert.deepEqual(ctx.exports.cameraRuntimeArtifacts('[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws', images), images.slice(0, 1));
assert.deepEqual(ctx.exports.cameraRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', images), images.slice(1));
assert.equal(ctx.exports.cameraRuntimeArtifacts('anything', undefined), undefined);
const names = ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript'];
for (const node of ast.statements) {
if (ts.isFunctionDeclaration(node) && names.includes(node.name?.text)) {
vm.runInNewContext(ts.transpileModule(node.getText(ast), {}).outputText, ctx);
}
}
const run = ctx.buildNav2DockerRunScript();
const build = ctx.buildNav2DockerBuildScript();
for (const script of [run, build]) {
const syntax = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' });
assert.equal(syntax.status, 0, syntax.stderr);
}
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/);
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
assert.match(run, /--device "\$capture_device:\$capture_device"/);
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
assert.match(run, /ROS_LOCALHOST_ONLY=\$ROS_LOCALHOST_ONLY_VALUE/);
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'camera-runtime-'));
const folder = '[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws';
try {
fs.mkdirSync(path.join(dir, folder, 'install'), { recursive: true });
fs.writeFileSync(path.join(dir, folder, 'install/setup.bash'), '');
for (const device of ['/dev/null', '/dev/video999999', '/tmp/video0']) {
const result = spawnSync('bash', ['-s', '--', 'camera-test', dir, folder, 'camera-image', '77', 'false', `video_device:=${device}`],
{ input: run, encoding: 'utf8' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Camera device not found/);
}
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
console.log('Camera module routing, shell syntax, DDS settings and device validation passed.');
+70
View File
@@ -0,0 +1,70 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
let now = 10000;
let requests = 0;
let ok = true;
let visible = false;
let afterBlob = () => {};
const context = vm.createContext({
ENABLE_CAMERA_PREVIEW: true,
cameraFrameRequestInFlight: false,
cameraFrameRetryAt: 0,
cameraFrameObjectUrl: null,
cameraPreview: {},
latestSlamState: null,
latestSlamStateReceivedAt: now,
document: { hidden: false },
Date: { now: () => now },
BACKEND_URL: 'http://localhost:8765',
AbortSignal,
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
setCameraPreviewAvailable(value) { visible = value; },
async fetch() {
requests++;
return { ok, async blob() { afterBlob(); return {}; } };
},
});
vm.runInContext(source, context);
const refresh = () => context.refreshCameraFrame();
const available = (value) => {
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
context.latestSlamStateReceivedAt = now;
};
(async () => {
await refresh();
available(false);
await refresh();
assert.equal(requests, 0, 'Absent camera must not fetch frames');
available(true);
await refresh();
assert.equal(requests, 1);
assert.equal(context.cameraPreview.src, 'blob:frame');
now += 3001;
await refresh();
assert.equal(requests, 1, 'Stale websocket state must stop polling');
assert.equal(visible, false);
available(true);
ok = false;
await refresh();
await refresh();
assert.equal(requests, 2, 'HTTP errors must back off');
now += 5000;
available(true);
ok = true;
afterBlob = () => available(false);
context.cameraPreview.src = '';
await refresh();
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
assert.equal(context.cameraFrameRequestInFlight, false);
afterBlob = () => {};
available(true);
await refresh();
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
console.log('camera preview polling tests passed');
})().catch((error) => { console.error(error); process.exitCode = 1; });
+51
View File
@@ -0,0 +1,51 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
const moduleRow = { name: 'Test', folderName: 'test' };
const clean = 'modules/Test/clean.py';
const dirty = 'modules/Test/dirty.py';
let reads = 0;
const context = {
exports: {}, console,
require: name => name === 'react' ? { useRef: value => ({ current: value }) } : {},
window: { remoteWorkspace: { moduleFiles: { read: async () => {
reads++;
return { files: {}, filePaths: [clean, dirty], folders: [], absoluteRoot: '/module' };
} } } }
};
const source = fs.readFileSync(path.join(__dirname, '../src/renderer/src/features/workspace/useWorkspaceFileActions.ts'), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
let files = { [clean]: 'cached', [dirty]: 'unsaved' };
const options = {
selectedModule: moduleRow, remoteWorkspace: true, getRemoteWorkspaceTarget: () => ({ host: 'test' }),
clonePathPreview: '/workspace', dirtyEditorFiles: { [dirty]: true },
setEditorFiles: fn => { files = fn(files); },
setNewFileDraft: () => {}
};
for (const name of ['setModuleFileLoadingByName', 'setSavedEditorFiles', 'setLoadedModuleFilePaths', 'setLoadedModuleFolders', 'setLoadedModuleRoots']) {
options[name] = fn => fn({});
}
(async () => {
const actions = context.exports.useWorkspaceFileActions(options);
const cached = files;
await actions.loadModuleFiles(moduleRow);
await actions.loadModuleFiles(moduleRow);
assert.equal(reads, 2);
assert.equal(files, cached, 'Automatic listing refresh must retain the content cache');
await actions.loadModuleFiles(moduleRow, true);
assert.equal(files[clean], undefined, 'Explicit refresh invalidates clean content');
assert.equal(files[dirty], 'unsaved', 'Explicit refresh preserves unsaved edits');
files = { [clean]: 'reloaded', [dirty]: 'unsaved' };
actions.refreshModuleFiles();
await new Promise(resolve => setImmediate(resolve));
assert.equal(files[clean], undefined, 'Refresh button must explicitly invalidate content');
assert.equal(files[dirty], 'unsaved');
options.remoteWorkspace = false;
context.window.localWorkspace = { moduleFiles: { read: async () => ({ files: { [clean]: 'local disk' }, folders: [], absoluteRoot: '/module' }) } };
await context.exports.useWorkspaceFileActions(options).loadModuleFiles(moduleRow);
assert.equal(files[clean], 'local disk', 'Local workspace retains its existing eager loading');
console.log('Automatic cache retention, explicit refresh, dirty protection and local loading tests passed.');
})().catch(error => { console.error(error); process.exitCode = 1; });
+67
View File
@@ -0,0 +1,67 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const ts = require('typescript');
// Exercise effect cleanup and asynchronous IPC responses without starting Electron.
const slots = [];
let cursor = 0;
let effects = [];
const react = {
useRef(value) { const i = cursor++; return slots[i] ??= { current: value }; },
useState(value) {
const i = cursor++;
if (!(i in slots)) slots[i] = value;
return [slots[i], next => { slots[i] = typeof next === 'function' ? next(slots[i]) : next; }];
},
useEffect(fn, deps) {
const i = cursor++;
const previous = slots[i];
if (previous && deps.every((dep, index) => Object.is(dep, previous.deps[index]))) return;
effects.push(() => { previous?.cleanup?.(); slots[i] = { deps, cleanup: fn() }; });
}
};
const requests = [];
const context = { exports: {}, require: name => { assert.equal(name, 'react'); return react; }, window: {
remoteWorkspace: { moduleFiles: { content: (...args) => new Promise((resolve, reject) => requests.push({ args, resolve, reject })) } }
} };
const source = fs.readFileSync(path.join(__dirname, '../src/renderer/src/features/workspace/useRemoteEditorFile.ts'), 'utf8');
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
const options = {
enabled: true, targetKey: 'platform', getTarget: () => ({}), module: { name: 'Test', folderName: 'test' },
path: null, files: {}, dirty: {},
setFiles(fn) { options.files = fn(options.files); },
setSaved(fn) { options.saved = fn(options.saved ?? {}); }
};
function render() {
cursor = 0;
const result = context.exports.useRemoteEditorFile(options);
const pending = effects; effects = []; pending.forEach(fn => fn());
return result;
}
const tick = () => new Promise(resolve => setImmediate(resolve));
(async () => {
render(); assert.equal(requests.length, 0);
options.path = 'modules/Test/first.py';
assert.equal(render().loading, true); assert.equal(requests.length, 1);
render(); assert.equal(requests.length, 1);
options.path = 'modules/Test/second.py'; render();
requests[0].resolve({ content: 'old response' }); await tick();
assert.equal(options.files['modules/Test/first.py'], undefined);
requests[1].resolve({ content: 'second' }); await tick();
assert.equal(render().loading, false);
assert.equal(options.files[options.path], 'second');
options.path = null; render(); options.path = 'modules/Test/second.py'; render();
assert.equal(requests.length, 2);
options.path = 'modules/Test/fail.py'; render();
requests[2].reject(new Error('offline')); await tick();
assert.ok(render().error);
render().retry(); render(); assert.equal(requests.length, 4);
options.dirty[options.path] = true; options.files[options.path] = 'unsaved'; render();
requests[3].resolve({ content: 'server copy' }); await tick();
assert.equal(options.files[options.path], 'unsaved');
options.path = 'modules/Test/image.png'; render(); assert.equal(requests.length, 4);
options.enabled = false; options.path = 'modules/Test/local.py'; render(); assert.equal(requests.length, 4);
console.log('Remote editor click loading, caching, cancellation, retry and dirty-file protection tests passed.');
})().catch(error => { console.error(error); process.exitCode = 1; });
+44
View File
@@ -0,0 +1,44 @@
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { spawnSync } = require('node:child_process');
const ts = require('typescript');
const source = fs.readFileSync(path.join(__dirname, '../src/main/remoteModuleFiles.ts'), 'utf8');
const context = { exports: {}, Buffer };
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
const script = context.exports.remoteModuleFilesScript;
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-files-'));
function run(request) {
return spawnSync('bash', ['-s'], { input: script(root, request), encoding: 'utf8' });
}
function read(request) {
const result = run(request);
assert.equal(result.status, 0, result.stderr);
return JSON.parse(result.stdout);
}
try {
fs.mkdirSync(path.join(root, 'src'));
fs.mkdirSync(path.join(root, 'build'));
fs.writeFileSync(path.join(root, 'src', 'hello.py'), 'first\nHello world\n');
fs.writeFileSync(path.join(root, 'build', 'hidden.py'), 'Hello');
fs.writeFileSync(path.join(root, 'large.txt'), 'x'.repeat(1048577));
fs.writeFileSync(path.join(root, 'binary.txt'), Buffer.from([0, 1]));
fs.symlinkSync('/etc/passwd', path.join(root, 'outside.txt'));
const listing = read({ action: 'list' });
assert.deepEqual(listing, { filePaths: ['binary.txt', 'large.txt', 'src/hello.py'], folders: ['src'] });
assert.equal(read({ action: 'content', path: 'src/hello.py' }).content, 'first\nHello world\n');
for (const file of ['large.txt', 'binary.txt']) {
assert.equal(read({ action: 'content', path: file }).content, 'Binary or large file preview is not available.');
}
for (const file of ['../outside.txt', '/etc/passwd', 'outside.txt', 'missing.py']) {
assert.notEqual(run({ action: 'content', path: file }).status, 0);
}
assert.deepEqual(read({ action: 'search', query: 'HELLO' }), [{ file: 'src/hello.py', lineNumber: 2, lineText: 'Hello world' }]);
fs.writeFileSync(path.join(root, 'src', 'many.txt'), 'hello\n'.repeat(300));
assert.equal(read({ action: 'search', query: 'hello' }).length, 200);
console.log('Remote module listing, on-demand content, path validation and search tests passed.');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+47 -5
View File
@@ -4,12 +4,13 @@ const vm = require('node:vm');
const path = require('node:path'); const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
const code = html.slice(html.indexOf('let maxLinear ='), html.indexOf('// A static Gazebo model')); const code = html.slice(html.indexOf('let maxLinear ='), html.indexOf('// A static Gazebo model'));
function create(real = true, yahboom = true) { function create(real = true, yahboom = true, mkmini = !yahboom) {
const handlers = {}, messages = [], intervals = new Map(); const handlers = {}, messages = [], intervals = new Map();
let id = 0; let id = 0;
const context = vm.createContext({ const context = vm.createContext({
REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_YAHBOOM_TELEOP: real && yahboom, REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_TELEOP: real && (yahboom || mkmini),
USE_MKMINI_MODEL: !yahboom, DIRECT_BRIDGE: true, REAL_MKMINI_TELEOP: real && mkmini, EDITOR_MODE: false,
USE_MKMINI_MODEL: mkmini, DIRECT_BRIDGE: true,
latestSlamState: {teleop_deadman: true}, latestSlamStateReceivedAt: Date.now(), latestSlamState: {teleop_deadman: true}, latestSlamStateReceivedAt: Date.now(),
controlSocket: {readyState: 1, bufferedAmount: 0, send: data => messages.push(JSON.parse(data)), close() {this.readyState = 3;}}, controlSocket: {readyState: 1, bufferedAmount: 0, send: data => messages.push(JSON.parse(data)), close() {this.readyState = 3;}},
WebSocket: {OPEN: 1}, document: {hidden: false, hasFocus: () => true, addEventListener: (name, fn) => handlers[name] = fn}, WebSocket: {OPEN: 1}, document: {hidden: false, hasFocus: () => true, addEventListener: (name, fn) => handlers[name] = fn},
@@ -45,8 +46,49 @@ assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0); assert.equal(t.intervals.size, 0);
t = create(); t.context.latestSlamState.teleop_deadman = false; t.key('keydown', 'i'); t = create(); t.context.latestSlamState.teleop_deadman = false; t.key('keydown', 'i');
assert.equal(t.messages.length, 0); assert.equal(t.messages.length, 0);
t = create(true, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0); t = create(true, false, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
for (const [forward, left, reverse] of [['i', 'j', ','], ['ArrowUp', 'ArrowLeft', 'ArrowDown']]) {
t = create(true, false);
t.key('keydown', left);
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.messages.at(-1).angular, 0);
t.key('keydown', forward);
assert.equal(t.messages.at(-1).linear, 0.1);
assert.equal(t.messages.at(-1).angular, 0.08);
assert.equal(t.messages.at(-1).deadman, true);
t.key('keyup', left, {target: {tagName: 'INPUT'}});
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
t.key('keydown', reverse);
t.key('keydown', 'l');
assert.equal(t.messages.at(-1).linear, -0.1);
assert.equal(t.messages.at(-1).angular, -0.08);
t.key('keydown', ' ');
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.messages.at(-1).angular, 0);
}
for (const event of ['blur', 'pagehide', 'visibilitychange']) {
t = create(true, false);
t.key('keydown', 'i');
if (event === 'visibilitychange') t.context.document.hidden = true;
t.handlers[event]();
assert.equal(t.messages.at(-1).linear, 0);
assert.equal(t.intervals.size, 0);
}
t = create(true, false);
t.key('keydown', 'q'); t.key('keydown', 'i');
assert.equal(t.messages.at(-1).linear, 0.1);
t.context.latestSlamStateReceivedAt = 0;
for (const tick of t.intervals.values()) tick();
assert.equal(t.messages.at(-1).linear, 0);
t = create(true, false); t.context.latestSlamState.teleop_deadman = false;
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
t = create(true, false); t.context.controlSocket.bufferedAmount = 1;
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
assert.equal(t.context.controlSocket.readyState, 3);
t = create(true, false); t.context.EDITOR_MODE = true;
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
t = create(false); t.key('keydown', 'i'); t.key('keyup', 'i'); t = create(false); t.key('keydown', 'i'); t.key('keyup', 'i');
assert.equal(t.messages.at(-1).linear, 0.22); assert.equal(t.messages.at(-1).linear, 0.22);
assert.equal(t.intervals.size, 1); assert.equal(t.intervals.size, 1);
console.log('PASS: deadman release, focus/visibility/stale-state stops, legacy bridge and other real vehicles blocked, simulation unchanged'); console.log('PASS: Yahboom/MK-mini deadman, combined steering, arrows/reverse, release/focus/stale-state stops, speed cap, legacy/read-only blocking, simulation unchanged');