From 5aef38a99d9a4d423fc5cfa619458b6b5ec2ce5c Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 8 Sep 2026 14:11:59 +0900 Subject: [PATCH] fix: display module dialog overlays across the full viewport --- package.json | 2 +- src/main/index.ts | 228 ++++++++++++++-- src/preload/index.ts | 2 + src/renderer/src/App.tsx | 47 +++- src/renderer/src/env.d.ts | 7 + .../src/features/workspace/ModuleListView.tsx | 247 +++++++++++++++++- .../workspace/useWorkspaceModuleControl.ts | 13 +- 7 files changed, 507 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 6a0fa0d..cf9eaf1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lms", - "version": "1.1.21", + "version": "1.1.22", "description": "Autonomy Studio LMS Electron app", "main": "./out/main/index.js", "author": { diff --git a/src/main/index.ts b/src/main/index.ts index 30dc482..0b5c2b3 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,4 +1,4 @@ -import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron' +import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen } from 'electron' import type { WebContents } from 'electron' import { spawn } from 'child_process' import * as pty from 'node-pty' @@ -204,6 +204,17 @@ type CustomModuleScaffoldInput = { launchFile: string } +type CustomModuleImportSource = { + sourcePath: string + packageName: string + launchFiles: string[] + buildType: 'ament_python' | 'ament_cmake' +} + +type CustomModuleImportInput = CustomModuleScaffoldInput & { + sourcePath: string +} + type ModulePrebuiltArtifact = { type?: string | null url?: string | null @@ -931,19 +942,152 @@ function validateCustomModuleScaffold(input: CustomModuleScaffoldInput): CustomM return { name, category, folderName, packageName, launchFile, dockerImage } } +function customModuleConfigContent(input: CustomModuleScaffoldInput): string { + const module = validateCustomModuleScaffold(input) + return `${JSON.stringify({ + sourceType: 'CUSTOM', + name: module.name, + category: module.category, + dockerImage: module.dockerImage, + packageName: module.packageName, + launchFile: module.launchFile, + }, null, 2)}\n` +} + +async function resolveCustomModuleImportSource(sourcePath: string): Promise { + const selectedPath = sourcePath.trim() + if (!selectedPath) throw new Error('불러올 모듈 폴더를 선택하세요.') + + const selectedRoot = resolve(selectedPath) + let packageRoot = selectedRoot + try { + if (!(await stat(selectedRoot)).isDirectory()) throw new Error('not-a-directory') + } catch (error) { + if (error instanceof Error && error.message === 'not-a-directory') { + throw new Error('선택한 경로가 폴더가 아닙니다.') + } + throw new Error('선택한 모듈 폴더를 찾을 수 없습니다.') + } + + if (!existsSync(join(packageRoot, 'package.xml'))) { + const sourceDirectory = join(selectedRoot, 'src') + let packageDirectories: string[] = [] + try { + packageDirectories = (await readdir(sourceDirectory, { withFileTypes: true })) + .filter((entry) => entry.isDirectory() && existsSync(join(sourceDirectory, entry.name, 'package.xml'))) + .map((entry) => join(sourceDirectory, entry.name)) + } catch { + // The error below explains the required ROS workspace structure. + } + if (packageDirectories.length === 0) { + throw new Error('package.xml을 찾을 수 없습니다. ROS2 패키지 폴더 또는 src 아래에 패키지가 하나 있는 워크스페이스를 선택하세요.') + } + if (packageDirectories.length > 1) { + throw new Error('여러 ROS2 패키지가 있습니다. 불러올 패키지 폴더를 직접 선택하세요.') + } + packageRoot = packageDirectories[0] + } + + const packageXml = await readFile(join(packageRoot, 'package.xml'), 'utf8') + const packageName = packageXml.match(/\s*([^<]+?)\s*<\/name>/)?.[1]?.trim() ?? '' + if (!/^[a-z][a-z0-9_]*$/.test(packageName)) { + throw new Error('package.xml의 패키지명이 ROS2 패키지명 형식과 맞지 않습니다.') + } + + const buildType = /\s*ament_python\s*<\/buildtool_depend>/.test(packageXml) + ? 'ament_python' + : /\s*ament_cmake\s*<\/buildtool_depend>/.test(packageXml) + ? 'ament_cmake' + : null + if (!buildType) throw new Error('ament_python 또는 ament_cmake 기반 ROS2 패키지만 불러올 수 있습니다.') + if (buildType === 'ament_python' && !existsSync(join(packageRoot, 'setup.py'))) { + throw new Error('ament_python 패키지에 setup.py가 없습니다.') + } + if (buildType === 'ament_cmake' && !existsSync(join(packageRoot, 'CMakeLists.txt'))) { + throw new Error('ament_cmake 패키지에 CMakeLists.txt가 없습니다.') + } + + let launchFiles: string[] = [] + try { + launchFiles = (await readdir(join(packageRoot, 'launch'), { withFileTypes: true })) + .filter((entry) => entry.isFile() && /^[a-zA-Z0-9_.-]+\.launch\.py$/.test(entry.name)) + .map((entry) => entry.name) + .sort((left, right) => left.localeCompare(right)) + } catch { + // The validation message below covers a missing launch directory too. + } + if (launchFiles.length === 0) { + throw new Error('launch 폴더에 실행 가능한 *.launch.py 파일이 없습니다.') + } + + return { sourcePath: packageRoot, packageName, launchFiles, buildType } +} + +async function selectCustomModuleImportSource(): Promise { + const selection = await dialog.showOpenDialog({ + title: '모듈 불러오기', + buttonLabel: '선택', + properties: ['openDirectory'], + }) + if (selection.canceled || !selection.filePaths[0]) return null + return resolveCustomModuleImportSource(selection.filePaths[0]) +} + +const ignoredCustomModuleImportDirectories = new Set([ + '.git', + '.mypy_cache', + '.pytest_cache', + '__pycache__', + 'build', + 'install', + 'log', + 'node_modules', +]) + +async function importCustomWorkspaceModule( + workspaceRoot: string | null | undefined, + input: CustomModuleImportInput, +): Promise<{ ok: boolean; workspacePath: string }> { + const module = validateCustomModuleScaffold(input) + const source = await resolveCustomModuleImportSource(input.sourcePath) + if (source.packageName !== module.packageName || !source.launchFiles.includes(module.launchFile)) { + throw new Error('선택한 소스의 패키지 또는 Launch 파일 검증 결과가 현재 입력값과 다릅니다. 다시 선택하세요.') + } + + const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) + if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') + const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName) + if (moduleRoot === resolvedWorkspaceRoot || !moduleRoot.startsWith(`${resolvedWorkspaceRoot}/`)) { + throw new Error('허용되지 않은 모듈 경로입니다.') + } + if (existsSync(moduleRoot)) throw new Error('같은 이름의 모듈이 이미 현재 워크스페이스에 있습니다.') + + await mkdir(resolvedWorkspaceRoot, { recursive: true }) + try { + await cp(source.sourcePath, moduleRoot, { + recursive: true, + errorOnExist: true, + force: false, + filter: (currentPath) => { + const pathParts = relative(source.sourcePath, currentPath).split(/[\\/]/).filter(Boolean) + return !pathParts.some((part) => ignoredCustomModuleImportDirectories.has(part)) + }, + }) + await writeFile(resolve(moduleRoot, '.autonomy-studio-module.json'), customModuleConfigContent(module), { encoding: 'utf8', flag: 'wx' }) + await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined) + } catch (error) { + await rm(moduleRoot, { recursive: true, force: true }).catch(() => undefined) + throw error + } + return { ok: true, workspacePath: moduleRoot } +} + function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record { const module = validateCustomModuleScaffold(input) const executable = `${module.packageName}_node` const nodePath = `${module.packageName}/node.py` return { - '.autonomy-studio-module.json': `${JSON.stringify({ - sourceType: 'CUSTOM', - name: module.name, - category: module.category, - dockerImage: module.dockerImage, - packageName: module.packageName, - launchFile: module.launchFile, - }, null, 2)}\n`, + '.autonomy-studio-module.json': customModuleConfigContent(module), '.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n', 'package.xml': [ '', @@ -4058,6 +4202,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo discoveryBasePort, ...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId)) ] + const localAddresses = Array.from(new Set(['127.0.0.1', localAddress])) const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress])) return [ '', @@ -4070,8 +4215,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo ' 1048576', ' 1200', ' ', - '
127.0.0.1
', - `
${localAddress}
`, + ...localAddresses.map((address) => `
${address}
`), '
', ' ', ' ', @@ -4083,12 +4227,14 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo ' ', ' ', ' ', + ...localAddresses.flatMap((address) => [ ' ', ' ', - `
${localAddress}
`, + `
${address}
`, ' 0', '
', '
', + ]), '
', ' ', ...peerAddresses.flatMap((address) => peers.flatMap((port) => [ @@ -4269,9 +4415,29 @@ function buildNav2DockerRunScript(): string { 'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}', 'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}', 'LOCALIZATION_MODE=slam', + 'WORLD_LAYOUT_PATH_VALUE=/modules/world-layout.json', 'filtered_args=()', 'for arg in "$@"; do', - ' if [ "$arg" = "localization_mode:=amcl" ]; then LOCALIZATION_MODE=amcl; else filtered_args+=("$arg"); fi', + ' case "$arg" in', + ' localization_mode:=amcl)', + ' LOCALIZATION_MODE=amcl', + ' ;;', + ' world_layout_path:=*)', + ' candidate_path=${arg#world_layout_path:=}', + ' case "$candidate_path" in', + ' /modules/maps/*.gazebo-layout.json|/modules/maps/*.gazebo-3d-layout.json)', + ' WORLD_LAYOUT_PATH_VALUE=$candidate_path', + ' ;;', + ' *)', + ' echo "Invalid saved map world layout path: $candidate_path" >&2', + ' exit 64', + ' ;;', + ' esac', + ' ;;', + ' *)', + ' filtered_args+=("$arg")', + ' ;;', + ' esac', 'done', 'set -- "${filtered_args[@]}"', '', @@ -4332,6 +4498,7 @@ function buildNav2DockerRunScript(): string { ' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"', ' -e "MODULE_ROOT=/modules"', ' -e "MODULE_FOLDER=$MODULE_FOLDER"', + ' -e "WORLD_LAYOUT_PATH=$WORLD_LAYOUT_PATH_VALUE"', ' -e "GAZEBO_MODEL_PATH=/usr/share/gazebo-11/models:/modules/[lms-nav2-simulator]nav2-simulator_v1.0.0_ws/install/turtlebot3_gazebo/share/turtlebot3_gazebo/models:/modules/[lms-nav2-gazebo-simulator]nav2-simulator_v1.0.0_ws/install/turtlebot3_gazebo/share/turtlebot3_gazebo/models"', ' -v "$MODULE_ROOT:/modules"', ' -v "$(dirname "$0"):/runtime/nav2/docker:ro"', @@ -5514,8 +5681,24 @@ function quoteShellArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } -function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd: string, cols: number, rows: number): string { +function buildWorkspaceShellCommand( + moduleName: string | undefined, + terminalCwd: string, + cols: number, + rows: number, + restrictToModuleWorkspace: boolean, +): string { const promptLabel = (moduleName?.trim() || basename(terminalCwd)).replace(/[^a-zA-Z0-9_.-]+/g, '-') + const navigationCommands = restrictToModuleWorkspace + ? [ + '__module_workspace_guard() { if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" || "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then return 0; fi; builtin cd "$MODULE_WORKSPACE_ROOT"; printf "\\n모듈 워크스페이스 밖으로 이동할 수 없습니다.\\n"; return 1; }', + 'cd() { builtin cd "$@" || return; __module_workspace_guard; }', + 'export PROMPT_COMMAND=\'__module_workspace_guard; if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; else __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'', + 'export -f __module_workspace_guard cd', + ] + : [ + 'export PROMPT_COMMAND=\'if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; elif [[ "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; else __rel=".../${PWD##*/}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'', + ] const initCommands = [ `stty cols ${cols} rows ${rows}`, @@ -5526,10 +5709,8 @@ function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd: 'grep() { command grep --color=auto "$@"; }', 'egrep() { command egrep --color=auto "$@"; }', 'fgrep() { command fgrep --color=auto "$@"; }', - '__module_workspace_guard() { if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" || "$PWD" == "$MODULE_WORKSPACE_ROOT"/* ]]; then return 0; fi; builtin cd "$MODULE_WORKSPACE_ROOT"; printf "\\n모듈 워크스페이스 밖으로 이동할 수 없습니다.\\n"; return 1; }', - 'cd() { builtin cd "$@" || return; __module_workspace_guard; }', - 'export PROMPT_COMMAND=\'__module_workspace_guard; if [[ "$PWD" == "$MODULE_WORKSPACE_ROOT" ]]; then __rel=""; else __rel="${PWD:$(( ${#MODULE_WORKSPACE_ROOT} + 1 ))}"; fi; PS1="\\[\\e[1;34m\\]${MODULE_PROMPT_LABEL}${__rel:+/$__rel}\\[\\e[0m\\]: "\'', - 'export -f ls grep egrep fgrep __module_workspace_guard cd', + 'export -f ls grep egrep fgrep', + ...navigationCommands, 'exec /bin/bash --noprofile --norc -i' ] @@ -5627,7 +5808,7 @@ 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)], { + const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], { cwd: terminalCwd, name: 'xterm-256color', cols: safeCols, @@ -5657,7 +5838,7 @@ 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)], { + const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], { cwd: terminalCwd, name: 'xterm-256color', cols: safeCols, @@ -5701,7 +5882,7 @@ function registerTerminalHandlers(): void { '-i', target.identityFile, `${target.user}@${target.host}`, - buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows) + buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true) ], { name: 'xterm-256color', cols: safeCols, @@ -5970,6 +6151,11 @@ function registerLocalWorkspaceHandlers(): void { if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') return createCustomWorkspaceModule(workspaceRoot, input) }) + ipcMain.handle('local-workspace:modules:select-import-source', () => selectCustomModuleImportSource()) + ipcMain.handle('local-workspace:modules:import-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleImportInput | null) => { + if (!input) throw new Error('불러올 모듈 정보가 없습니다.') + return importCustomWorkspaceModule(workspaceRoot, input) + }) ipcMain.handle('local-workspace:modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork)) ipcMain.handle('local-workspace:modules:build', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string | null) => buildWorkspaceModule(moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams)) diff --git a/src/preload/index.ts b/src/preload/index.ts index f5e6258..7fde938 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -196,6 +196,8 @@ contextBridge.exposeInMainWorld('localWorkspace', { listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'), install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input), + selectImportSource: () => ipcRenderer.invoke('local-workspace:modules:select-import-source'), + importCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string; sourcePath: string }) => ipcRenderer.invoke('local-workspace:modules:import-custom', workspaceRoot, input), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:delete', moduleName, workspaceRoot, folderName), start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork), build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:build', moduleName, workspaceRoot, folderName, gpuEnabled, rosDomainId, runtimeImages, rosParams), diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 2664139..15ab630 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,7 +17,7 @@ import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen' import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal' import MaterialsModal from './features/curriculum/MaterialsModal' import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi' -import type { CustomModuleInput } from './features/workspace/ModuleListView' +import type { CustomModuleImportInput, CustomModuleInput } from './features/workspace/ModuleListView' import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' import { getContextHelpSteps } from './features/help/contextHelp' import AiGuideView from './features/AiGuideView' @@ -3292,7 +3292,7 @@ function LocalPcWorkspaceMockup({ openEditorFile(editorPath) setActiveIdeActivity('workspace') } - const createCustomModule = async (input: CustomModuleInput): Promise => { + const registerCustomModule = async (input: CustomModuleInput): Promise[number]> => { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, { method: 'POST', headers: { 'content-type': 'application/json' }, @@ -3333,17 +3333,47 @@ function LocalPcWorkspaceMockup({ launchFile: payload.launchFile ?? input.launchFile, runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget, } satisfies NonNullable[number] - if (remoteWorkspace) { - await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule) - } else { - await window.localWorkspace.modules.createCustom(clonePathPreview, customModule) - } + return customModule + } + const addCustomModuleToList = (customModule: NonNullable[number]): void => { setCustomModules((current) => [ ...current, - { ...customModule, sortOrder: payload.sortOrder ?? 10_000 + current.length }, + { ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length }, ]) + } + const createCustomModule = async (input: CustomModuleInput): Promise => { + const customModule = await registerCustomModule(input) + const workspaceModule = { + name: customModule.name, + category: customModule.category, + folderName: customModule.folderName, + dockerImage: customModule.dockerImage ?? null, + packageName: customModule.packageName ?? input.packageName, + launchFile: customModule.launchFile ?? input.launchFile, + } + if (remoteWorkspace) { + await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), workspaceModule) + } else { + await window.localWorkspace.modules.createCustom(clonePathPreview, workspaceModule) + } + addCustomModuleToList(customModule) onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info') } + const importCustomModule = async ({ sourcePath, ...input }: CustomModuleImportInput): Promise => { + if (remoteWorkspace) throw new Error('모듈 불러오기는 로컬 워크스페이스에서만 사용할 수 있습니다.') + const customModule = await registerCustomModule(input) + await window.localWorkspace.modules.importCustom(clonePathPreview, { + name: customModule.name, + category: customModule.category, + folderName: customModule.folderName, + dockerImage: customModule.dockerImage ?? null, + packageName: customModule.packageName ?? input.packageName, + launchFile: customModule.launchFile ?? input.launchFile, + sourcePath, + }) + addCustomModuleToList(customModule) + onTitlebarNotice('모듈을 현재 워크스페이스로 불러왔습니다.', 'info') + } const controlEndTime = controlEndsAt ? new Date(controlEndsAt).getTime() : Number.NaN const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow) const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) @@ -3911,6 +3941,7 @@ function LocalPcWorkspaceMockup({ setWorldMapSelectorOpen(true) }} onCreateCustomModule={createCustomModule} + onImportCustomModule={importCustomModule} /> )} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 78af0dc..948c963 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -127,6 +127,13 @@ interface Window { } }> createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => Promise<{ ok: boolean; workspacePath: string }> + selectImportSource: () => Promise<{ + sourcePath: string + packageName: string + launchFiles: string[] + buildType: 'ament_python' | 'ament_cmake' + } | null> + importCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string; sourcePath: string }) => Promise<{ ok: boolean; workspacePath: string }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; message: string }> build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => Promise<{ ok: boolean; message: string }> diff --git a/src/renderer/src/features/workspace/ModuleListView.tsx b/src/renderer/src/features/workspace/ModuleListView.tsx index 7151ddd..7b8320a 100644 --- a/src/renderer/src/features/workspace/ModuleListView.tsx +++ b/src/renderer/src/features/workspace/ModuleListView.tsx @@ -1,4 +1,4 @@ -import { BookOpen, ChevronLeft, ChevronRight, PackagePlus, X } from 'lucide-react' +import { BookOpen, ChevronLeft, ChevronRight, FolderOpen, PackagePlus, X } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import type React from 'react' import ModuleDeleteConfirm from './ModuleDeleteConfirm' @@ -46,6 +46,7 @@ type ModuleListViewProps = { parkingHeightStorageKey?: string onOpenWorldEditor: () => void onCreateCustomModule: (input: CustomModuleInput) => Promise + onImportCustomModule: (input: CustomModuleImportInput) => Promise } export default function ModuleListView({ @@ -80,12 +81,14 @@ export default function ModuleListView({ onRestoreParkedModule, parkingHeightStorageKey, onOpenWorldEditor, - onCreateCustomModule + onCreateCustomModule, + onImportCustomModule }: ModuleListViewProps): React.JSX.Element { const [moduleFilter, setModuleFilter] = useState('all') const [deleteConfirmModule, setDeleteConfirmModule] = useState(null) const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null) const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false) + const [moduleImportDialogOpen, setModuleImportDialogOpen] = useState(false) const [moduleGuideOpen, setModuleGuideOpen] = useState(false) const [floatingActionMenuOpen, setFloatingActionMenuOpen] = useState(false) const floatingActionMenuRef = useRef(null) @@ -101,6 +104,7 @@ export default function ModuleListView({ const stoppedCount = modules.filter((row) => row.state === 'stopped').length const installingAll = installingModuleName === '__all__' const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__' + const hasModuleRows = modules.length > 0 || parkedModules.length > 0 useEffect(() => { try { @@ -234,11 +238,15 @@ export default function ModuleListView({ setModuleAddDialogOpen(false) return } + if (moduleImportDialogOpen) { + event.preventDefault() + setModuleImportDialogOpen(false) + } } window.addEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape) - }, [deleteConfirmModule, moduleAddDialogOpen, parameterDialog]) + }, [deleteConfirmModule, moduleAddDialogOpen, moduleImportDialogOpen, parameterDialog]) return (
@@ -306,7 +314,7 @@ export default function ModuleListView({ }} >
- {loading ? ( + {loading && !hasModuleRows ? ( ) : filteredModuleRows.length === 0 ? ( @@ -381,12 +389,36 @@ export default function ModuleListView({ +
@@ -439,6 +471,16 @@ export default function ModuleListView({ }} /> )} + {allowCustomModuleCreation && moduleImportDialogOpen && ( + setModuleImportDialogOpen(false)} + onImport={async (input) => { + await onImportCustomModule(input) + setModuleImportDialogOpen(false) + }} + /> + )} {moduleGuideOpen && ( +
@@ -749,3 +795,188 @@ function ModuleAddDialog({
) } + +function ModuleImportDialog({ + dark, + onClose, + onImport +}: { + dark: boolean + onClose: () => void + onImport: (input: CustomModuleImportInput) => Promise +}): React.JSX.Element { + const directDockerImageOption = '__direct_input__' + const [source, setSource] = useState<{ + sourcePath: string + packageName: string + launchFiles: string[] + buildType: 'ament_python' | 'ament_cmake' + } | null>(null) + const [name, setName] = useState('') + const [category, setCategory] = useState('') + const [dockerImage, setDockerImage] = useState('') + const [dockerImageSelection, setDockerImageSelection] = useState('') + const [dockerImages, setDockerImages] = useState([]) + const [dockerImagesLoading, setDockerImagesLoading] = useState(true) + const [dockerImagesMessage, setDockerImagesMessage] = useState(null) + const [launchFile, setLaunchFile] = useState('') + const [selectingSource, setSelectingSource] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [error, setError] = useState(null) + + const inputClassName = [ + 'h-9 w-full rounded-md border px-2.5 text-[12px] font-medium outline-none transition', + dark ? 'border-white/12 bg-[#1f1f1f] text-white/82 placeholder:text-white/28 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2f8dff]/55' + ].join(' ') + + useEffect(() => { + let cancelled = false + void window.localWorkspace.modules.listDockerImages() + .then(({ images, message }) => { + if (cancelled) return + setDockerImages(images) + setDockerImagesMessage(message ?? null) + }) + .catch((reason) => { + if (cancelled) return + setDockerImagesMessage(reason instanceof Error ? reason.message : '로컬 Docker 이미지를 불러오지 못했습니다.') + }) + .finally(() => { + if (!cancelled) setDockerImagesLoading(false) + }) + + return () => { + cancelled = true + } + }, []) + + const selectSource = (): void => { + if (selectingSource) return + setSelectingSource(true) + setError(null) + void window.localWorkspace.modules.selectImportSource() + .then((selectedSource) => { + if (!selectedSource) return + setSource(selectedSource) + setName(selectedSource.packageName) + setLaunchFile(selectedSource.launchFiles[0] ?? '') + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : '모듈 구조를 확인하지 못했습니다.')) + .finally(() => setSelectingSource(false)) + } + + return ( +
+
+
+
+

모듈 불러오기

+

ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.

+
+ +
+
{ + event.preventDefault() + if (!source || submitting) return + setSubmitting(true) + setError(null) + void onImport({ + name: name.trim(), + category, + dockerImage: dockerImage.trim(), + packageName: source.packageName, + launchFile, + sourcePath: source.sourcePath, + }) + .catch((reason) => setError(reason instanceof Error ? reason.message : '모듈을 불러오지 못했습니다.')) + .finally(() => setSubmitting(false)) + }}> +
+ 모듈 경로 +
+
+ {source?.sourcePath ?? '선택한 폴더 없음'} +
+ +
+
+ {source && ( +
+ 패키지: {source.packageName} + 빌드: {source.buildType} + Launch: {source.launchFiles.join(', ')} +
+ )} + + + + {dockerImageSelection === directDockerImageOption && ( + + )} + {dockerImagesMessage &&

{dockerImagesMessage}

} +
+ + +
+ {error &&

{error}

} + +
+
+
+ ) +} diff --git a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts index 921b886..2500d03 100644 --- a/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts +++ b/src/renderer/src/features/workspace/useWorkspaceModuleControl.ts @@ -15,6 +15,7 @@ type ModuleActionResult = { type SavedMapPathOverrides = { savedMapYamlPath?: string | null savedMapDatabasePath?: string | null + savedMapWorldLayoutPath?: string | null } const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system'] @@ -142,14 +143,21 @@ export function useWorkspaceModuleControl({ bulkExcludedModuleKeys.includes(module.folderName ?? module.name) ), [bulkExcludedModuleKeys]) - const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { simulatorWorldFile?: string | null; savedMapYamlPath?: string | null; savedMapDatabasePath?: string | null }): string => { + const getModuleRosParams = useCallback((module: ModuleRow, overrides?: { + simulatorWorldFile?: string | null + savedMapYamlPath?: string | null + savedMapDatabasePath?: string | null + savedMapWorldLayoutPath?: string | null + }): string => { const worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath + const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '') const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini') const parameters = module.parameters.filter((parameter) => ( !parameter.startsWith('world:=') + && !parameter.startsWith('world_layout_path:=') && !parameter.startsWith('world_layout_enabled:=') && !parameter.startsWith('map:=') && !parameter.startsWith('database_path:=') @@ -176,6 +184,9 @@ export function useWorkspaceModuleControl({ } if (module.category.toLowerCase() === 'simulator' && worldFile) { parameters.push(`world:=${worldFile}`) + if (mapWorldLayoutPath) { + parameters.push(`world_layout_path:=${mapWorldLayoutPath.replace(/^\/workspace\//, '/modules/')}`) + } } if (module.category.toLowerCase() === 'map') { parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)