diff --git a/src/main/index.ts b/src/main/index.ts index 0b5c2b3..6774fc0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -954,6 +954,35 @@ function customModuleConfigContent(input: CustomModuleScaffoldInput): string { }, null, 2)}\n` } +function buildCustomModuleWorkspaceFiles(input: CustomModuleScaffoldInput): Record { + const module = validateCustomModuleScaffold(input) + const header = [ + '#!/usr/bin/env bash', + 'set -euo pipefail', + '', + 'MODULE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"', + 'RUNTIME_ROOT="${AUTONOMY_STUDIO_RUNTIME_ROOT:-$HOME/autonomy-studio/runtime/nav2}"', + 'FOLDER_NAME="$(basename "$MODULE_PATH")"', + `DEFAULT_IMAGE=${quoteShellSingle(module.dockerImage || 'future-nav2:humble')}`, + 'IMAGE="${NAV2_DOCKER_IMAGE:-$DEFAULT_IMAGE}"', + ] + return { + '.autonomy-studio-module.json': customModuleConfigContent(module), + '.gitignore': 'build/\ninstall/\nlog/\n__pycache__/\n*.py[cod]\n', + 'build.sh': [...header, + 'exec bash "$RUNTIME_ROOT/docker/docker_build_nav2.sh" "$(dirname "$MODULE_PATH")" "$FOLDER_NAME" "$IMAGE" "${ROS_DOMAIN_ID:-0}" "$@"', + '', + ].join('\n'), + 'run.sh': [...header, + 'CONTAINER_NAME="${FOLDER_NAME#\\[}"', + 'CONTAINER_NAME="${CONTAINER_NAME/\\]/-}"', + 'CONTAINER_NAME="${CONTAINER_NAME%_ws}"', + 'exec bash "$RUNTIME_ROOT/docker/docker_run_nav2.sh" "$CONTAINER_NAME" "$(dirname "$MODULE_PATH")" "$FOLDER_NAME" "$IMAGE" "${ROS_DOMAIN_ID:-0}" "${GPU_ENABLED:-false}" "$@"', + '', + ].join('\n'), + } +} + async function resolveCustomModuleImportSource(sourcePath: string): Promise { const selectedPath = sourcePath.trim() if (!selectedPath) throw new Error('불러올 모듈 폴더를 선택하세요.') @@ -1064,16 +1093,23 @@ async function importCustomWorkspaceModule( await mkdir(resolvedWorkspaceRoot, { recursive: true }) try { - await cp(source.sourcePath, moduleRoot, { + const packageRoot = join(moduleRoot, 'src', module.packageName) + await mkdir(dirname(packageRoot), { recursive: true }) + await cp(source.sourcePath, packageRoot, { recursive: true, errorOnExist: true, force: false, filter: (currentPath) => { const pathParts = relative(source.sourcePath, currentPath).split(/[\\/]/).filter(Boolean) return !pathParts.some((part) => ignoredCustomModuleImportDirectories.has(part)) + && relative(source.sourcePath, currentPath) !== '.autonomy-studio-module.json' }, }) - await writeFile(resolve(moduleRoot, '.autonomy-studio-module.json'), customModuleConfigContent(module), { encoding: 'utf8', flag: 'wx' }) + for (const [fileName, content] of Object.entries(buildCustomModuleWorkspaceFiles(module))) { + await writeFile(join(moduleRoot, fileName), content, { + encoding: 'utf8', flag: 'wx', mode: fileName.endsWith('.sh') ? 0o755 : 0o644, + }) + } await runCommand('git', ['init', '--initial-branch=main'], moduleRoot, undefined, 15_000).catch(() => undefined) } catch (error) { await rm(moduleRoot, { recursive: true, force: true }).catch(() => undefined) @@ -1086,9 +1122,7 @@ function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record', '', @@ -1161,6 +1195,12 @@ function buildCustomModuleFiles(input: CustomModuleScaffoldInput): Record [ + `src/${module.packageName}/${path}`, content, + ])), + } } async function createCustomWorkspaceModule( @@ -1172,12 +1212,16 @@ async function createCustomWorkspaceModule( if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') const moduleRoot = resolve(resolvedWorkspaceRoot, module.folderName) const files = buildCustomModuleFiles(module) + const legacyLayout = existsSync(join(moduleRoot, 'package.xml')) await mkdir(moduleRoot, { recursive: true }) for (const [relativePath, content] of Object.entries(files)) { - const targetPath = resolve(moduleRoot, relativePath) + const filePath = legacyLayout && relativePath.startsWith(`src/${module.packageName}/`) + ? relativePath.slice(`src/${module.packageName}/`.length) + : relativePath + const targetPath = resolve(moduleRoot, filePath) if (targetPath !== moduleRoot && !targetPath.startsWith(`${moduleRoot}/`)) throw new Error('허용되지 않은 모듈 파일 경로입니다.') await mkdir(dirname(targetPath), { recursive: true }) - await writeFile(targetPath, content, { encoding: 'utf8', flag: 'wx' }).catch((error: NodeJS.ErrnoException) => { + await writeFile(targetPath, content, { encoding: 'utf8', flag: 'wx', mode: filePath.endsWith('.sh') ? 0o755 : 0o644 }).catch((error: NodeJS.ErrnoException) => { if (error.code !== 'EEXIST') throw error }) } @@ -1192,7 +1236,7 @@ async function createRemoteCustomWorkspaceModule( const target = normalizeRemoteTarget(targetInput) const module = validateCustomModuleScaffold(input) const files = buildCustomModuleFiles(module) - const payload = encodeBase64(JSON.stringify({ folderName: module.folderName, files })) + const payload = encodeBase64(JSON.stringify({ folderName: module.folderName, packageName: module.packageName, files })) const output = await runSshOutput(target, [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, @@ -1206,12 +1250,16 @@ async function createRemoteCustomWorkspaceModule( 'target = os.path.abspath(os.path.join(root, folder))', 'if target == root or not target.startswith(root + os.sep): raise SystemExit("invalid module path")', 'os.makedirs(target, exist_ok=True)', + 'legacy = os.path.isfile(os.path.join(target, "package.xml"))', + 'prefix = "src/" + payload["packageName"] + "/"', 'for relative, content in payload["files"].items():', + ' if legacy and relative.startswith(prefix): relative = relative[len(prefix):]', ' path = os.path.abspath(os.path.join(target, relative))', ' if not path.startswith(target + os.sep): raise SystemExit("invalid module file path")', ' os.makedirs(os.path.dirname(path), exist_ok=True)', ' try:', ' with open(path, "x", encoding="utf-8") as fp: fp.write(content)', + ' if relative in ("build.sh", "run.sh"): os.chmod(path, 0o755)', ' except FileExistsError:', ' pass', 'print(target)', @@ -4572,8 +4620,13 @@ function buildNav2DockerBuildScript(): string { '', 'MODULE_PATH="$MODULE_ROOT/$MODULE_FOLDER"', 'if [[ "$MODULE_FOLDER" == *lms-nav2-custom* ]]; then', - ' test -f "$MODULE_PATH/package.xml" || { echo "Custom module source is missing: $MODULE_FOLDER" >&2; exit 1; }', - ' SOURCE_BASE_PATH=.', + ' if [ -f "$MODULE_PATH/src/$TARGET/package.xml" ]; then', + ' SOURCE_BASE_PATH=src', + ' elif [ -f "$MODULE_PATH/package.xml" ]; then', + ' SOURCE_BASE_PATH=.', + ' else', + ' echo "Custom module source is missing: $MODULE_FOLDER" >&2; exit 1', + ' fi', 'else', ' test -d "$MODULE_PATH/src" || { echo "Module source is missing: $MODULE_FOLDER" >&2; exit 1; }', ' SOURCE_BASE_PATH=src', diff --git a/src/renderer/src/features/workspace/useWorkspaceModuleList.ts b/src/renderer/src/features/workspace/useWorkspaceModuleList.ts index 12622bc..116f986 100644 --- a/src/renderer/src/features/workspace/useWorkspaceModuleList.ts +++ b/src/renderer/src/features/workspace/useWorkspaceModuleList.ts @@ -105,6 +105,9 @@ export function useWorkspaceModuleList({ const moduleInputsRef = useRef({ curriculumModules, applyModuleParameterOverrides }) const loadSequenceRef = useRef(0) const moduleLoadInFlightRef = useRef(false) + const queuedModuleLoadRef = useRef<(() => void) | null>(null) + const workspaceScopeKey = remoteWorkspace ? `remote:${remoteWorkspaceTargetKey}` : `local:${clonePathPreview}` + const previousWorkspaceScopeRef = useRef(workspaceScopeKey) const pendingModuleStatesRef = useRef(new Map()) const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name @@ -173,7 +176,10 @@ export function useWorkspaceModuleList({ const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise => { // Remote probes open an SSH session. Do not let polling overlap a slow // probe, otherwise every response becomes stale and loading never settles. - if (moduleLoadInFlightRef.current) return + if (moduleLoadInFlightRef.current) { + queuedModuleLoadRef.current = () => { void loadAvailableModules({ silent }) } + return + } moduleLoadInFlightRef.current = true const requestSequence = ++loadSequenceRef.current if (!silent) setModulesLoading(true) @@ -212,6 +218,9 @@ export function useWorkspaceModuleList({ } finally { moduleLoadInFlightRef.current = false if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false) + const queuedLoad = queuedModuleLoadRef.current + queuedModuleLoadRef.current = null + queuedLoad?.() } }, [ applyPendingModuleStates, @@ -223,24 +232,38 @@ export function useWorkspaceModuleList({ ]) useEffect(() => { + // A response for an earlier module set must not replace newly added rows. + ++loadSequenceRef.current + const sameWorkspace = previousWorkspaceScopeRef.current === workspaceScopeKey + previousWorkspaceScopeRef.current = workspaceScopeKey + if (!sameWorkspace) pendingModuleStatesRef.current.clear() const cachedModules = readRemoteModuleCache(moduleCacheKey) - if (cachedModules) { - const modules = applyPendingModuleStates( - moduleInputsRef.current.applyModuleParameterOverrides(cachedModules).sort(compareModulePriority), - ) - setAvailableModules(modules) - setModuleListError(null) - setModulesLoading(false) - if (skipInitialModuleLoad) return - } + setAvailableModules((current) => { + const previous = new Map((sameWorkspace && current.length ? current : cachedModules ?? []) + .map((module) => [module.folderName ?? module.name, module])) + const modules = makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules).map((module) => { + const existing = previous.get(module.folderName ?? module.name) + return existing ? { + ...module, + state: existing.state, + workspacePath: existing.workspacePath, + parameters: existing.parameters, + cpu: existing.cpu, + memory: existing.memory, + runtime: existing.runtime, + } : module + }) + return applyPendingModuleStates(moduleInputsRef.current.applyModuleParameterOverrides(modules)) + }) + setModuleListError(null) + setModulesLoading(false) + if (!(cachedModules && skipInitialModuleLoad)) void loadAvailableModules() - if (!cachedModules) { - setAvailableModules(makeUninstalledModuleRows(moduleInputsRef.current.curriculumModules)) - setModuleListError(null) + return () => { + ++loadSequenceRef.current + queuedModuleLoadRef.current = null } - - void loadAvailableModules() - }, [applyPendingModuleStates, loadAvailableModules, moduleCacheKey, remoteWorkspaceTargetKey, skipInitialModuleLoad]) + }, [applyPendingModuleStates, loadAvailableModules, moduleCacheKey, skipInitialModuleLoad, workspaceScopeKey]) useEffect(() => { if (!pollingActive) return