fix: display module dialog overlays across the full viewport

This commit is contained in:
2026-09-08 14:11:59 +09:00
parent bfb3cbaf9b
commit 5aef38a99d
7 changed files with 507 additions and 39 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "lms", "name": "lms",
"version": "1.1.21", "version": "1.1.22",
"description": "Autonomy Studio LMS Electron app", "description": "Autonomy Studio LMS Electron app",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": { "author": {
+205 -19
View File
@@ -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 type { WebContents } from 'electron'
import { spawn } from 'child_process' import { spawn } from 'child_process'
import * as pty from 'node-pty' import * as pty from 'node-pty'
@@ -204,6 +204,17 @@ type CustomModuleScaffoldInput = {
launchFile: string launchFile: string
} }
type CustomModuleImportSource = {
sourcePath: string
packageName: string
launchFiles: string[]
buildType: 'ament_python' | 'ament_cmake'
}
type CustomModuleImportInput = CustomModuleScaffoldInput & {
sourcePath: string
}
type ModulePrebuiltArtifact = { type ModulePrebuiltArtifact = {
type?: string | null type?: string | null
url?: string | null url?: string | null
@@ -931,19 +942,152 @@ function validateCustomModuleScaffold(input: CustomModuleScaffoldInput): CustomM
return { name, category, folderName, packageName, launchFile, dockerImage } return { name, category, folderName, packageName, launchFile, dockerImage }
} }
function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record<string, string> { function customModuleConfigContent(input: CustomModuleScaffoldInput): string {
const module = validateCustomModuleScaffold(input) const module = validateCustomModuleScaffold(input)
const executable = `${module.packageName}_node` return `${JSON.stringify({
const nodePath = `${module.packageName}/node.py`
return {
'.autonomy-studio-module.json': `${JSON.stringify({
sourceType: 'CUSTOM', sourceType: 'CUSTOM',
name: module.name, name: module.name,
category: module.category, category: module.category,
dockerImage: module.dockerImage, dockerImage: module.dockerImage,
packageName: module.packageName, packageName: module.packageName,
launchFile: module.launchFile, launchFile: module.launchFile,
}, null, 2)}\n`, }, null, 2)}\n`
}
async function resolveCustomModuleImportSource(sourcePath: string): Promise<CustomModuleImportSource> {
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(/<name>\s*([^<]+?)\s*<\/name>/)?.[1]?.trim() ?? ''
if (!/^[a-z][a-z0-9_]*$/.test(packageName)) {
throw new Error('package.xml의 패키지명이 ROS2 패키지명 형식과 맞지 않습니다.')
}
const buildType = /<buildtool_depend>\s*ament_python\s*<\/buildtool_depend>/.test(packageXml)
? 'ament_python'
: /<buildtool_depend>\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<CustomModuleImportSource | null> {
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<string, string> {
const module = validateCustomModuleScaffold(input)
const executable = `${module.packageName}_node`
const nodePath = `${module.packageName}/node.py`
return {
'.autonomy-studio-module.json': customModuleConfigContent(module),
'.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n', '.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n',
'package.xml': [ 'package.xml': [
'<?xml version="1.0"?>', '<?xml version="1.0"?>',
@@ -4058,6 +4202,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
discoveryBasePort, discoveryBasePort,
...Array.from({ length: 121 }, (_, participantId) => discoveryBasePort + 10 + (2 * participantId)) ...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])) const peerAddresses = Array.from(new Set(['127.0.0.1', peerAddress]))
return [ return [
'<?xml version="1.0" encoding="UTF-8" ?>', '<?xml version="1.0" encoding="UTF-8" ?>',
@@ -4070,8 +4215,7 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
' <receiveBufferSize>1048576</receiveBufferSize>', ' <receiveBufferSize>1048576</receiveBufferSize>',
' <maxMessageSize>1200</maxMessageSize>', ' <maxMessageSize>1200</maxMessageSize>',
' <interfaceWhiteList>', ' <interfaceWhiteList>',
' <address>127.0.0.1</address>', ...localAddresses.map((address) => ` <address>${address}</address>`),
` <address>${localAddress}</address>`,
' </interfaceWhiteList>', ' </interfaceWhiteList>',
' </transport_descriptor>', ' </transport_descriptor>',
' </transport_descriptors>', ' </transport_descriptors>',
@@ -4083,12 +4227,14 @@ function buildFastDdsPeerConfig(peerAddress: string, localAddress: string, rosDo
' </userTransports>', ' </userTransports>',
' <builtin>', ' <builtin>',
' <metatrafficUnicastLocatorList>', ' <metatrafficUnicastLocatorList>',
...localAddresses.flatMap((address) => [
' <locator>', ' <locator>',
' <udpv4>', ' <udpv4>',
` <address>${localAddress}</address>`, ` <address>${address}</address>`,
' <port>0</port>', ' <port>0</port>',
' </udpv4>', ' </udpv4>',
' </locator>', ' </locator>',
]),
' </metatrafficUnicastLocatorList>', ' </metatrafficUnicastLocatorList>',
' <initialPeersList>', ' <initialPeersList>',
...peerAddresses.flatMap((address) => peers.flatMap((port) => [ ...peerAddresses.flatMap((address) => peers.flatMap((port) => [
@@ -4269,9 +4415,29 @@ function buildNav2DockerRunScript(): string {
'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}', 'FASTDDS_CONFIG_PATH=${FASTDDS_CONFIG_PATH:-}',
'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}', 'ROS_LOCALHOST_ONLY_VALUE=${ROS_LOCALHOST_ONLY:-1}',
'LOCALIZATION_MODE=slam', 'LOCALIZATION_MODE=slam',
'WORLD_LAYOUT_PATH_VALUE=/modules/world-layout.json',
'filtered_args=()', 'filtered_args=()',
'for arg in "$@"; do', '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', 'done',
'set -- "${filtered_args[@]}"', 'set -- "${filtered_args[@]}"',
'', '',
@@ -4332,6 +4498,7 @@ function buildNav2DockerRunScript(): string {
' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"', ' -e "TURTLEBOT3_MODEL=${TURTLEBOT3_MODEL:-burger}"',
' -e "MODULE_ROOT=/modules"', ' -e "MODULE_ROOT=/modules"',
' -e "MODULE_FOLDER=$MODULE_FOLDER"', ' -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"', ' -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 "$MODULE_ROOT:/modules"',
' -v "$(dirname "$0"):/runtime/nav2/docker:ro"', ' -v "$(dirname "$0"):/runtime/nav2/docker:ro"',
@@ -5514,8 +5681,24 @@ function quoteShellArg(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'` 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 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 = [ const initCommands = [
`stty cols ${cols} rows ${rows}`, `stty cols ${cols} rows ${rows}`,
@@ -5526,10 +5709,8 @@ function buildWorkspaceShellCommand(moduleName: string | undefined, terminalCwd:
'grep() { command grep --color=auto "$@"; }', 'grep() { command grep --color=auto "$@"; }',
'egrep() { command egrep --color=auto "$@"; }', 'egrep() { command egrep --color=auto "$@"; }',
'fgrep() { command fgrep --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; }', 'export -f ls grep egrep fgrep',
'cd() { builtin cd "$@" || return; __module_workspace_guard; }', ...navigationCommands,
'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',
'exec /bin/bash --noprofile --norc -i' '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 safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], { const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], {
cwd: terminalCwd, cwd: terminalCwd,
name: 'xterm-256color', name: 'xterm-256color',
cols: safeCols, cols: safeCols,
@@ -5657,7 +5838,7 @@ function registerTerminalHandlers(): void {
const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80))) const safeCols = Math.max(20, Math.min(300, Math.floor(cols ?? 80)))
const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24))) const safeRows = Math.max(5, Math.min(120, Math.floor(rows ?? 24)))
const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows)], { const proc = pty.spawn('/bin/bash', ['--noprofile', '--norc', '-c', buildWorkspaceShellCommand(moduleName, terminalCwd, safeCols, safeRows, false)], {
cwd: terminalCwd, cwd: terminalCwd,
name: 'xterm-256color', name: 'xterm-256color',
cols: safeCols, cols: safeCols,
@@ -5701,7 +5882,7 @@ function registerTerminalHandlers(): void {
'-i', '-i',
target.identityFile, target.identityFile,
`${target.user}@${target.host}`, `${target.user}@${target.host}`,
buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows) buildWorkspaceShellCommand(moduleName, resolved.moduleRoot, safeCols, safeRows, true)
], { ], {
name: 'xterm-256color', name: 'xterm-256color',
cols: safeCols, cols: safeCols,
@@ -5970,6 +6151,11 @@ function registerLocalWorkspaceHandlers(): void {
if (!input) throw new Error('커스텀 모듈 정보가 없습니다.') if (!input) throw new Error('커스텀 모듈 정보가 없습니다.')
return createCustomWorkspaceModule(workspaceRoot, input) 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: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: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)) 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))
+2
View File
@@ -196,6 +196,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'), 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), 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), 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), 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), 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), 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),
+38 -7
View File
@@ -17,7 +17,7 @@ import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen'
import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal' import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal'
import MaterialsModal from './features/curriculum/MaterialsModal' import MaterialsModal from './features/curriculum/MaterialsModal'
import { enrollInCourse, fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl, searchCourseCatalog } from './features/curriculum/curriculumApi' 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 type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types'
import { getContextHelpSteps } from './features/help/contextHelp' import { getContextHelpSteps } from './features/help/contextHelp'
import AiGuideView from './features/AiGuideView' import AiGuideView from './features/AiGuideView'
@@ -3292,7 +3292,7 @@ function LocalPcWorkspaceMockup({
openEditorFile(editorPath) openEditorFile(editorPath)
setActiveIdeActivity('workspace') setActiveIdeActivity('workspace')
} }
const createCustomModule = async (input: CustomModuleInput): Promise<void> => { const registerCustomModule = async (input: CustomModuleInput): Promise<NonNullable<CurriculumWeek['modules']>[number]> => {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, { const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/custom-modules`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
@@ -3333,17 +3333,47 @@ function LocalPcWorkspaceMockup({
launchFile: payload.launchFile ?? input.launchFile, launchFile: payload.launchFile ?? input.launchFile,
runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget, runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget,
} satisfies NonNullable<CurriculumWeek['modules']>[number] } satisfies NonNullable<CurriculumWeek['modules']>[number]
if (remoteWorkspace) { return customModule
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
} else {
await window.localWorkspace.modules.createCustom(clonePathPreview, customModule)
} }
const addCustomModuleToList = (customModule: NonNullable<CurriculumWeek['modules']>[number]): void => {
setCustomModules((current) => [ setCustomModules((current) => [
...current, ...current,
{ ...customModule, sortOrder: payload.sortOrder ?? 10_000 + current.length }, { ...customModule, sortOrder: customModule.sortOrder ?? 10_000 + current.length },
]) ])
}
const createCustomModule = async (input: CustomModuleInput): Promise<void> => {
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') onTitlebarNotice('커스텀 모듈이 저장되었습니다.', 'info')
} }
const importCustomModule = async ({ sourcePath, ...input }: CustomModuleImportInput): Promise<void> => {
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 controlEndTime = controlEndsAt ? new Date(controlEndsAt).getTime() : Number.NaN
const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow) const controlRemainingMs = Number.isNaN(controlEndTime) ? null : Math.max(0, controlEndTime - controlCountdownNow)
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000) const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
@@ -3911,6 +3941,7 @@ function LocalPcWorkspaceMockup({
setWorldMapSelectorOpen(true) setWorldMapSelectorOpen(true)
}} }}
onCreateCustomModule={createCustomModule} onCreateCustomModule={createCustomModule}
onImportCustomModule={importCustomModule}
/> />
</div> </div>
)} )}
+7
View File
@@ -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 }> 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 }> 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 }> 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 }> build: (moduleName: string, workspaceRoot?: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, rosParams?: string) => Promise<{ ok: boolean; message: string }>
@@ -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 { useEffect, useRef, useState } from 'react'
import type React from 'react' import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm' import ModuleDeleteConfirm from './ModuleDeleteConfirm'
@@ -46,6 +46,7 @@ type ModuleListViewProps = {
parkingHeightStorageKey?: string parkingHeightStorageKey?: string
onOpenWorldEditor: () => void onOpenWorldEditor: () => void
onCreateCustomModule: (input: CustomModuleInput) => Promise<void> onCreateCustomModule: (input: CustomModuleInput) => Promise<void>
onImportCustomModule: (input: CustomModuleImportInput) => Promise<void>
} }
export default function ModuleListView({ export default function ModuleListView({
@@ -80,12 +81,14 @@ export default function ModuleListView({
onRestoreParkedModule, onRestoreParkedModule,
parkingHeightStorageKey, parkingHeightStorageKey,
onOpenWorldEditor, onOpenWorldEditor,
onCreateCustomModule onCreateCustomModule,
onImportCustomModule
}: ModuleListViewProps): React.JSX.Element { }: ModuleListViewProps): React.JSX.Element {
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all') const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null) const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null) const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false) const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
const [moduleImportDialogOpen, setModuleImportDialogOpen] = useState(false)
const [moduleGuideOpen, setModuleGuideOpen] = useState(false) const [moduleGuideOpen, setModuleGuideOpen] = useState(false)
const [floatingActionMenuOpen, setFloatingActionMenuOpen] = useState(false) const [floatingActionMenuOpen, setFloatingActionMenuOpen] = useState(false)
const floatingActionMenuRef = useRef<HTMLDivElement>(null) const floatingActionMenuRef = useRef<HTMLDivElement>(null)
@@ -101,6 +104,7 @@ export default function ModuleListView({
const stoppedCount = modules.filter((row) => row.state === 'stopped').length const stoppedCount = modules.filter((row) => row.state === 'stopped').length
const installingAll = installingModuleName === '__all__' const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__' const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
const hasModuleRows = modules.length > 0 || parkedModules.length > 0
useEffect(() => { useEffect(() => {
try { try {
@@ -234,11 +238,15 @@ export default function ModuleListView({
setModuleAddDialogOpen(false) setModuleAddDialogOpen(false)
return return
} }
if (moduleImportDialogOpen) {
event.preventDefault()
setModuleImportDialogOpen(false)
}
} }
window.addEventListener('keydown', handleEscape) window.addEventListener('keydown', handleEscape)
return () => window.removeEventListener('keydown', handleEscape) return () => window.removeEventListener('keydown', handleEscape)
}, [deleteConfirmModule, moduleAddDialogOpen, parameterDialog]) }, [deleteConfirmModule, moduleAddDialogOpen, moduleImportDialogOpen, parameterDialog])
return ( return (
<section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}> <section className={['relative flex h-full min-h-0 flex-col', dark ? 'bg-[#1f1f1f]' : 'bg-[#f7f8fb]'].join(' ')}>
@@ -306,7 +314,7 @@ export default function ModuleListView({
}} }}
> >
<div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}> <div className={[dark ? 'divide-y divide-white/7' : 'divide-y divide-[#eef1f6]', 'flex min-h-full flex-col'].join(' ')}>
{loading ? ( {loading && !hasModuleRows ? (
<ModuleListEmptyState dark={dark} loading /> <ModuleListEmptyState dark={dark} loading />
) : filteredModuleRows.length === 0 ? ( ) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} /> <ModuleListEmptyState dark={dark} />
@@ -381,12 +389,36 @@ export default function ModuleListView({
</button> </button>
<button <button
type="button" type="button"
aria-label="모듈 추가" aria-label="새 모듈"
disabled={!allowCustomModuleCreation} disabled={!allowCustomModuleCreation}
onClick={() => { onClick={() => {
setModuleAddDialogOpen(true) setModuleAddDialogOpen(true)
setFloatingActionMenuOpen(false) setFloatingActionMenuOpen(false)
}} }}
className={[
'absolute bottom-[180px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out',
allowCustomModuleCreation
? 'cursor-pointer bg-[#2563eb] text-white shadow-[0_8px_20px_rgba(20,54,104,0.24)] hover:scale-105 hover:bg-[#1d4ed8]'
: 'cursor-not-allowed bg-[#3b4250] text-white/35 shadow-none',
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-100' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
].join(' ')}
>
<PackagePlus size={18} />
<span className={[
'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]',
allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
].join(' ')}>
새 모듈
</span>
</button>
<button
type="button"
aria-label="모듈 불러오기"
disabled={!allowCustomModuleCreation}
onClick={() => {
setModuleImportDialogOpen(true)
setFloatingActionMenuOpen(false)
}}
className={[ className={[
'absolute bottom-[124px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out', 'absolute bottom-[124px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out',
allowCustomModuleCreation allowCustomModuleCreation
@@ -395,12 +427,12 @@ export default function ModuleListView({
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-75' : 'pointer-events-none translate-y-4 scale-50 opacity-0' floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-75' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
].join(' ')} ].join(' ')}
> >
<PackagePlus size={18} /> <FolderOpen size={18} />
<span className={[ <span className={[
'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]', 'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]',
allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none' allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
].join(' ')}> ].join(' ')}>
모듈 추가 모듈 불러오기
</span> </span>
</button> </button>
</div> </div>
@@ -439,6 +471,16 @@ export default function ModuleListView({
}} }}
/> />
)} )}
{allowCustomModuleCreation && moduleImportDialogOpen && (
<ModuleImportDialog
dark={dark}
onClose={() => setModuleImportDialogOpen(false)}
onImport={async (input) => {
await onImportCustomModule(input)
setModuleImportDialogOpen(false)
}}
/>
)}
{moduleGuideOpen && ( {moduleGuideOpen && (
<ModuleGuideDialog <ModuleGuideDialog
dark={dark} dark={dark}
@@ -616,6 +658,10 @@ export type CustomModuleInput = {
launchFile: string launchFile: string
} }
export type CustomModuleImportInput = CustomModuleInput & {
sourcePath: string
}
function ModuleAddDialog({ function ModuleAddDialog({
dark, dark,
onClose, onClose,
@@ -665,7 +711,7 @@ function ModuleAddDialog({
}, []) }, [])
return ( return (
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 정보 입력"> <div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 정보 입력">
<section className={['w-full max-w-[440px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}> <section className={['w-full max-w-[440px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
<header className={['flex items-center justify-between border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}> <header className={['flex items-center justify-between border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<div> <div>
@@ -749,3 +795,188 @@ function ModuleAddDialog({
</div> </div>
) )
} }
function ModuleImportDialog({
dark,
onClose,
onImport
}: {
dark: boolean
onClose: () => void
onImport: (input: CustomModuleImportInput) => Promise<void>
}): 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<string[]>([])
const [dockerImagesLoading, setDockerImagesLoading] = useState(true)
const [dockerImagesMessage, setDockerImagesMessage] = useState<string | null>(null)
const [launchFile, setLaunchFile] = useState('')
const [selectingSource, setSelectingSource] = useState(false)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(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 (
<div className="fixed inset-0 z-[200] grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 불러오기">
<section className={['w-full max-w-[480px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
<header className={['flex items-center justify-between border-b px-4 py-3', dark ? 'border-white/10' : 'border-[#d8dde8]'].join(' ')}>
<div>
<h2 className="text-[14px] font-bold">모듈 불러오기</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>ROS2 패키지 소스를 검증한 뒤 현재 워크스페이스로 복사합니다.</p>
</div>
<button type="button" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label="닫기">
<X size={15} aria-hidden="true" />
</button>
</header>
<form className="grid gap-3 p-4" onSubmit={(event) => {
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))
}}>
<div className="grid gap-1.5">
<span className={['text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>모듈 경로</span>
<div className="flex gap-2">
<div className={['w-0 min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-md border px-2.5 py-2 text-[11px] font-medium', dark ? 'border-white/12 bg-[#1f1f1f] text-white/66' : 'border-[#cfd6e4] bg-[#f8fafc] text-[#596477]'].join(' ')} title={source?.sourcePath}>
{source?.sourcePath ?? '선택한 폴더 없음'}
</div>
<button type="button" onClick={selectSource} disabled={selectingSource || submitting} className="inline-flex h-9 shrink-0 cursor-pointer items-center gap-1.5 rounded-md border border-[#2563eb]/50 px-2.5 text-[12px] font-bold text-[#2f8dff] transition hover:bg-[#2563eb]/10 disabled:cursor-not-allowed disabled:opacity-45">
<FolderOpen size={14} aria-hidden="true" />
{selectingSource ? '확인 중' : '경로 선택'}
</button>
</div>
</div>
{source && (
<div className={['grid grid-cols-[1fr_auto] gap-x-3 gap-y-1 rounded-md border px-3 py-2 text-[11px] font-medium', dark ? 'border-[#2f8dff]/25 bg-[#2563eb]/10 text-white/74' : 'border-[#b7d3ff] bg-[#f2f7ff] text-[#40516b]'].join(' ')}>
<span>패키지: <strong className={dark ? 'text-white' : 'text-[#173c70]'}>{source.packageName}</strong></span>
<span>빌드: <strong className={dark ? 'text-white' : 'text-[#173c70]'}>{source.buildType}</strong></span>
<span className="col-span-2">Launch: {source.launchFiles.join(', ')}</span>
</div>
)}
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
모듈 이름
<input value={name} onChange={(event) => setName(event.target.value)} className={inputClassName} placeholder="예: NAV2-MyPlanner" disabled={!source} autoFocus />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
카테고리
<select value={category} onChange={(event) => setCategory(event.target.value)} className={inputClassName} disabled={!source}>
<option value="">선택</option>
<option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option>
<option value="Map">Map</option>
<option value="Perception">Perception</option>
<option value="Localization">Localization</option>
<option value="Planning">Planning</option>
<option value="Control">Control</option>
<option value="System">System</option>
<option value="Simulator">Simulator</option>
<option value="Gui">Gui</option>
</select>
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
실행 이미지
<select
value={dockerImageSelection}
disabled={!source}
onChange={(event) => {
const selection = event.target.value
setDockerImageSelection(selection)
setDockerImage(selection === directDockerImageOption ? '' : selection)
}}
className={inputClassName}
>
<option value="" disabled>선택</option>
<option value={directDockerImageOption}>직접 입력</option>
{dockerImagesLoading && <option value="__loading__" disabled>로컬 Docker 이미지 불러오는 중</option>}
{dockerImages.map((image) => <option key={image} value={image}>{image}</option>)}
</select>
</label>
{dockerImageSelection === directDockerImageOption && (
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
직접 입력
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" disabled={!source} />
</label>
)}
{dockerImagesMessage && <p className={dark ? 'text-[11px] font-medium text-white/42' : 'text-[11px] font-medium text-[#7b8494]'}>{dockerImagesMessage}</p>}
<div className="grid grid-cols-2 gap-2">
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
패키지명
<input value={source?.packageName ?? ''} readOnly className={[inputClassName, 'cursor-default opacity-70'].join(' ')} />
</label>
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
Launch 파일
<select value={launchFile} onChange={(event) => setLaunchFile(event.target.value)} className={inputClassName} disabled={!source}>
<option value="" disabled>선택</option>
{source?.launchFiles.map((file) => <option key={file} value={file}>{file}</option>)}
</select>
</label>
</div>
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
<footer className="mt-1 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-not-allowed', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>취소</button>
<button type="submit" disabled={submitting || !source || !name.trim() || !category || !launchFile} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '불러오는 중' : '확인'}</button>
</footer>
</form>
</section>
</div>
)
}
@@ -15,6 +15,7 @@ type ModuleActionResult = {
type SavedMapPathOverrides = { type SavedMapPathOverrides = {
savedMapYamlPath?: string | null savedMapYamlPath?: string | null
savedMapDatabasePath?: string | null savedMapDatabasePath?: string | null
savedMapWorldLayoutPath?: string | null
} }
const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system'] const startupCategoryOrder = ['simulator', 'vehicle', 'sensing', 'map', 'localization', 'planning', 'control', 'system']
@@ -142,14 +143,21 @@ export function useWorkspaceModuleControl({
bulkExcludedModuleKeys.includes(module.folderName ?? module.name) bulkExcludedModuleKeys.includes(module.folderName ?? module.name)
), [bulkExcludedModuleKeys]) ), [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 worldFile = overrides?.simulatorWorldFile ?? simulatorWorldFile
const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath const mapYamlPath = overrides?.savedMapYamlPath ?? savedMapYamlPath
const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath const mapDatabasePath = overrides?.savedMapDatabasePath ?? savedMapDatabasePath
const mapWorldLayoutPath = overrides?.savedMapWorldLayoutPath
const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '') const isMkMiniModule = /lms-nav2-mkmini/i.test(module.folderName ?? '')
const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini') const mkMiniProfile = vehicleRuntimeProfiles.find((profile) => profile.profileKey === 'mkmini')
const parameters = module.parameters.filter((parameter) => ( const parameters = module.parameters.filter((parameter) => (
!parameter.startsWith('world:=') !parameter.startsWith('world:=')
&& !parameter.startsWith('world_layout_path:=')
&& !parameter.startsWith('world_layout_enabled:=') && !parameter.startsWith('world_layout_enabled:=')
&& !parameter.startsWith('map:=') && !parameter.startsWith('map:=')
&& !parameter.startsWith('database_path:=') && !parameter.startsWith('database_path:=')
@@ -176,6 +184,9 @@ export function useWorkspaceModuleControl({
} }
if (module.category.toLowerCase() === 'simulator' && worldFile) { if (module.category.toLowerCase() === 'simulator' && worldFile) {
parameters.push(`world:=${worldFile}`) parameters.push(`world:=${worldFile}`)
if (mapWorldLayoutPath) {
parameters.push(`world_layout_path:=${mapWorldLayoutPath.replace(/^\/workspace\//, '/modules/')}`)
}
} }
if (module.category.toLowerCase() === 'map') { if (module.category.toLowerCase() === 'map') {
parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`) parameters.push(`world_layout_enabled:=${worldFile === 'empty_world.world'}`)