From d05ca7582c3ca50606428bc556b0e7c7e2794cca Mon Sep 17 00:00:00 2001 From: chyoo Date: Tue, 30 Jun 2026 17:07:53 +0900 Subject: [PATCH] init.. --- src/main/index.ts | 347 ++- src/preload/index.ts | 57 +- src/renderer/src/App.tsx | 2569 +++-------------- src/renderer/src/config/appConfig.ts | 17 + src/renderer/src/env.d.ts | 38 +- src/renderer/src/features/auth/authSession.ts | 125 + .../features/curriculum/LearningInfoCard.tsx | 2 +- .../src/features/curriculum/curriculumApi.ts | 47 + src/renderer/src/features/curriculum/types.ts | 88 + .../src/features/platforms/platformControl.ts | 91 + .../workspace/WorkspaceActivityHeader.tsx | 2 +- .../workspace/WorkspaceModuleTabs.tsx | 4 +- .../src/features/workspace/WorkspaceNav.tsx | 4 +- .../features/workspace/WorkspaceQaPanel.tsx | 48 + .../WorkspaceTerminalContextMenu.tsx | 26 +- .../src/features/workspace/gitUtils.ts | 134 + .../features/workspace/moduleParameters.ts | 30 + .../workspace/remoteWorkspaceTarget.ts | 24 + .../src/features/workspace/sourceSettings.ts | 69 + .../workspace/studioRuntimeSession.ts | 49 + src/renderer/src/features/workspace/types.ts | 19 + .../workspace/useRemoteWorkspaceRuntime.ts | 137 + .../features/workspace/useSourceControl.ts | 209 ++ .../workspace/useWorkspaceContextMenus.ts | 60 + .../workspace/useWorkspaceFileActions.ts | 429 +++ .../workspace/useWorkspaceModuleControl.ts | 314 ++ .../workspace/useWorkspaceModuleList.ts | 115 + .../workspace/useWorkspaceModuleLogs.ts | 113 + .../features/workspace/useWorkspaceSearch.ts | 147 + .../workspace/useWorkspaceTerminal.ts | 211 ++ .../workspace/useWorkspaceTreeState.ts | 89 + .../workspace/useWorkspaceVisualization.ts | 164 ++ .../src/features/workspace/workspaceFiles.ts | 203 ++ .../features/workspace/workspaceFormatters.ts | 19 + .../src/features/workspace/workspaceLogs.ts | 67 + .../features/workspace/workspaceModules.ts | 27 + .../src/features/workspace/workspaceTree.ts | 27 + 37 files changed, 3839 insertions(+), 2282 deletions(-) create mode 100644 src/renderer/src/config/appConfig.ts create mode 100644 src/renderer/src/features/auth/authSession.ts create mode 100644 src/renderer/src/features/curriculum/curriculumApi.ts create mode 100644 src/renderer/src/features/curriculum/types.ts create mode 100644 src/renderer/src/features/platforms/platformControl.ts create mode 100644 src/renderer/src/features/workspace/gitUtils.ts create mode 100644 src/renderer/src/features/workspace/remoteWorkspaceTarget.ts create mode 100644 src/renderer/src/features/workspace/sourceSettings.ts create mode 100644 src/renderer/src/features/workspace/studioRuntimeSession.ts create mode 100644 src/renderer/src/features/workspace/useRemoteWorkspaceRuntime.ts create mode 100644 src/renderer/src/features/workspace/useSourceControl.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceContextMenus.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceFileActions.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceModuleControl.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceModuleList.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceModuleLogs.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceSearch.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceTerminal.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceTreeState.ts create mode 100644 src/renderer/src/features/workspace/useWorkspaceVisualization.ts create mode 100644 src/renderer/src/features/workspace/workspaceFiles.ts create mode 100644 src/renderer/src/features/workspace/workspaceFormatters.ts create mode 100644 src/renderer/src/features/workspace/workspaceLogs.ts create mode 100644 src/renderer/src/features/workspace/workspaceModules.ts create mode 100644 src/renderer/src/features/workspace/workspaceTree.ts diff --git a/src/main/index.ts b/src/main/index.ts index fc51455..b939ae8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1,11 +1,14 @@ import { app, BrowserWindow, globalShortcut, ipcMain, screen } from 'electron' import type { WebContents } from 'electron' import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' -import { existsSync } from 'fs' +import { createWriteStream, existsSync } from 'fs' import { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises' import { homedir } from 'os' import { basename, dirname, join, relative, resolve } from 'path' import { is } from '@electron-toolkit/utils' +import { createHash } from 'crypto' +import http from 'http' +import https from 'https' let mainWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null @@ -13,6 +16,7 @@ const terminalProcesses = new Map() const terminalStopTimers = new Map() const intentionalTerminalStops = new Set() const moduleCacheRoot = resolve(homedir(), '.caches') +const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' const autowareRosBridgeImage = 'autoware-ros-bridge' @@ -83,6 +87,8 @@ type CachedModuleInfo = { sourcePath: string workspacePath: string | null templateRepositoryUrl?: string | null + sourceType?: string | null + prebuiltArtifacts?: ModulePrebuiltArtifact[] | null parameters: string[] cpu: number | null memory: number | null @@ -95,6 +101,30 @@ type CourseModuleInput = { name: string folderName: string templateRepositoryUrl?: string | null + sourceType?: string | null + prebuiltArtifacts?: ModulePrebuiltArtifact[] | null +} + +type ModulePrebuiltArtifact = { + type?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + extractPath?: string | null + required?: boolean | null +} + +type RuntimeImageArtifact = { + image?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + required?: boolean | null +} + +type ModuleInstallOptions = { + prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + runtimeImages?: RuntimeImageArtifact[] | null } type RemoteWorkspaceTarget = { @@ -144,6 +174,7 @@ type EnvironmentProfileConfig = { logFile?: string }> } + runtimeImages?: RuntimeImageArtifact[] | null bridge?: { name?: string image?: string @@ -337,6 +368,8 @@ async function collectAvailableModules( sourcePath, workspacePath, templateRepositoryUrl: module.templateRepositoryUrl ?? null, + sourceType: module.sourceType ?? null, + prebuiltArtifacts: module.prebuiltArtifacts ?? null, parameters: splitRosParams(rosParams), cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, memory: dockerStatus?.running ? dockerStat?.memory ?? null : null, @@ -519,6 +552,120 @@ async function runCommand(command: string, args: string[], cwd?: string, env?: N await runCommandOutput(command, args, cwd, env, timeoutMs) } +function safeArtifactFileName(url: string, fileName?: string | null): string { + const rawName = fileName?.trim() || basename(new URL(url).pathname) || 'artifact.tar' + return rawName.replace(/[^a-zA-Z0-9._-]/g, '_') +} + +function artifactCachePath(url: string, fileName?: string | null): string { + return resolve(artifactCacheRoot, safeArtifactFileName(url, fileName)) +} + +async function downloadArtifact(url: string, targetPath: string, redirects = 0): Promise { + if (redirects > 5) throw new Error('artifact redirect limit exceeded') + await mkdir(dirname(targetPath), { recursive: true }) + + await new Promise((resolvePromise, rejectPromise) => { + const parsed = new URL(url) + const client = parsed.protocol === 'http:' ? http : https + const request = client.get(parsed, (response) => { + const statusCode = response.statusCode ?? 0 + const location = response.headers.location + if (statusCode >= 300 && statusCode < 400 && location) { + response.resume() + const nextUrl = new URL(location, parsed).toString() + downloadArtifact(nextUrl, targetPath, redirects + 1).then(resolvePromise, rejectPromise) + return + } + if (statusCode < 200 || statusCode >= 300) { + response.resume() + rejectPromise(new Error(`artifact download failed: HTTP ${statusCode}`)) + return + } + + const file = createWriteStream(targetPath) + response.pipe(file) + file.on('finish', () => file.close(() => resolvePromise())) + file.on('error', rejectPromise) + }) + request.on('error', rejectPromise) + }) +} + +async function ensureArtifactFile(url?: string | null, fileName?: string | null, checksumSha256?: string | null): Promise { + const artifactUrl = url?.trim() + if (!artifactUrl) return null + + const targetPath = artifactCachePath(artifactUrl, fileName) + if (!existsSync(targetPath)) await downloadArtifact(artifactUrl, targetPath) + if (checksumSha256?.trim()) { + const actual = createHash('sha256').update(await readFile(targetPath)).digest('hex') + if (actual.toLowerCase() !== checksumSha256.trim().toLowerCase()) { + await rm(targetPath, { force: true }).catch(() => undefined) + throw new Error(`artifact checksum mismatch: ${safeArtifactFileName(artifactUrl, fileName)}`) + } + } + return targetPath +} + +function tarArgsFor(filePath: string, mode: 'list' | 'extract'): string[] { + const lower = filePath.toLowerCase() + if (lower.endsWith('.tar.zst') || lower.endsWith('.tzst')) return mode === 'list' ? ['--zstd', '-tf'] : ['--zstd', '-xf'] + if (lower.endsWith('.tar.gz') || lower.endsWith('.tgz')) return mode === 'list' ? ['-tzf'] : ['-xzf'] + return mode === 'list' ? ['-tf'] : ['-xf'] +} + +function ensureSafeTarEntry(entry: string): void { + const normalized = entry.trim() + if (!normalized || normalized.startsWith('/') || normalized.includes('../') || normalized === '..' || normalized.startsWith('..\\')) { + throw new Error(`unsafe artifact path: ${entry}`) + } +} + +async function extractArtifact(filePath: string, targetRoot: string, extractPath?: string | null): Promise { + const destination = resolve(targetRoot, extractPath?.trim() || '.') + if (destination !== targetRoot && !destination.startsWith(`${targetRoot}/`)) throw new Error('artifact extract path is outside module root') + const entries = await runCommandOutput('tar', [...tarArgsFor(filePath, 'list'), filePath], undefined, undefined, 120000) + entries.split('\n').filter(Boolean).forEach(ensureSafeTarEntry) + await mkdir(destination, { recursive: true }) + await runCommand('tar', [...tarArgsFor(filePath, 'extract'), filePath, '-C', destination], undefined, undefined, 900000) +} + +async function applyModulePrebuiltArtifacts(targetRoot: string, artifacts?: ModulePrebuiltArtifact[] | null): Promise { + const normalizedArtifacts = (artifacts ?? []).filter((artifact) => artifact?.url?.trim()) + for (const artifact of normalizedArtifacts) { + const filePath = await ensureArtifactFile(artifact.url, artifact.fileName, artifact.checksumSha256) + if (!filePath) { + if (artifact.required) throw new Error('required module artifact url is missing') + continue + } + await extractArtifact(filePath, targetRoot, artifact.extractPath) + } +} + +async function prepareRuntimeImages(images?: RuntimeImageArtifact[] | null): Promise { + const normalizedImages = (images ?? []).filter((image) => image?.image?.trim()) + for (const image of normalizedImages) { + const imageName = image.image?.trim() + if (!imageName) continue + const exists = await runCommand('docker', ['image', 'inspect', imageName], undefined, undefined, 10000) + .then(() => true) + .catch(() => false) + if (exists) continue + + const filePath = await ensureArtifactFile(image.url, image.fileName, image.checksumSha256) + if (!filePath) { + if (image.required) throw new Error(`runtime image artifact url is missing: ${imageName}`) + continue + } + if (filePath.toLowerCase().endsWith('.tar.zst') || filePath.toLowerCase().endsWith('.tzst')) { + await runCommand('bash', ['-lc', 'zstd -dc "$1" | docker load', 'autonomy-studio-runtime-image', filePath], undefined, undefined, 900000) + } else { + await runCommand('docker', ['load', '-i', filePath], undefined, undefined, 900000) + } + } +} + function defaultSshIdentityFile(): string { return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519') } @@ -1301,11 +1448,55 @@ function visualizationBridgeContainerName(stack: VisualizationStack): string { } function visualizationBridgeImageName(stack: VisualizationStack): string { - if (stack === 'autoware') return autowareRosBridgeImage + if (stack === 'autoware') return autowareDockerImage if (stack === 'nav2') return nav2RosBridgeImage return slamRosBridgeImage } +function visualizationBridgeResourceContext(stack: VisualizationStack, bridge?: EnvironmentProfileConfig['bridge'] | null): string | null { + if (bridge?.buildContext?.trim()) return bridge.buildContext.trim() + if (stack === 'autoware') return 'resources/autoware-ros-bridge' + if (stack === 'nav2') return 'resources/nav2-ros-bridge' + return 'resources/slam-ros-bridge' +} + +function resolveVisualizationBridgeImage( + stack: VisualizationStack, + bridge?: EnvironmentProfileConfig['bridge'] | null, + runtimeImages?: RuntimeImageArtifact[] | null, +): string { + const configuredImage = bridge?.image?.trim() + if (stack === 'autoware') { + if (!configuredImage || configuredImage === autowareRosBridgeImage) { + return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || autowareDockerImage + } + return configuredImage + } + if (stack === 'slam') { + if (!configuredImage || configuredImage === slamRosBridgeImage) { + return runtimeImages?.find((image) => image?.image?.trim())?.image?.trim() || slamSimulationRuntimeImage + } + return configuredImage + } + return configuredImage || visualizationBridgeImageName(stack) +} + +function bridgeContainerCommand(stack: VisualizationStack): string { + const setupWorkspace = stack === 'autoware' + ? 'if [ -f /autoware_ws/install/setup.bash ]; then source /autoware_ws/install/setup.bash; fi' + : '' + const bridgePath = stack === 'slam' ? '/app/bridge.py' : '/ros_bridge/bridge.py' + const requirementPath = stack === 'slam' ? '/app/requirements.txt' : '/ros_bridge/requirements.txt' + return [ + 'source /opt/ros/humble/setup.bash', + setupWorkspace, + `python3 - <<'PY' || python3 -m pip install --no-cache-dir -r ${requirementPath}`, + 'import fastapi, uvicorn, websockets', + 'PY', + `python3 ${bridgePath}` + ].filter(Boolean).join(' && ') +} + function normalizeVisualizationStack(value?: string | null): VisualizationStack { if (value === 'slam') return 'slam' return value === 'nav2' ? 'nav2' : 'autoware' @@ -1554,7 +1745,7 @@ async function startVisualizationBridge( const target = normalizeRuntimeTarget(resolvedProfile?.target) const bridge = resolvedProfile?.bridge const containerName = bridge?.name?.trim() || visualizationBridgeContainerName(stack) - const bridgeImage = bridge?.image?.trim() || visualizationBridgeImageName(stack) + const bridgeImage = resolveVisualizationBridgeImage(stack, bridge, resolvedProfile?.runtimeImages) const bridgePort = bridge?.port ?? rosBridgePort const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[]) .filter((item) => item !== stack) @@ -1585,17 +1776,21 @@ async function startVisualizationBridge( await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) } + await prepareRuntimeImages(resolvedProfile?.runtimeImages) if (resolvedProfile?.runtime?.containers?.length) { - await ensureEnvironmentRuntime(resolvedProfile, rosDomainId) + const runtimeProfile = { + ...resolvedProfile, + runtime: { + ...resolvedProfile.runtime, + containers: resolvedProfile.runtime.containers.filter((container) => container.name?.trim() !== containerName) + } + } + await ensureEnvironmentRuntime(runtimeProfile, rosDomainId) } - if (bridge?.buildContext) { - await ensureBridgeImage(bridgeImage, bridge.buildContext) - } else if (stack === 'autoware') { - await ensureBridgeImage(bridgeImage, 'resources/autoware-ros-bridge') - } else if (stack === 'nav2') { - await ensureBridgeImage(bridgeImage, 'resources/nav2-ros-bridge') - } else if (stack === 'slam') { - await ensureBridgeImage(bridgeImage, 'resources/slam-ros-bridge') + const bridgeContextPath = resolveResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined) + if (!bridgeContextPath) throw new Error(`브릿지 리소스를 찾을 수 없습니다: ${visualizationBridgeResourceContext(stack, bridge) ?? stack}`) + if (stack !== 'autoware' && stack !== 'slam') { + await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge)) } const args = [ @@ -1606,6 +1801,8 @@ async function startVisualizationBridge( '--net=host', '-v', '/dev/shm:/dev/shm', + '-v', + `${bridgeContextPath}:${stack === 'slam' ? '/app' : '/ros_bridge'}:ro`, '-e', `ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`, '-e', @@ -1644,6 +1841,7 @@ async function startVisualizationBridge( } args.push(bridgeImage) + args.push('bash', '-lc', bridgeContainerCommand(stack)) await runCommand('docker', args, undefined, undefined, 15000) await waitForVisualizationBridgeReady(stack, bridgePort) return readVisualizationBridgeStatus() @@ -1724,6 +1922,7 @@ async function installModuleFromCache( folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, + options?: ModuleInstallOptions | null, ): Promise<{ ok: boolean; module: CachedModuleInfo }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') @@ -1760,6 +1959,7 @@ async function installModuleFromCache( await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true }) } } + await applyModulePrebuiltArtifacts(targetRoot, options?.prebuiltArtifacts) const { name, category } = parseModuleFolderName(targetFolderName) return { @@ -1773,6 +1973,8 @@ async function installModuleFromCache( sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName), workspacePath: targetRoot, templateRepositoryUrl: templateRepositoryUrl ?? null, + sourceType: 'GIT', + prebuiltArtifacts: options?.prebuiltArtifacts ?? null, parameters: [], cpu: null, memory: null, @@ -1787,6 +1989,7 @@ async function installRemoteWorkspaceModule( folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, + options?: ModuleInstallOptions | null, ): Promise<{ ok: boolean; module: CachedModuleInfo }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() || moduleName @@ -1803,6 +2006,16 @@ async function installRemoteWorkspaceModule( ? withGitCredential(remoteStudentRepositoryUrl) : '' const targetRoot = remotePathJoin(target.workspaceRoot, targetFolderName) + const prebuiltArtifacts = (options?.prebuiltArtifacts ?? []) + .filter((artifact) => artifact?.url?.trim()) + .map((artifact) => ({ + url: artifact.url?.trim() ?? '', + fileName: safeArtifactFileName(artifact.url?.trim() ?? '', artifact.fileName), + checksumSha256: artifact.checksumSha256?.trim() ?? '', + extractPath: artifact.extractPath?.trim() ?? '.', + required: Boolean(artifact.required), + })) + const prebuiltJson = JSON.stringify(prebuiltArtifacts) const script = [ 'set -e', `ROOT=${quoteShellSingle(target.workspaceRoot)}`, @@ -1811,6 +2024,7 @@ async function installRemoteWorkspaceModule( `TARGET="$ROOT/$FOLDER"`, `TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`, `STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`, + `PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`, 'mkdir -p "$ROOT"', 'case "$ROOT" in', ' /tmp/autonomy-studio/sessions/*) rm -rf "$TARGET" ;;', @@ -1829,6 +2043,39 @@ async function installRemoteWorkspaceModule( ' fi', 'fi', 'rm -rf "$TARGET/build" "$TARGET/install" "$TARGET/log"', + 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', + 'mkdir -p "$ARTIFACT_CACHE"', + 'python3 - "$PREBUILT_JSON" "$ARTIFACT_CACHE" "$TARGET" <<\'PY\'', + 'import json, os, subprocess, sys', + 'artifacts = json.loads(sys.argv[1] or "[]")', + 'cache = sys.argv[2]', + 'target = sys.argv[3]', + 'def tar_args(path, mode):', + ' lower = path.lower()', + ' if lower.endswith(".tar.zst") or lower.endswith(".tzst"): return ["--zstd", "-tf" if mode == "list" else "-xf"]', + ' if lower.endswith(".tar.gz") or lower.endswith(".tgz"): return ["-tzf" if mode == "list" else "-xzf"]', + ' return ["-tf" if mode == "list" else "-xf"]', + 'for item in artifacts:', + ' url = (item.get("url") or "").strip()', + ' if not url: continue', + ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "artifact.tar"', + ' path = os.path.join(cache, name)', + ' if not os.path.exists(path):', + ' subprocess.run(["curl", "-fL", url, "-o", path], check=True)', + ' expected = (item.get("checksumSha256") or "").strip().lower()', + ' if expected:', + ' import hashlib', + ' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', + ' if actual != expected: raise SystemExit(f"artifact checksum mismatch: {name}")', + ' entries = subprocess.check_output(["tar", *tar_args(path, "list"), path], text=True)', + ' for entry in entries.splitlines():', + ' entry = entry.strip()', + ' if entry.startswith("/") or "../" in entry or entry == "..": raise SystemExit(f"unsafe artifact path: {entry}")', + ' extract_path = os.path.abspath(os.path.join(target, item.get("extractPath") or "."))', + ' if extract_path != target and not extract_path.startswith(target + os.sep): raise SystemExit("artifact extract path is outside module root")', + ' os.makedirs(extract_path, exist_ok=True)', + ' subprocess.run(["tar", *tar_args(path, "extract"), path, "-C", extract_path], check=True)', + 'PY', 'printf "OK\\t%s\\n" "$TARGET"' ].join('\n') @@ -1845,6 +2092,8 @@ async function installRemoteWorkspaceModule( sourcePath: normalizedTemplateRepositoryUrl, workspacePath: targetRoot, templateRepositoryUrl: normalizedTemplateRepositoryUrl, + sourceType: 'GIT', + prebuiltArtifacts: options?.prebuiltArtifacts ?? null, parameters: [], cpu: null, memory: null, @@ -2029,6 +2278,7 @@ async function startWorkspaceModule( rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, + runtimeImages?: RuntimeImageArtifact[] | null, ): Promise<{ ok: boolean; message: string }> { const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') @@ -2042,6 +2292,7 @@ async function startWorkspaceModule( const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') + await prepareRuntimeImages(runtimeImages) await ensureStudioRuntimeAssets() const scriptPath = resolveAutowareDockerRunScript() if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) @@ -2084,6 +2335,7 @@ async function startRemoteWorkspaceModule( rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, + runtimeImages?: RuntimeImageArtifact[] | null, ): Promise<{ ok: boolean; message: string }> { const target = normalizeRemoteTarget(targetInput) const targetFolderName = folderName?.trim() @@ -2091,6 +2343,16 @@ async function startRemoteWorkspaceModule( const dockerInfo = parseModuleDockerInfo(targetFolderName) if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') + const runtimeImageArtifacts = (runtimeImages ?? []) + .filter((image) => image?.image?.trim()) + .map((image) => ({ + image: image.image?.trim() ?? '', + url: image.url?.trim() ?? '', + fileName: image.url?.trim() ? safeArtifactFileName(image.url.trim(), image.fileName) : '', + checksumSha256: image.checksumSha256?.trim() ?? '', + required: Boolean(image.required), + })) + const runtimeImageJson = JSON.stringify(runtimeImageArtifacts) const existsOutput = await runSshOutput(target, [ `ROOT=${quoteShellSingle(target.workspaceRoot)}`, @@ -2102,6 +2364,43 @@ async function startRemoteWorkspaceModule( if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.') const runtimeRoot = await ensureRemoteStudioRuntimeAssets(target) + await runSsh( + target, + [ + 'set -e', + `RUNTIME_IMAGE_JSON="$(printf %s ${quoteShellSingle(encodeBase64(runtimeImageJson))} | base64 -d)"`, + 'ARTIFACT_CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/autonomy-studio/artifacts"', + 'mkdir -p "$ARTIFACT_CACHE"', + 'python3 - "$RUNTIME_IMAGE_JSON" "$ARTIFACT_CACHE" <<\'PY\'', + 'import json, os, subprocess, sys', + 'images = json.loads(sys.argv[1] or "[]")', + 'cache = sys.argv[2]', + 'for item in images:', + ' image = (item.get("image") or "").strip()', + ' if not image: continue', + ' exists = subprocess.run(["docker", "image", "inspect", image], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL).returncode == 0', + ' if exists: continue', + ' url = (item.get("url") or "").strip()', + ' if not url:', + ' if item.get("required"): raise SystemExit(f"runtime image artifact url is missing: {image}")', + ' continue', + ' name = item.get("fileName") or os.path.basename(url.split("?", 1)[0]) or "runtime-image.tar"', + ' path = os.path.join(cache, name)', + ' if not os.path.exists(path): subprocess.run(["curl", "-fL", url, "-o", path], check=True)', + ' expected = (item.get("checksumSha256") or "").strip().lower()', + ' if expected:', + ' import hashlib', + ' with open(path, "rb") as fp: actual = hashlib.sha256(fp.read()).hexdigest().lower()', + ' if actual != expected: raise SystemExit(f"runtime image checksum mismatch: {name}")', + ' if path.lower().endswith(".tar.zst") or path.lower().endswith(".tzst"):', + ' import shlex', + ' subprocess.run(f"zstd -dc {shlex.quote(path)} | docker load", shell=True, check=True)', + ' else:', + ' subprocess.run(["docker", "load", "-i", path], check=True)', + 'PY' + ].join('\n'), + 900000 + ) const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000) if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (currentStatus.trim()) { @@ -2292,9 +2591,10 @@ async function restartWorkspaceModule( rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, + runtimeImages?: RuntimeImageArtifact[] | null, ): Promise<{ ok: boolean; message: string }> { await stopWorkspaceModule(moduleName, workspaceRoot, folderName) - return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId) + return startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages) } async function restartRemoteWorkspaceModule( @@ -2304,9 +2604,10 @@ async function restartRemoteWorkspaceModule( rosParamsOverride?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, + runtimeImages?: RuntimeImageArtifact[] | null, ): Promise<{ ok: boolean; message: string }> { await stopRemoteWorkspaceModule(target, folderName) - return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId) + return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages) } async function readWorkspaceModuleLogs( @@ -2874,22 +3175,22 @@ function createWindow(): void { function registerLocalWorkspaceHandlers(): void { ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) - ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl)) + ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) 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) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) + 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) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('local-workspace:modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('local-workspace:modules:stop-running', () => stopRunningWorkspaceContainers()) - ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) + ipcMain.handle('local-workspace:modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('local-workspace:modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('local-workspace:modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('remote-workspace:modules:list', (_event, target: RemoteWorkspaceTarget, modules?: CourseModuleInput[] | null) => collectRemoteAvailableModules(target, modules)) - ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl)) - ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId)) + ipcMain.handle('remote-workspace:modules:install', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installRemoteWorkspaceModule(target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) + ipcMain.handle('remote-workspace:modules:start', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, folderName)) ipcMain.handle('remote-workspace:modules:stop-many', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => stopRemoteWorkspaceModules(target, modules)) ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => resetRemoteWorkspaceSession(target, modules)) - ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId)) + ipcMain.handle('remote-workspace:modules:restart', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, folderName)) ipcMain.handle('remote-workspace:git:status', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, selectedFilePath?: string | null) => readRemoteWorkspaceGitStatus(target, moduleName, folderName, selectedFilePath)) ipcMain.handle('remote-workspace:git:submit', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, message?: string | null, authorName?: string | null, authorEmail?: string | null, submissionId?: string | null) => submitRemoteWorkspaceGitChanges(target, moduleName, folderName, message, authorName, authorEmail, submissionId)) @@ -3045,13 +3346,13 @@ app.whenReady().then(() => { ipcMain.on('titlebar-close', (event) => BrowserWindow.fromWebContents(event.sender)?.close()) ipcMain.handle('learning-video:open', (event, input: LearningVideoInput) => openLearningVideoWindow(BrowserWindow.fromWebContents(event.sender), input)) ipcMain.handle('modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules)) - ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl)) + ipcMain.handle('modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options)) ipcMain.handle('modules:delete', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => deleteWorkspaceModule(moduleName, workspaceRoot, folderName)) - ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) + ipcMain.handle('modules:start', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => startWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('modules:stop', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => stopWorkspaceModule(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:stop-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot)) ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) - ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId)) + ipcMain.handle('modules:restart', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string | null, gpuEnabled?: boolean | null, rosDomainId?: string | null, runtimeImages?: RuntimeImageArtifact[] | null) => restartWorkspaceModule(moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages)) ipcMain.handle('modules:logs', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null) => readWorkspaceModuleLogs(moduleName, workspaceRoot, folderName)) ipcMain.handle('modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams)) ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus()) diff --git a/src/preload/index.ts b/src/preload/index.ts index 870cde5..746d687 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -15,6 +15,28 @@ type RemoteWorkspaceTarget = { workspaceRoot?: string | null } +type ModulePrebuiltArtifact = { + type?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + extractPath?: string | null + required?: boolean | null +} + +type RuntimeImageArtifact = { + image?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + required?: boolean | null +} + +type ModuleInstallOptions = { + prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + runtimeImages?: RuntimeImageArtifact[] | null +} + const decodeArgValue = (prefix: string): string | null => { const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) if (!rawValue) return null @@ -28,13 +50,21 @@ const decodeArgValue = (prefix: string): string | null => { const readLauncherAuthFile = (): LauncherAuthPayload | null => { const filePath = process.env.ATNMY_HANDOFF_FILE - if (!filePath) return null + if (!filePath) { + console.info('[lms-preload] launcher auth file env is empty') + return null + } try { const payload = JSON.parse(readFileSync(filePath, 'utf8')) as LauncherAuthPayload rmSync(filePath, { force: true }) + console.info('[lms-preload] launcher auth file loaded', { + hasHandoffToken: Boolean(payload.handoffToken), + audienceApp: payload.audienceApp ?? null + }) return payload - } catch { + } catch (error) { + console.warn('[lms-preload] launcher auth file read failed', error) return null } } @@ -47,6 +77,11 @@ const launcherAuth = { audienceApp: launcherAuthFile?.audienceApp ?? decodeArgValue('--audience-app=') ?? process.env.ATNMY_AUDIENCE_APP ?? 'LMS' } +console.info('[lms-preload] launcher auth resolved', { + hasHandoffToken: Boolean(launcherAuth.handoffToken), + audienceApp: launcherAuth.audienceApp +}) + contextBridge.exposeInMainWorld('electron', electronAPI) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('titlebar', { @@ -67,13 +102,13 @@ contextBridge.exposeInMainWorld('moduleFiles', { }) contextBridge.exposeInMainWorld('modules', { list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('modules:list', workspaceRoot, modules), - install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl), + install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options), delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:delete', moduleName, workspaceRoot, folderName), - start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId), + start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:stop', moduleName, workspaceRoot, folderName), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot), stopRunning: () => ipcRenderer.invoke('modules:stop-running'), - restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId), + restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('modules:logs', moduleName, workspaceRoot, folderName), updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) }) @@ -122,13 +157,13 @@ contextBridge.exposeInMainWorld('localWorkspace', { }, modules: { list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules), - install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl), + 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), 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) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId), + start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:start', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:stop', moduleName, workspaceRoot, folderName), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('local-workspace:modules:stop-many', modules, workspaceRoot), stopRunning: () => ipcRenderer.invoke('local-workspace:modules:stop-running'), - restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId), + restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('local-workspace:modules:restart', moduleName, workspaceRoot, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:modules:logs', moduleName, workspaceRoot, folderName), updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => ipcRenderer.invoke('local-workspace:modules:update-parameters', moduleName, workspaceRoot, folderName, rosParams) }, @@ -176,12 +211,12 @@ contextBridge.exposeInMainWorld('remoteWorkspace', { }, modules: { list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:list', target, modules), - install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl), - start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId), + install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('remote-workspace:modules:install', target, moduleName, folderName, templateRepositoryUrl, studentRepositoryUrl, options), + start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:stop', target, moduleName, folderName), stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:stop-many', target, modules), resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => ipcRenderer.invoke('remote-workspace:modules:reset-session', target, modules), - restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId), + restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => ipcRenderer.invoke('remote-workspace:modules:restart', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages), logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName) }, git: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4703a26..01d284a 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,24 +1,23 @@ import { type BeforeMount, type OnMount } from '@monaco-editor/react' import { driver } from 'driver.js' -import { FitAddon } from '@xterm/addon-fit' -import { Terminal as XTerm } from '@xterm/xterm' -import '@xterm/xterm/css/xterm.css' -import { AlertCircle, BookOpen, Bot, CarFront, CheckCircle2, ChevronDown, ChevronLeft, Clock3, Code2, Download, FileCode2, FilePlus, Folder, FolderOpen, FolderPlus, GitBranch, Laptop, Link2, ListCollapse, LogOut, Maximize2, MessageCircle, Minimize2, Minus, Monitor, Network, PanelLeft, Play, Power, RefreshCw, Search, Settings, Square, Terminal, Trash2, Upload, User, X } from 'lucide-react' +import { CarFront, Laptop, Monitor } from 'lucide-react' import type { LucideIcon } from 'lucide-react' -import { Fragment, useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useRef, useState } from 'react' import { io } from 'socket.io-client' import type { Socket } from 'socket.io-client' import Sidebar from './components/Sidebar' -import { ChatAvatar, ExpandableMarkdownMessage, ExpandablePlainMessage, MarkdownMessage, QaInputBox } from './components/ChatWidgets' import PlatformLoadingOverlay from './components/PlatformLoadingOverlay' import { RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals' import TitleBar from './components/TitleBar' -import Tooltip from './components/Tooltip' +import { LMS_API_BASE_URL, LMS_SOCKET_BASE_URL, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig' +import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession' +import type { LmsAuthSession } from './features/auth/authSession' import CourseSelectionScreen from './features/curriculum/CourseSelectionScreen' import CurriculumHomeScreen from './features/curriculum/CurriculumHomeScreen' import InstructorStudioNoticeModal from './features/curriculum/InstructorStudioNoticeModal' -import type { LearningStatItem } from './features/curriculum/LearningStatsCard' import MaterialsModal from './features/curriculum/MaterialsModal' +import { fetchCurriculum, fetchStudentCourses, getDownloadResourceSizeLabel, getDownloadResourceWeek, resolveDownloadUrl } from './features/curriculum/curriculumApi' +import type { CurriculumWeek, DownloadResource, LearningStat, StudentCourseOption } from './features/curriculum/types' import { getContextHelpSteps } from './features/help/contextHelp' import type { PlatformFilter } from './features/platforms/PlatformFilterTabs' import PlatformSelectModal from './features/platforms/PlatformSelectModal' @@ -26,6 +25,8 @@ import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel' import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel' import RecentSessionModal from './features/platforms/RecentSessionModal' import type { RecentSessionItem } from './features/platforms/RecentSessionModal' +import { connectPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl' +import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl' import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types' import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader' import WorkspaceEmptyModuleState from './features/workspace/WorkspaceEmptyModuleState' @@ -45,441 +46,42 @@ import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceCont import WorkspaceTerminalContextMenu from './features/workspace/WorkspaceTerminalContextMenu' import WorkspaceTerminalPanel from './features/workspace/WorkspaceTerminalPanel' import { useQaChat } from './features/workspace/useQaChat' +import { useRemoteWorkspaceRuntime } from './features/workspace/useRemoteWorkspaceRuntime' +import { useSourceControl } from './features/workspace/useSourceControl' +import { useWorkspaceContextMenus } from './features/workspace/useWorkspaceContextMenus' import { useWorkspaceEditorFiles } from './features/workspace/useWorkspaceEditorFiles' -import { serializeModuleParameters } from './features/workspace/moduleParameters' -import type { ModuleParameterPair, ModuleRow } from './features/workspace/types' +import { useWorkspaceFileActions } from './features/workspace/useWorkspaceFileActions' +import { useWorkspaceModuleList } from './features/workspace/useWorkspaceModuleList' +import { useWorkspaceModuleControl } from './features/workspace/useWorkspaceModuleControl' +import { useWorkspaceModuleLogs } from './features/workspace/useWorkspaceModuleLogs' +import { useWorkspaceSearch } from './features/workspace/useWorkspaceSearch' +import { useWorkspaceTerminal } from './features/workspace/useWorkspaceTerminal' +import { useWorkspaceTreeState } from './features/workspace/useWorkspaceTreeState' +import { useWorkspaceVisualization } from './features/workspace/useWorkspaceVisualization' +import { makeModuleParameterKey, readStoredModuleParameters, serializeModuleParameters } from './features/workspace/moduleParameters' +import { parseHostFromUrl, readNestedString } from './features/workspace/remoteWorkspaceTarget' +import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings' +import { makeWorkspaceUiSessionKey, readStudioRuntimeSessionState, storeStudioRuntimeSessionState } from './features/workspace/studioRuntimeSession' +import type { StudioRuntimeSessionState } from './features/workspace/studioRuntimeSession' +import type { ModuleRow } from './features/workspace/types' +import { formatControlRemainingTime, formatSessionClock } from './features/workspace/workspaceFormatters' +import { buildModuleTreeItems, buildPathTreeItems, getEditorLanguage } from './features/workspace/workspaceFiles' +import type { EditorFilePath } from './features/workspace/workspaceFiles' +import { getPathName } from './features/workspace/workspaceTree' +import { parseDiffViewLines } from './features/workspace/gitUtils' +import type { DiffViewLine, GitChangedFile } from './features/workspace/gitUtils' import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs' import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav' import WorkspaceTopBar from './features/workspace/WorkspaceTopBar' import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel' -import type { VisualizationStack } from './features/workspace/WorkspaceVisualizationPanel' import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals' -type CurriculumWeek = { - week: number - title: string - tags: string[] - progress: number - state: 'done' | 'current' | 'locked' | 'ready' - topics: string[] - duration: string - github: string - detail: { - overview: string - topicDetails: string[] - topicVideoUrls?: string[] - practiceContents?: Array<{ - title: string - description: string - }> - references?: DownloadResource[] - exampleCodes?: DownloadResource[] - deliverable: string - requiresSourceSubmission: boolean - sourceSubmitted?: boolean - sourceSubmittedAt?: string | null - } - modules?: Array<{ - id: string - sortOrder: number - category: string - name: string - folderName: string - templateRepositoryUrl?: string | null - dockerImage?: string | null - }> - environmentProfiles?: EnvironmentProfile[] -} - -type CurriculumResponse = { - course?: { - id: string - slug: string - title: string - subtitle?: string | null - description?: string | null - organizationName: string - teacher?: { - id: string - email: string - name: string | null - primaryOrganizationName?: string | null - } - } - weeks?: CurriculumWeek[] - activeWeek?: number | null - activeLearningStep?: string | null - learningStats?: LearningStat[] - platforms?: PlatformOption[] -} - -type StudentCourseOption = NonNullable & { - progress?: number -} - -const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4400/api').replace(/\/+$/, '') -const LMS_SOCKET_BASE_URL = LMS_API_BASE_URL.replace(/\/api$/, '') -const SELECTED_COURSE_STORAGE_KEY = 'lms-selected-course-id' -const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL || 'http://localhost:4200/api').replace(/\/+$/, '') -const LMS_AUTH_SESSION_STORAGE_KEY = 'autonomy-studio.lms.auth-session' -const STUDIO_SOURCE_SETTINGS_STORAGE_KEY = 'autonomy-studio.lms.source-settings' -const STUDIO_RUNTIME_SESSION_STORAGE_KEY = 'autonomy-studio.lms.runtime-session' -const STUDIO_MODULE_PARAMETERS_STORAGE_KEY = 'autonomy-studio.lms.module-parameters' - type IdeActivity = WorkspaceActivity -type StoredModuleParameters = Record - -type StudioRuntimeSessionState = { - localPcLayoutOpen: boolean - studioEntered: boolean - sessionStatus: 'idle' | 'running' | 'ended' - sessionStartedAt: string | null - sessionEndedAt: string | null - connectedPlatformId: string | null - runningPlatformIds: string[] - workspaceWeekNumber: number | null -} - -const defaultStudioSourceSettings: StudioSourceSettings = { - workspaceRoot: '~/autonomy-studio/workspaces', - giteaBaseUrl: 'http://localhost:3000', - giteaOwner: 'student', - gpuEnabled: false, - rosDomainId: '77' -} - -function readStudioRuntimeSessionState(): StudioRuntimeSessionState | null { - try { - const rawValue = window.localStorage.getItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY) - ?? window.sessionStorage.getItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY) - if (!rawValue) return null - const parsed = JSON.parse(rawValue) as Partial - if (!Array.isArray(parsed.runningPlatformIds)) return null - - return { - localPcLayoutOpen: Boolean(parsed.localPcLayoutOpen), - studioEntered: Boolean(parsed.studioEntered), - sessionStatus: parsed.sessionStatus === 'running' || parsed.sessionStatus === 'ended' ? parsed.sessionStatus : 'idle', - sessionStartedAt: typeof parsed.sessionStartedAt === 'string' ? parsed.sessionStartedAt : null, - sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null, - connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null, - runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'), - workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null - } - } catch { - return null - } -} - -function storeStudioRuntimeSessionState(state: StudioRuntimeSessionState): void { - const hasRunningSession = state.runningPlatformIds.length > 0 || state.sessionStatus === 'running' || state.localPcLayoutOpen - if (!hasRunningSession) { - window.localStorage.removeItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY) - window.sessionStorage.removeItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY) - return - } - window.localStorage.setItem(STUDIO_RUNTIME_SESSION_STORAGE_KEY, JSON.stringify(state)) -} - -function makeWorkspaceUiSessionKey(studentSlug: string, weekNumber: number, platformId: string): string { - return `${STUDIO_RUNTIME_SESSION_STORAGE_KEY}:workspace:${studentSlug}:week-${weekNumber}:platform-${platformId}` -} - function makeStudentSlug(email?: string | null): string { return ((email ?? '').split('@')[0] || 'student').replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase() } -function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number): string { - const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '') - const suffix = `${studentSlug}/week-${weekNumber}` - if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) { - return normalizedRoot - } - return `${normalizedRoot}/${suffix}` -} - -function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, weekNumber: number, platformKey: string): string { - const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '') - if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, weekNumber) - const safePlatformKey = platformKey - .replace(/^futuredrive[-_]/i, '') - .replace(/[^a-zA-Z0-9_-]+/g, '-') - .toLowerCase() - const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}` - if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot - return `${normalizedRoot}/${suffix}` -} - -function launcherApiUrl(path: string): string { - const normalizedPath = path.startsWith('/') ? path : `/${path}` - if (normalizedPath.startsWith('/api/')) { - return `${LAUNCHER_API_BASE_URL.replace(/\/api$/, '')}${normalizedPath}` - } - return `${LAUNCHER_API_BASE_URL}${normalizedPath}` -} - -type LmsAuthSession = { - accessToken: string - accessTokenExpiresIn?: number - refreshToken?: string - refreshTokenExpiresIn?: number - tokenType: 'Bearer' - user: { - id: string - email: string - name: string | null - role: string - roles?: string[] - primaryAffiliation?: { - id: string - organizationId: string - organizationName: string - organizationType: string - department: string | null - position: string | null - } | null - currentLicense?: { - id: string - code: string - name: string - startsAt: string - expiresAt: string | null - } | null - } -} - -let handoffExchangePromise: Promise | null = null - -function readStoredAuthSession(): LmsAuthSession | null { - try { - const rawSession = window.sessionStorage.getItem(LMS_AUTH_SESSION_STORAGE_KEY) - if (!rawSession) return null - - const session = JSON.parse(rawSession) as LmsAuthSession - return session?.accessToken ? session : null - } catch { - return null - } -} - -function storeAuthSession(session: LmsAuthSession): void { - try { - window.sessionStorage.setItem(LMS_AUTH_SESSION_STORAGE_KEY, JSON.stringify(session)) - } catch { - // Session persistence is best-effort; the handoff flow can still continue without it. - } -} - -function normalizeStudioSourceSettings(settings?: Partial | null): StudioSourceSettings { - const giteaOwner = settings?.giteaOwner?.trim() || defaultStudioSourceSettings.giteaOwner - return { - workspaceRoot: settings?.workspaceRoot || defaultStudioSourceSettings.workspaceRoot, - giteaBaseUrl: (settings?.giteaBaseUrl || defaultStudioSourceSettings.giteaBaseUrl).replace(/\/+$/, ''), - giteaOwner: giteaOwner === 'students' ? defaultStudioSourceSettings.giteaOwner : giteaOwner, - gpuEnabled: Boolean(settings?.gpuEnabled), - rosDomainId: String(settings?.rosDomainId ?? defaultStudioSourceSettings.rosDomainId).trim() || defaultStudioSourceSettings.rosDomainId, - agentUrl: settings?.agentUrl ?? null, - sshUser: settings?.sshUser ?? null, - sshPort: settings?.sshPort ?? null - } -} - -function studioSourceSettingsStorageKey(platformId: string): string { - return `${STUDIO_SOURCE_SETTINGS_STORAGE_KEY}:platform:${platformId}` -} - -function readStudioSourceSettings(platformId: string, defaults: StudioSourceSettings = defaultStudioSourceSettings): StudioSourceSettings { - try { - const rawSettings = window.localStorage.getItem(studioSourceSettingsStorageKey(platformId)) - ?? window.localStorage.getItem(STUDIO_SOURCE_SETTINGS_STORAGE_KEY) - if (!rawSettings) return defaults - const settings = JSON.parse(rawSettings) as Partial - return normalizeStudioSourceSettings({ ...defaults, ...settings }) - } catch { - return defaults - } -} - -function storeStudioSourceSettings(platformId: string, settings: StudioSourceSettings): void { - try { - window.localStorage.setItem(studioSourceSettingsStorageKey(platformId), JSON.stringify(settings)) - } catch { - // Local source settings can be re-entered if persistence is unavailable. - } -} - -function readStoredModuleParameters(): StoredModuleParameters { - try { - const rawValue = window.localStorage.getItem(STUDIO_MODULE_PARAMETERS_STORAGE_KEY) - if (!rawValue) return {} - const parsed = JSON.parse(rawValue) as StoredModuleParameters - return typeof parsed === 'object' && parsed !== null ? parsed : {} - } catch { - return {} - } -} - -function storeModuleParameters(key: string, parameters: ModuleParameterPair[]): void { - const current = readStoredModuleParameters() - try { - window.localStorage.setItem(STUDIO_MODULE_PARAMETERS_STORAGE_KEY, JSON.stringify({ - ...current, - [key]: parameters - })) - } catch { - // Module parameters fall back to run.sh defaults if local persistence is unavailable. - } -} - -function makeModuleParameterKey(courseKey: string, week: number, folderName?: string): string { - return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}` -} - -function exchangeLauncherHandoff(): Promise { - const storedSession = readStoredAuthSession() - if (storedSession) return Promise.resolve(storedSession) - - const handoffToken = window.launcherAuth?.handoffToken - const audienceApp = window.launcherAuth?.audienceApp ?? 'LMS' - - if (!handoffToken) return Promise.resolve(null) - if (handoffExchangePromise) return handoffExchangePromise - - handoffExchangePromise = fetch(`${LMS_API_BASE_URL}/auth/handoff/exchange`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ handoffToken, audienceApp }) - }) - .then(async (response) => { - if (!response.ok) return null - - const session = await response.json() as LmsAuthSession - if (session?.accessToken) storeAuthSession(session) - return session - }) - .catch(() => null) - - return handoffExchangePromise -} - -async function refreshStoredAuthSession(session: LmsAuthSession): Promise { - if (!session.refreshToken) return null - - const response = await fetch(`${LMS_API_BASE_URL}/auth/refresh`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ refreshToken: session.refreshToken }) - }) - - if (!response.ok) return null - - const nextSession = await response.json() as LmsAuthSession - if (!nextSession?.accessToken) return null - - storeAuthSession(nextSession) - return nextSession -} - -type LearningStat = LearningStatItem - -type DownloadResource = { - id: string - appKey?: string - contextKey?: string - title: string - description?: string | null - fileName: string - contentType?: string | null - fileSizeBytes?: string | null - week?: number | string | null - weekNumber?: number | string | null - metadata?: { - week?: number | string | null - weekNumber?: number | string | null - } | null - downloadUrl: string - createdAt?: string -} - -const resolveDownloadUrl = (resource: DownloadResource): string => { - if (/^https?:\/\//i.test(resource.downloadUrl)) return resource.downloadUrl - if (resource.downloadUrl.startsWith('/curriculum/')) return `${LMS_API_BASE_URL}${resource.downloadUrl}` - return launcherApiUrl(resource.downloadUrl) -} - -const getDownloadResourceWeek = (resource: DownloadResource): number | null => { - const explicitWeek = - resource.week ?? - resource.weekNumber ?? - resource.metadata?.week ?? - resource.metadata?.weekNumber - const parsedExplicitWeek = Number(explicitWeek) - if (Number.isInteger(parsedExplicitWeek) && parsedExplicitWeek > 0) return parsedExplicitWeek - - const searchable = [resource.contextKey, resource.title, resource.fileName].filter(Boolean).join(' ') - const match = searchable.match(/(?:week|주차)[\s_-]*(\d+)|(\d+)[\s_-]*주차/i) - const parsedMatch = Number(match?.[1] ?? match?.[2]) - return Number.isInteger(parsedMatch) && parsedMatch > 0 ? parsedMatch : null -} - -const getDownloadResourceSizeLabel = (resource: DownloadResource): string => { - const bytes = Number(resource.fileSizeBytes) - if (!Number.isFinite(bytes) || bytes <= 0) return resource.fileName - if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` - if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB` - return `${bytes} B` -} - -const formatSessionClock = (value?: string | null): string | null => { - if (!value) return null - const date = new Date(value) - if (Number.isNaN(date.getTime())) return null - return date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }) -} - -const formatControlRemainingTime = (endsAt?: string | null): string => { - if (!endsAt) return '00:00' - const endTime = new Date(endsAt).getTime() - if (Number.isNaN(endTime)) return '00:00' - const remainingSeconds = Math.max(0, Math.ceil((endTime - Date.now()) / 1000)) - const hours = Math.floor(remainingSeconds / 3600) - const minutes = Math.floor((remainingSeconds % 3600) / 60) - const seconds = remainingSeconds % 60 - const paddedMinutes = hours > 0 ? String(minutes).padStart(2, '0') : String(minutes) - const paddedSeconds = String(seconds).padStart(2, '0') - return hours > 0 ? `${hours}:${paddedMinutes}:${paddedSeconds}` : `${paddedMinutes}:${paddedSeconds}` -} - -const extractStringRecord = (value: unknown): Record | null => ( - value && typeof value === 'object' && !Array.isArray(value) - ? value as Record - : null -) - -const readNestedString = (value: unknown, keys: string[]): string | null => { - let current: unknown = value - for (const key of keys) { - const record = extractStringRecord(current) - if (!record) return null - current = record[key] - } - return typeof current === 'string' && current.trim() ? current.trim() : null -} - -const parseHostFromUrl = (value?: string | null): string | null => { - if (!value?.trim()) return null - try { - return new URL(value).hostname - } catch { - return value.trim() - } -} - const PLATFORM_ICONS: Record = { laptop: Laptop, monitor: Monitor, @@ -494,44 +96,6 @@ const isInstructorAccount = (user?: LmsAuthSession['user'] | null): boolean => { return roles.some((role) => role === 'ADMIN' || role === 'INSTRUCTOR' || role === 'TEACHER') } -const getYoutubeEmbedUrl = (url: string | undefined): string | null => { - const value = url?.trim() - if (!value) return null - - try { - const parsed = new URL(value) - const host = parsed.hostname.replace(/^www\./, '') - let videoId = '' - - if (host === 'youtu.be') { - videoId = parsed.pathname.split('/').filter(Boolean)[0] ?? '' - } else if (host === 'youtube.com' || host === 'm.youtube.com' || host === 'music.youtube.com') { - const segments = parsed.pathname.split('/').filter(Boolean) - if (segments[0] === 'embed' || segments[0] === 'shorts' || segments[0] === 'live') { - videoId = segments[1] ?? '' - } else { - videoId = parsed.searchParams.get('v') ?? '' - } - } - - if (!/^[A-Za-z0-9_-]{6,}$/.test(videoId)) return null - return `https://www.youtube.com/embed/${videoId}` - } catch { - return null - } -} - -const getYoutubeVideoId = (url: string | undefined): string | null => { - const embedUrl = getYoutubeEmbedUrl(url) - if (!embedUrl) return null - - try { - return new URL(embedUrl).pathname.split('/').filter(Boolean)[1] ?? null - } catch { - return null - } -} - const EMPTY_WEEK: CurriculumWeek = { week: 0, title: '', @@ -714,23 +278,6 @@ dist ` } -type EditorFilePath = string - -const getEditorLanguage = (file: string): string => { - if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript' - if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp' - if (file.endsWith('.json')) return 'json' - if (file.endsWith('.xml')) return 'xml' - if (file.endsWith('.html')) return 'html' - if (file.endsWith('.css')) return 'css' - if (file.endsWith('.py')) return 'python' - if (file.endsWith('.sh')) return 'shell' - if (file.endsWith('CMakeLists.txt') || file.endsWith('.cmake')) return 'cmake' - if (file.endsWith('.md')) return 'markdown' - if (file.endsWith('.yml') || file.endsWith('.yaml')) return 'yaml' - return 'plaintext' -} - const configureMonacoTheme: BeforeMount = (monaco) => { monaco.languages.typescript.typescriptDefaults.setDiagnosticsOptions({ noSemanticValidation: true, @@ -800,446 +347,12 @@ const imagePreviewSources: Partial> = { 'src/renderer/public/assets/images/theme-reference.png': '/assets/images/theme-reference.png' } -const compareModulePriority = (a: ModuleRow, b: ModuleRow): number => a.sortOrder - b.sortOrder -const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): number => b.sortOrder - a.sortOrder - -function makeUninstalledModuleRows(curriculumModules: CurriculumWeek['modules']): ModuleRow[] { - return (curriculumModules ?? []) - .map((module) => ({ - state: 'uninstalled' as const, - sortOrder: module.sortOrder, - category: module.category, - name: module.name, - folderName: module.folderName, - sourcePath: module.templateRepositoryUrl ?? '', - workspacePath: null, - templateRepositoryUrl: module.templateRepositoryUrl ?? null, - parameters: [], - cpu: null, - memory: null, - runtime: '-' - })) - .sort(compareModulePriority) -} - -const parseDiffViewLines = (diff: string): DiffViewLine[] => { - const viewLines: DiffViewLine[] = [] - let oldLine = 0 - let newLine = 0 - - diff.split('\n').forEach((line, index) => { - if (line.startsWith('diff --git ')) { - const match = line.match(/^diff --git a\/(.+) b\/(.+)$/) - viewLines.push({ - key: `file-${index}`, - type: 'file', - oldLine: null, - newLine: null, - content: match?.[2] ?? line.replace(/^diff --git\s+/, '') - }) - return - } - - if ( - line.startsWith('index ') || - line.startsWith('--- ') || - line.startsWith('+++ ') || - line.startsWith('new file mode ') || - line.startsWith('deleted file mode ') - ) { - return - } - - if (line.startsWith('\\ No newline')) { - viewLines.push({ - key: `note-${index}`, - type: 'note', - oldLine: null, - newLine: null, - content: '파일 끝 줄바꿈 없음' - }) - return - } - - if (line.startsWith('@@')) { - const match = line.match(/@@\s+-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@(.*)$/) - oldLine = Number(match?.[1] ?? 0) - newLine = Number(match?.[2] ?? 0) - viewLines.push({ - key: `hunk-${index}`, - type: 'hunk', - oldLine: null, - newLine: null, - content: match?.[3]?.trim() || line - }) - return - } - - if (!viewLines.length) return - - if (line.startsWith('+')) { - viewLines.push({ - key: `add-${index}`, - type: 'add', - oldLine: null, - newLine, - content: line.slice(1) - }) - newLine += 1 - return - } - - if (line.startsWith('-')) { - viewLines.push({ - key: `delete-${index}`, - type: 'delete', - oldLine, - newLine: null, - content: line.slice(1) - }) - oldLine += 1 - return - } - - viewLines.push({ - key: `context-${index}`, - type: 'context', - oldLine, - newLine, - content: line.startsWith(' ') ? line.slice(1) : line - }) - oldLine += 1 - newLine += 1 - }) - - return viewLines -} - type ModuleWorkspaceSnapshot = { openTabs: EditorFilePath[] activeFile: EditorFilePath | null selectedTreePath: string | null } -type GitChangedFile = { - path: string - status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked' - staged: boolean - unstaged: boolean -} - -type GitCommitInfo = { - hash: string - shortHash: string - author: string - date: string - message: string -} - -type GitStatusInfo = { - ok: boolean - branch: string | null - ahead: number - behind: number - files: GitChangedFile[] - selectedFilePath: string | null - diff: string - commits: GitCommitInfo[] - message?: string -} - -type GitSubmissionInfo = { - id: string - submittedAt: string - moduleName: string - commit: GitCommitInfo -} - -type DiffViewLine = { - key: string - type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note' - oldLine: number | null - newLine: number | null - content: string -} - -type EditorCloseRequest = - | { kind: 'tab'; file: EditorFilePath } - | { kind: 'others'; file: EditorFilePath } - | { kind: 'right'; file: EditorFilePath } - | { kind: 'all'; example: boolean } - | { kind: 'module'; moduleName: string } - | { kind: 'modules'; moduleNames: string[] } - | { kind: 'workspace' } - -type PlatformControlWarningPayload = { - type: 'platform.control.warning' - sessionId: string - platformId?: string | null - instructorPlatformId?: string | null - endsAt: string - warningAt: string -} - -type PlatformControlGrantedPayload = { - type: 'platform.control.granted' - sessionId: string - startedAt: string - endsAt: string - warningAt: string -} - -type PlatformControlTerminatedPayload = { - type: 'platform.control.terminated' - sessionId: string - platformId?: string | null - instructorPlatformId?: string | null - reason: string - message: string - endedAt: string -} - -const maxVisibleModuleLogEntries = 200 -const getModuleTerminalSessionId = (moduleName: string): string => `lms-module-terminal:${moduleName}` -type ModuleLogEntry = { time: string; level: 'INFO' | 'WARN' | 'ERROR'; source: string; message: string } - -function stripInternalTerminalCommands(data: string): string { - return data - .replace(/(?:\x1b\[\?2004[lh])?\r*stty cols \d+ rows \d+\r?\n?/g, '') - .replace(/(?:^|\r?\n)cd '(?:[^'\\]|\\.)*'\r?\n?/g, '') - .replace(/(?:^|\r?\n)cd "(?:[^"\\]|\\.)*"\r?\n?/g, '') - .replace(/(?:^|\r?\n)cd [^\r\n]+\r?\n?/g, '') -} - -function stripLogControlSequences(line: string): string { - return line - .replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '') - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') - .replace(/␛\][^␇]*(?:␇|$)/g, '') - .replace(/␛\[[0-?]*[ -/]*[@-~]/g, '') -} - -function parseLogDate(value?: string): Date | null { - if (!value) return null - const normalized = value.replace(/(\.\d{3})\d+/, '$1') - const date = new Date(normalized) - return Number.isNaN(date.getTime()) ? null : date -} - -const parseModuleLogEntries = (module: ModuleRow, logs: string): ModuleLogEntry[] => { - return logs - .split(/\r?\n/) - .map((line) => stripLogControlSequences(line).trimEnd()) - .filter(Boolean) - .map((line): ModuleLogEntry => { - const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)\s+(.*)$/) - const rosEpochMatch = line.match(/\[(\d{10}\.\d+)\]/) - const bracketLevelMatch = line.match(/\b(ERROR|ERR|WARN|WARNING|INFO|DEBUG|TRACE|FATAL)\b/i) - const levelToken = bracketLevelMatch?.[1]?.toUpperCase() - const level: ModuleLogEntry['level'] = - levelToken === 'ERROR' || levelToken === 'ERR' || levelToken === 'FATAL' - ? 'ERROR' - : levelToken === 'WARN' || levelToken === 'WARNING' - ? 'WARN' - : 'INFO' - const date = parseLogDate(timestampMatch?.[1]) ?? (rosEpochMatch ? new Date(Number(rosEpochMatch[1]) * 1000) : null) - const time = date && !Number.isNaN(date.getTime()) - ? date.toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }) - : '--:--:--' - - return { - time, - level, - source: module.name, - message: timestampMatch?.[2] ?? line - } - }) -} - -const getModuleSourceFiles = (module: ModuleRow): Record => { - const packageName = module.name.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '') - const className = module.name.replace(/[^a-zA-Z0-9]/g, '') - const nodeName = `${packageName}_node` - - return { - [`modules/${module.name}/launch/${packageName}.launch.py`]: `from launch import LaunchDescription -from launch_ros.actions import Node - -def generate_launch_description(): - return LaunchDescription([ - Node( - package='${packageName}', - executable='${nodeName}', - name='${nodeName}', - output='screen', - parameters=['config/${packageName}.yaml'] - ) - ]) -`, - [`modules/${module.name}/config/${packageName}.yaml`]: `/**: - ros__parameters: - enabled: ${module.state === 'running'} -${module.parameters.length ? module.parameters.map((parameter) => ` ${parameter.replace(': ', ': ')}`).join('\n') : ' update_rate: 20.0'} -`, - [`modules/${module.name}/src/${nodeName}.cpp`]: `#include - -class ${className}Node : public rclcpp::Node -{ -public: - ${className}Node() : Node("${nodeName}") - { - timer_ = create_wall_timer( - std::chrono::milliseconds(100), - std::bind(&${className}Node::onTimer, this)); - } - -private: - void onTimer() - { - RCLCPP_INFO_THROTTLE(get_logger(), *get_clock(), 3000, "${module.name} running"); - } - - rclcpp::TimerBase::SharedPtr timer_; -}; - -int main(int argc, char ** argv) -{ - rclcpp::init(argc, argv); - rclcpp::spin(std::make_shared<${className}Node>()); - rclcpp::shutdown(); - return 0; -} -`, - [`modules/${module.name}/include/${packageName}/${nodeName}.hpp`]: `#pragma once - -#include - -class ${className}Node : public rclcpp::Node -{ -public: - ${className}Node(); - -private: - void onTimer(); - rclcpp::TimerBase::SharedPtr timer_; -}; -`, - [`modules/${module.name}/CMakeLists.txt`]: `cmake_minimum_required(VERSION 3.8) -project(${packageName}) - -find_package(ament_cmake REQUIRED) -find_package(rclcpp REQUIRED) - -add_executable(${nodeName} src/${nodeName}.cpp) -ament_target_dependencies(${nodeName} rclcpp) - -install(TARGETS ${nodeName} - DESTINATION lib/\${PROJECT_NAME}) - -install(DIRECTORY launch config - DESTINATION share/\${PROJECT_NAME}) - -ament_package() -`, - [`modules/${module.name}/package.xml`]: ` - - ${packageName} - 0.1.0 - ${module.category} module for autonomy practice. - Autonomy Studio - Apache-2.0 - - ament_cmake - rclcpp - - - ament_cmake - - -` - } -} - -const buildModuleTreeItems = (module: ModuleRow, filePaths = Object.keys(getModuleSourceFiles(module)), folderPaths: string[] = []): Array< - { kind: 'folder'; name: string; path: string; parent?: string; depth: number } | { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number } -> => { - const rootPath = `modules/${module.name}` - return buildPathTreeItems(rootPath, filePaths, folderPaths) -} - -const buildPathTreeItems = (rootPath: string, filePaths: string[], folderPaths: string[] = []): Array< - { kind: 'folder'; name: string; path: string; parent?: string; depth: number } | { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number } -> => { - const sourceFiles = filePaths.filter((filePath) => filePath.startsWith(`${rootPath}/`)) - type ModuleTreeItem = - | { kind: 'folder'; name: string; path: string; parent?: string; depth: number } - | { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number } - const folderMap = new Map() - const fileItems: Array<{ kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number }> = [] - - sourceFiles.forEach((filePath) => { - const parts = filePath.split('/') - const rootDepth = rootPath.split('/').length - for (let index = rootDepth; index < parts.length - 1; index += 1) { - const folderPath = parts.slice(0, index + 1).join('/') - if (!folderMap.has(folderPath)) { - folderMap.set(folderPath, { - kind: 'folder', - name: parts[index], - path: folderPath, - parent: parts.slice(0, index).join('/'), - depth: index - rootDepth + 1 - }) - } - } - fileItems.push({ - kind: 'file', - name: parts.at(-1) ?? filePath, - path: filePath, - parent: parts.slice(0, -1).join('/'), - depth: parts.length - rootDepth - }) - }) - folderPaths.forEach((folderPath) => { - const parts = folderPath.split('/') - const rootDepth = rootPath.split('/').length - for (let index = rootDepth; index < parts.length; index += 1) { - const currentFolderPath = parts.slice(0, index + 1).join('/') - if (!folderMap.has(currentFolderPath)) { - folderMap.set(currentFolderPath, { - kind: 'folder', - name: parts[index], - path: currentFolderPath, - parent: parts.slice(0, index).join('/'), - depth: index - rootDepth + 1 - }) - } - } - }) - - const childrenByParent = new Map() - ;[...folderMap.values(), ...fileItems].forEach((item) => { - if (!item.parent) return - childrenByParent.set(item.parent, [...(childrenByParent.get(item.parent) ?? []), item]) - }) - - const sortTreeItems = (a: ModuleTreeItem, b: ModuleTreeItem): number => { - if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1 - return a.name.localeCompare(b.name) - } - const orderedItems: ModuleTreeItem[] = [] - const visitChildren = (parentPath: string): void => { - ;(childrenByParent.get(parentPath) ?? []).sort(sortTreeItems).forEach((item) => { - orderedItems.push(item) - if (item.kind === 'folder') visitChildren(item.path) - }) - } - - visitChildren(rootPath) - - return orderedItems -} - function LocalPcWorkspaceMockup({ onClose, onDisconnectSession, @@ -1327,66 +440,35 @@ function LocalPcWorkspaceMockup({ const [pendingSourceSettingsSave, setPendingSourceSettingsSave] = useState(null) const [selectedModule, setSelectedModule] = useState(null) const [moduleTabs, setModuleTabs] = useState([]) - const [availableModules, setAvailableModules] = useState([]) - const [modulesLoading, setModulesLoading] = useState(false) - const [installingModuleName, setInstallingModuleName] = useState(null) - const [controllingModuleNames, setControllingModuleNames] = useState([]) - const [moduleListError, setModuleListError] = useState(null) - const [moduleInstallError, setModuleInstallError] = useState(null) - const [moduleLogText, setModuleLogText] = useState('') - const [moduleLogMessage, setModuleLogMessage] = useState(null) - const [moduleLogsLoading, setModuleLogsLoading] = useState(false) - const [gitModuleStatuses, setGitModuleStatuses] = useState>({}) - const [gitLoading, setGitLoading] = useState(false) - const [gitError, setGitError] = useState(null) - const [selectedGitTarget, setSelectedGitTarget] = useState<{ moduleName: string; filePath: string } | null>(null) - const [gitPanelTab, setGitPanelTab] = useState<'source' | 'history'>('source') - const [gitSubmissions, setGitSubmissions] = useState([]) - const [gitSubmissionsLoading, setGitSubmissionsLoading] = useState(false) - const [selectedGitSubmission, setSelectedGitSubmission] = useState(null) - const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('') - const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false) - const [gitSubmitting, setGitSubmitting] = useState(false) const [submitConfirmOpen, setSubmitConfirmOpen] = useState(false) const [workspaceNavFlash, setWorkspaceNavFlash] = useState(false) const [moduleWorkspaceSnapshots, setModuleWorkspaceSnapshots] = useState>({}) - const [moduleSearchQuery, setModuleSearchQuery] = useState('') - const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('') - const [moduleSearchResults, setModuleSearchResults] = useState>([]) - const [moduleSearchRunning, setModuleSearchRunning] = useState(false) - const [pendingReveal, setPendingReveal] = useState<{ lineNumber: number; query: string } | null>(null) const [previewOpen, setPreviewOpen] = useState(false) const [previewLarge, setPreviewLarge] = useState(false) const [previewPosition, setPreviewPosition] = useState({ x: 1040, y: 72 }) const [previewSize, setPreviewSize] = useState({ width: 600, height: 600 }) - const [visualizationStack, setVisualizationStack] = useState('autoware') - const [visualizationRunning, setVisualizationRunning] = useState(false) - const [, setVisualizationStatusMessage] = useState('대기') - const [visualizationBusy, setVisualizationBusy] = useState(false) const { activeEditorFile, setActiveEditorFile, editorFiles, setEditorFiles, - savedEditorFiles, setSavedEditorFiles, dirtyEditorFiles, setDirtyEditorFiles, openEditorTabs, setOpenEditorTabs, - activeExampleEditorFile, setActiveExampleEditorFile, openExampleEditorTabs, setOpenExampleEditorTabs, draggingEditorTab, setDraggingEditorTab, tabDropIndex, + setTabDropIndex, pendingEditorCloseRequest, setPendingEditorCloseRequest, currentActiveEditorFile, currentOpenEditorTabs, isDirtyEditorFile, - hasDirtyFiles, markEditorFileSaved, updateEditorFileContent, discardDirtyFiles, @@ -1402,25 +484,37 @@ function LocalPcWorkspaceMockup({ finishTabDrop, resetTabDrag } = useWorkspaceEditorFiles(editorInitialFiles, exampleActive) - const [selectedTreePath, setSelectedTreePath] = useState(null) - const [selectedExampleTreePath, setSelectedExampleTreePath] = useState(null) const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null) const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null) - const [treeContextMenu, setTreeContextMenu] = useState<{ path: string; kind: 'file' | 'folder'; x: number; y: number } | null>(null) - const [terminalContextMenu, setTerminalContextMenu] = useState<{ text: string; x: number; y: number } | null>(null) const [gitFileContextMenu, setGitFileContextMenu] = useState<{ moduleName: string; folderName?: string | null; filePath: string; status: GitChangedFile['status']; x: number; y: number } | null>(null) - const [treeClipboard, setTreeClipboard] = useState<{ path: string; kind: 'file' | 'folder'; mode: 'cut' | 'copy' } | null>(null) - const [treeDialog, setTreeDialog] = useState< - | { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string } - | { type: 'delete'; path: string; kind: 'file' | 'folder' } - | null - >(null) - const [newFileDraft, setNewFileDraft] = useState<{ kind: 'file' | 'folder'; parent: string; afterFile: EditorFilePath | null; depth: number; value: string } | null>(null) - const [createdModuleFolders, setCreatedModuleFolders] = useState([]) - const [loadedModuleFolders, setLoadedModuleFolders] = useState>({}) - const [loadedModuleFilePaths, setLoadedModuleFilePaths] = useState>({}) - const [moduleFileLoadingByName, setModuleFileLoadingByName] = useState>({}) - const [loadedModuleRoots, setLoadedModuleRoots] = useState>({}) + const { + selectedTreePath, + setSelectedTreePath, + selectedExampleTreePath, + setSelectedExampleTreePath, + treeContextMenu, + setTreeContextMenu, + treeClipboard, + setTreeClipboard, + treeDialog, + setTreeDialog, + newFileDraft, + setNewFileDraft, + createdModuleFolders, + setCreatedModuleFolders, + loadedModuleFolders, + setLoadedModuleFolders, + loadedModuleFilePaths, + setLoadedModuleFilePaths, + moduleFileLoadingByName, + setModuleFileLoadingByName, + loadedModuleRoots, + setLoadedModuleRoots, + openProjectFolders, + setOpenProjectFolders, + treeContextMenuRef, + toggleProjectFolder + } = useWorkspaceTreeState() const [exampleCodeFiles, setExampleCodeFiles] = useState>({}) const [exampleCodeFolders, setExampleCodeFolders] = useState([]) const [exampleCodeLoaded, setExampleCodeLoaded] = useState(false) @@ -1500,35 +594,11 @@ function LocalPcWorkspaceMockup({ } const tabContextMenuRef = useRef(null) const moduleTabContextMenuRef = useRef(null) - const treeContextMenuRef = useRef(null) - const terminalContextMenuRef = useRef(null) const gitFileContextMenuRef = useRef(null) - const moduleSearchInputRef = useRef(null) - const moduleLogScrollRef = useRef(null) - const moduleLogStickToBottomRef = useRef(true) const workspaceRootRef = useRef(null) const editorPaneRef = useRef(null) const editorRef = useRef[0] | null>(null) - const terminalHostRef = useRef(null) - const terminalRef = useRef(null) - const terminalFitRef = useRef(null) - const terminalSuppressQueueRef = useRef([]) - const terminalOutputBufferRef = useRef>({}) - const terminalFallbackPromptShownRef = useRef>(new Set()) - const [terminalPanelHeight, setTerminalPanelHeight] = useState(235) const decorationIdsRef = useRef([]) - const [openProjectFolders, setOpenProjectFolders] = useState>({ - lms: true, - src: true, - 'src/main': true, - 'src/preload': true, - 'src/renderer': true, - 'src/renderer/public': true, - 'src/renderer/public/assets': true, - 'src/renderer/public/assets/images': true, - 'src/renderer/src': true, - 'src/renderer/src/components': true - }) const [leftPanelWidth, setLeftPanelWidth] = useState(310) const [rightPanelWidth, setRightPanelWidth] = useState(310) const [projectTreeWidth, setProjectTreeWidth] = useState(280) @@ -1548,11 +618,6 @@ function LocalPcWorkspaceMockup({ ].filter((profile, index, items) => ( items.findIndex((item) => item.profileType.toLowerCase() === profile.profileType.toLowerCase()) === index )) - const visualizationStacks = visualizationProfiles - .map((profile) => profile.profileType.toLowerCase()) - .filter((stack): stack is 'autoware' | 'nav2' | 'slam' => stack === 'autoware' || stack === 'nav2' || stack === 'slam') - const activeEnvironmentProfile = visualizationProfiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? visualizationProfiles[0] ?? null - const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser']) const configuredWorkspaceRoot = remoteWorkspace ? defaultStudioSourceSettings.workspaceRoot : sourceSettings.workspaceRoot @@ -1571,13 +636,25 @@ function LocalPcWorkspaceMockup({ const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}` const draftClonePathPreview = makePlatformWorkspaceRoot(configuredDraftWorkspaceRoot, studentSlug, weekNumber, platformKey) const draftGiteaRepositoryPreview = `${sourceSettingsDraft.giteaBaseUrl.replace(/\/+$/, '')}/${sourceSettingsDraft.giteaOwner}/${studentSlug}-week-${weekNumber}.git` - const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {} - const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null - ? activeEnvironmentConfig.agent as Record - : null - const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string' - ? activeEnvironmentAgent.url.replace(/\/+$/, '') - : '' + const { + visualizationStacks, + visualizationStack, + setVisualizationStack, + visualizationRunning, + visualizationBusy, + visualizationViewerUrl, + activeEnvironmentProfile, + activeRuntimeImages, + refreshVisualizationStatus, + startVisualizationBridge, + stopVisualizationBridge + } = useWorkspaceVisualization({ + profiles: visualizationProfiles, + clonePathPreview, + rosDomainId: sourceSettings.rosDomainId, + onTitlebarNotice + }) + const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser']) const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => { const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url']) const host = parseHostFromUrl(profileAgentUrl) @@ -1600,36 +677,6 @@ function LocalPcWorkspaceMockup({ return target } const remoteWorkspaceTargetKey = JSON.stringify(resolveRemoteWorkspaceTarget()) - const visualizationRemote = activeEnvironmentConfig.target === 'remote-jetson' && activeEnvironmentAgentUrl - const visualizationRemoteBridgeHttpUrl = (() => { - if (!visualizationRemote) return '' - try { - const url = new URL(activeEnvironmentAgentUrl) - url.port = '8765' - return url.toString().replace(/\/+$/, '') - } catch { - return '' - } - })() - const visualizationBridgeHttpUrl = visualizationRemoteBridgeHttpUrl || 'http://localhost:8765' - const visualizationBridgeUrl = visualizationRemote - ? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:') - : 'ws://localhost:8765' - const visualizationBackendUrl = visualizationBridgeHttpUrl - const visualizationViewerUrl = visualizationStack === 'slam' - ? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}` - : `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}` - const formatVisualizationStackLabel = (stack: 'autoware' | 'nav2' | 'slam' | null | undefined): string => { - if (stack === 'nav2') return 'Nav2' - if (stack === 'slam') return 'SLAM' - return 'Autoware' - } - useEffect(() => { - const profileType = visualizationProfiles[0]?.profileType?.toLowerCase() - if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') { - setVisualizationStack(profileType) - } - }, [visualizationProfiles.map((profile) => profile.id).join('|')]) const workspaceUiSessionKey = makeWorkspaceUiSessionKey(studentSlug, weekNumber, platformId) const hasRestorableWorkspaceUi = Boolean( window.localStorage.getItem(workspaceUiSessionKey) @@ -1638,7 +685,7 @@ function LocalPcWorkspaceMockup({ const [workspaceRestoreLoading, setWorkspaceRestoreLoading] = useState(hasRestorableWorkspaceUi) const workspaceRestoreStartedAtRef = useRef(Date.now()) const getModuleParameterStorageKey = (module: ModuleRow): string => makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name) - const applyModuleParameterOverrides = (modules: ModuleRow[]): ModuleRow[] => { + const applyModuleParameterOverrides = useCallback((modules: ModuleRow[]): ModuleRow[] => { const storedParameters = readStoredModuleParameters() return modules.map((module) => { const override = storedParameters[getModuleParameterStorageKey(module)] @@ -1648,121 +695,135 @@ function LocalPcWorkspaceMockup({ parameters: serializeModuleParameters(override).split(/\s+/).filter(Boolean) } }) - } - const getModuleRosParams = (module: ModuleRow): string => module.parameters.join(' ') - const installedGitModules = availableModules.filter((module) => module.state !== 'uninstalled') - const readGitStatus = (module: ModuleRow, selectedPath: string | null) => ( + }, [courseKey, weekNumber]) + const curriculumModuleKey = (curriculumWeek.modules ?? []) + .map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}`) + .join('|') + const { + availableModules, + setAvailableModules, + modulesLoading, + moduleListError, + loadAvailableModules + } = useWorkspaceModuleList({ + clonePathPreview, + curriculumModules: curriculumWeek.modules, + curriculumModuleKey, + remoteWorkspace, + remoteWorkspaceTargetKey, + pollingActive: activeIdeActivity === 'modules', + applyModuleParameterOverrides + }) + const readGitStatus = useCallback((module: ModuleRow, selectedPath: string | null) => ( remoteWorkspace ? window.remoteWorkspace.git.status(getRemoteWorkspaceTarget(), module.name, module.folderName, selectedPath) : window.localWorkspace.git.status(module.name, clonePathPreview, module.folderName, selectedPath) - ) - const readGitSubmissions = (modules: Array<{ moduleName: string; folderName?: string | null }>) => ( + ), [clonePathPreview, remoteWorkspace, remoteWorkspaceTargetKey]) + const readGitSubmissions = useCallback((modules: Array<{ moduleName: string; folderName?: string | null }>) => ( remoteWorkspace ? window.remoteWorkspace.git.submissions(getRemoteWorkspaceTarget(), modules) : window.localWorkspace.git.submissions(modules, clonePathPreview) - ) - const readGitCommitDiff = (moduleName: string, folderName: string | null | undefined, commitHash: string) => ( + ), [clonePathPreview, remoteWorkspace, remoteWorkspaceTargetKey]) + const readGitCommitDiff = useCallback((moduleName: string, folderName: string | null | undefined, commitHash: string) => ( remoteWorkspace ? window.remoteWorkspace.git.commitDiff(getRemoteWorkspaceTarget(), moduleName, folderName ?? undefined, commitHash) : window.localWorkspace.git.commitDiff(moduleName, clonePathPreview, folderName ?? undefined, commitHash) - ) + ), [clonePathPreview, remoteWorkspace, remoteWorkspaceTargetKey]) const restoreGitFile = (target: NonNullable, module: ModuleRow | undefined) => ( remoteWorkspace ? window.remoteWorkspace.git.restoreFile(getRemoteWorkspaceTarget(), target.moduleName, target.folderName ?? module?.folderName ?? undefined, target.filePath, target.status) : window.localWorkspace.git.restoreFile(target.moduleName, clonePathPreview, target.folderName ?? module?.folderName ?? undefined, target.filePath, target.status) ) - const refreshGitStatuses = async (target = selectedGitTarget): Promise => { - if (!installedGitModules.length) { - setGitModuleStatuses({}) - setGitError(null) - setSelectedGitTarget(null) - return - } - - setGitLoading(true) - setGitError(null) - try { - const entries = await Promise.all(installedGitModules.map(async (module) => { - const selectedPath = target?.moduleName === module.name ? target.filePath : null - const status = await readGitStatus(module, selectedPath) - return [module.name, status] as const - })) - const statusMap = Object.fromEntries(entries) - setGitModuleStatuses(statusMap) - - const currentTargetStatus = target ? statusMap[target.moduleName] : null - const nextTarget = currentTargetStatus?.files.some((file) => file.path === target?.filePath) - ? target - : entries.flatMap(([moduleName, status]) => status.files.map((file) => ({ moduleName, filePath: file.path })))[0] ?? null - setSelectedGitTarget(nextTarget) - - const firstError = entries.find(([, status]) => !status.ok)?.[1] - if (firstError) setGitError(firstError.message ?? '일부 모듈의 Git 상태를 불러오지 못했습니다.') - } catch (error) { - console.error('[git] status failed', error) - setGitModuleStatuses({}) - setGitError(error instanceof Error ? error.message : 'Git 상태를 불러오지 못했습니다.') - } finally { - setGitLoading(false) - } - } - const selectGitFile = async (moduleName: string, filePath: string): Promise => { - const target = { moduleName, filePath } - setSelectedGitTarget(target) - await refreshGitStatuses(target) - } - const refreshGitSubmissions = async (): Promise => { - if (!installedGitModules.length) { - setGitSubmissions([]) - setSelectedGitSubmission(null) - setSelectedGitSubmissionDiff('') - return - } - - setGitSubmissionsLoading(true) - setGitError(null) - try { - const submissionModules = installedGitModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })) - const result = await readGitSubmissions(submissionModules) - if (!result.ok) throw new Error(result.message ?? '제출 내역을 불러오지 못했습니다.') - - setGitSubmissions(result.submissions) - setSelectedGitSubmission((current) => { - if (current && result.submissions.some((submission) => submission.moduleName === current.moduleName && submission.commit.hash === current.commit.hash)) { - return current - } - return result.submissions[0] ?? null - }) - } catch (error) { - console.error('[git] submissions failed', error) - setGitSubmissions([]) - setSelectedGitSubmission(null) - setSelectedGitSubmissionDiff('') - setGitError(error instanceof Error ? error.message : '제출 내역을 불러오지 못했습니다.') - } finally { - setGitSubmissionsLoading(false) - } - } - const loadGitSubmissionDiff = async (submission: GitSubmissionInfo | null): Promise => { - if (!submission) { - setSelectedGitSubmissionDiff('') - return - } - - const module = availableModules.find((item) => item.name === submission.moduleName) - setGitSubmissionDiffLoading(true) - try { - const result = await readGitCommitDiff(submission.moduleName, module?.folderName, submission.commit.hash) - if (!result.ok) throw new Error(result.message ?? '제출 diff를 불러오지 못했습니다.') - setSelectedGitSubmissionDiff(result.diff) - } catch (error) { - console.error('[git] submission diff failed', error) - setSelectedGitSubmissionDiff('') - setGitError(error instanceof Error ? error.message : '제출 diff를 불러오지 못했습니다.') - } finally { - setGitSubmissionDiffLoading(false) - } - } + const { + gitPanelTab, + setGitPanelTab, + gitModuleStatuses, + setGitModuleStatuses, + gitLoading, + gitError, + setGitError, + selectedGitTarget, + setSelectedGitTarget, + gitSubmissions, + gitSubmissionsLoading, + selectedGitSubmission, + setSelectedGitSubmission, + selectedGitSubmissionDiff, + gitSubmissionDiffLoading, + gitSubmitting, + setGitSubmitting, + installedGitModules, + gitChangedModules, + gitChangedFileCount, + selectedGitStatus, + selectedGitFile, + gitSubmissionGroups, + refreshGitStatuses, + selectGitFile, + refreshGitSubmissions + } = useSourceControl({ + modules: availableModules, + readGitStatus, + readGitSubmissions, + readGitCommitDiff + }) + const { + loadModuleFiles, + startCreateFile, + commitCreateFile, + getTreeDisplayPath, + getModuleFilePrefix, + getDirtyModuleFiles, + saveCurrentEditorFile, + saveAllEditorFiles, + openTreeContextTarget, + copyTreePathToClipboard, + performRenameTreeTarget, + requestRenameTreeTarget, + performDeleteTreeTarget, + requestDeleteTreeTarget, + pasteTreeClipboard, + refreshModuleFiles + } = useWorkspaceFileActions({ + selectedModule, + clonePathPreview, + selectedTreePath, + setSelectedTreePath, + setSelectedExampleTreePath, + setActiveIdeActivity, + editorFiles, + setEditorFiles, + dirtyEditorFiles, + setDirtyEditorFiles, + setSavedEditorFiles, + currentActiveEditorFile, + isDirtyEditorFile, + markEditorFileSaved, + setActiveEditorFile, + setOpenEditorTabs, + setActiveExampleEditorFile, + setOpenExampleEditorTabs, + loadedModuleFolders, + setLoadedModuleFolders, + setLoadedModuleFilePaths, + setModuleFileLoadingByName, + loadedModuleRoots, + setLoadedModuleRoots, + createdModuleFolders, + setCreatedModuleFolders, + openProjectFolders, + setOpenProjectFolders, + treeContextMenu, + setTreeContextMenu, + treeClipboard, + setTreeClipboard, + setTreeDialog, + newFileDraft, + setNewFileDraft, + installedGitModules, + selectedGitTarget, + refreshGitStatuses + }) const restoreGitChangedFile = async (target = gitFileContextMenu): Promise => { if (!target) return setGitFileContextMenu(null) @@ -2062,6 +1123,25 @@ function LocalPcWorkspaceMockup({ height }) } + const { + terminalContextMenu, + setTerminalContextMenu, + terminalContextMenuRef, + terminalHostRef, + terminalPanelHeight, + fitTerminal, + copyTerminalSelection, + pasteTerminalClipboard, + startTerminalResize + } = useWorkspaceTerminal({ + moduleName: selectedModule?.name, + moduleFolderName: selectedModule?.folderName, + clonePathPreview, + dark, + terminalActive, + resetKey: `${remoteWorkspace}:${remoteWorkspaceTargetKey}`, + layoutEditorToPane + }) const startResize = (panel: 'left' | 'right', event: React.MouseEvent): void => { event.preventDefault() const startX = event.clientX @@ -2069,10 +1149,10 @@ function LocalPcWorkspaceMockup({ const relayoutEditorFrame = (): void => { window.requestAnimationFrame(() => { layoutEditorToPane() - terminalFitRef.current?.fit() + fitTerminal() window.requestAnimationFrame(() => { layoutEditorToPane() - terminalFitRef.current?.fit() + fitTerminal() }) }) } @@ -2202,38 +1282,10 @@ function LocalPcWorkspaceMockup({ setActiveEditorFile(file) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) } - const openEditorFileAtLine = (file: EditorFilePath, lineNumber: number): void => { + const openEditorFileAtLine = (file: EditorFilePath): void => { setSelectedTreePath(file) setActiveEditorFile(file) setOpenEditorTabs((tabs) => (tabs.includes(file) ? tabs : [...tabs, file])) - setPendingReveal({ lineNumber, query: moduleSearchQuery.trim() }) - } - const loadModuleFiles = async (module: ModuleRow): Promise => { - setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true })) - try { - const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName) - const nextFiles = result.files - const nextFolders = result.folders.length ? result.folders : [] - - setEditorFiles((files) => { - const merged = { ...files } - Object.entries(nextFiles).forEach(([path, content]) => { - if (!dirtyEditorFiles[path]) merged[path] = content - }) - return merged - }) - setSavedEditorFiles((files) => ({ ...files, ...nextFiles })) - setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: Object.keys(nextFiles) })) - setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: nextFolders })) - setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: result.absoluteRoot })) - } catch (error) { - console.error('[module-files] load failed', error) - setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: [] })) - setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: [] })) - setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: null })) - } finally { - setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: false })) - } } const loadExampleCodeFiles = async (): Promise => { try { @@ -2283,273 +1335,6 @@ function LocalPcWorkspaceMockup({ void loadExampleCodeFiles() } } - const startCreateFile = (kind: 'file' | 'folder'): void => { - if (!selectedModule) return - const rootPath = `modules/${selectedModule.name}` - const selectedPathInModule = selectedTreePath?.startsWith(`${rootPath}/`) || selectedTreePath === rootPath ? selectedTreePath : null - const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined : false - const parent = selectedPathInModule - ? selectedIsFile - ? selectedPathInModule.split('/').slice(0, -1).join('/') - : selectedPathInModule - : rootPath - const depth = Math.max(1, parent.split('/').length - 1) - - setOpenProjectFolders((folders) => ({ ...folders, [rootPath]: true, [parent]: true })) - setNewFileDraft({ kind, parent, afterFile: selectedIsFile ? selectedPathInModule : null, depth, value: '' }) - setActiveIdeActivity('workspace') - } - const commitCreateFile = async (): Promise => { - if (!newFileDraft || !selectedModule) return - const fileName = newFileDraft.value.trim() - if (!fileName) { - setNewFileDraft(null) - return - } - const normalizedName = fileName.replace(/^\/+/, '') - if (newFileDraft.kind === 'folder') { - const nextFolder = `${newFileDraft.parent}/${normalizedName}` - try { - await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName) - } catch (error) { - console.error('[module-files] create folder failed', error) - return - } - setCreatedModuleFolders((folders) => (folders.includes(nextFolder) ? folders : [...folders, nextFolder])) - setLoadedModuleFolders((folders) => ({ ...folders, [selectedModule.name]: Array.from(new Set([...(folders[selectedModule.name] ?? []), nextFolder])) })) - setOpenProjectFolders((folders) => ({ ...folders, [newFileDraft.parent]: true, [nextFolder]: true })) - setSelectedTreePath(nextFolder) - setNewFileDraft(null) - return - } - - const nextFile = `${newFileDraft.parent}/${normalizedName}` - try { - await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName) - } catch (error) { - console.error('[module-files] create file failed', error) - return - } - - setEditorFiles((files) => (files[nextFile] === undefined ? { ...files, [nextFile]: '' } : files)) - setSavedEditorFiles((files) => (files[nextFile] === undefined ? { ...files, [nextFile]: '' } : files)) - setLoadedModuleFilePaths((paths) => ({ ...paths, [selectedModule.name]: Array.from(new Set([...(paths[selectedModule.name] ?? []), nextFile])) })) - setSelectedTreePath(nextFile) - setActiveEditorFile(nextFile) - setOpenEditorTabs((tabs) => (tabs.includes(nextFile) ? tabs : [...tabs, nextFile])) - setNewFileDraft(null) - } - const getPathName = (path: string): string => path.split('/').pop() ?? path - const getPathParent = (path: string): string => path.split('/').slice(0, -1).join('/') - const makeUniquePath = (basePath: string, files: Record, folders: string[]): string => { - if (files[basePath] === undefined && !folders.includes(basePath)) return basePath - const parent = getPathParent(basePath) - const name = getPathName(basePath) - const dotIndex = name.lastIndexOf('.') - const stem = dotIndex > 0 ? name.slice(0, dotIndex) : name - const ext = dotIndex > 0 ? name.slice(dotIndex) : '' - let index = 1 - - while (true) { - const nextPath = `${parent}/${stem} copy${index > 1 ? ` ${index}` : ''}${ext}` - if (files[nextPath] === undefined && !folders.includes(nextPath)) return nextPath - index += 1 - } - } - const replacePathPrefix = (path: string, fromPath: string, toPath: string): string => ( - path === fromPath ? toPath : path.startsWith(`${fromPath}/`) ? `${toPath}${path.slice(fromPath.length)}` : path - ) - const getTreeDisplayPath = (path: string): string => { - const moduleRoot = selectedModule ? loadedModuleRoots[selectedModule.name] : null - const logicalRoot = selectedModule ? `modules/${selectedModule.name}` : '' - - if (!moduleRoot || !path.startsWith(logicalRoot)) return path - return path === logicalRoot ? moduleRoot : `${moduleRoot}/${path.slice(logicalRoot.length + 1)}` - } - const getModuleFilePrefix = (moduleName: string): string => `modules/${moduleName}` - const getDirtyModuleFiles = (moduleName: string): EditorFilePath[] => { - const moduleRoot = getModuleFilePrefix(moduleName) - return Object.keys(dirtyEditorFiles).filter((file) => dirtyEditorFiles[file] && (file === moduleRoot || file.startsWith(`${moduleRoot}/`))) - } - const saveEditorFile = async (file: EditorFilePath): Promise => { - if (!selectedModule || file.startsWith('examples/example-code/')) return - await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName) - markEditorFileSaved(file, editorFiles[file] ?? '') - if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget) - } - const saveCurrentEditorFile = (): void => { - if (!currentActiveEditorFile || currentActiveEditorFile.startsWith('examples/example-code/') || !isDirtyEditorFile(currentActiveEditorFile)) return - void saveEditorFile(currentActiveEditorFile).catch((error) => console.error('[module-files] save failed', error)) - } - const saveAllEditorFiles = (): void => { - if (!selectedModule) return - const moduleRoot = getModuleFilePrefix(selectedModule.name) - const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => ( - dirtyEditorFiles[file] && (file === moduleRoot || file.startsWith(`${moduleRoot}/`)) - )) - if (!dirtyFiles.length) return - - void Promise.all(dirtyFiles.map((file) => saveEditorFile(file))).catch((error) => console.error('[module-files] save all failed', error)) - } - const copyTerminalSelection = (): void => { - if (!terminalContextMenu?.text) return - void navigator.clipboard.writeText(terminalContextMenu.text).catch((error) => console.error('[terminal] copy failed', error)) - setTerminalContextMenu(null) - } - const updateModulePaths = (updater: (paths: string[]) => string[]): void => { - if (!selectedModule) return - setLoadedModuleFilePaths((paths) => ({ ...paths, [selectedModule.name]: updater(paths[selectedModule.name] ?? []) })) - setLoadedModuleFolders((folders) => ({ ...folders, [selectedModule.name]: updater(folders[selectedModule.name] ?? []) })) - setCreatedModuleFolders((folders) => updater(folders)) - } - const updateOpenPathReferences = (fromPath: string, toPath: string): void => { - setSelectedTreePath((path) => path ? replacePathPrefix(path, fromPath, toPath) : path) - setActiveEditorFile((path) => path ? replacePathPrefix(path, fromPath, toPath) : path) - setOpenEditorTabs((tabs) => tabs.map((tab) => replacePathPrefix(tab, fromPath, toPath))) - setOpenProjectFolders((folders) => { - const nextFolders: Record = {} - Object.entries(folders).forEach(([path, open]) => { - nextFolders[replacePathPrefix(path, fromPath, toPath)] = open - }) - return nextFolders - }) - } - const openTreeContextTarget = (target = treeContextMenu): void => { - if (!target) return - if (target.kind === 'file') { - openEditorFile(target.path) - } else { - if (target.path.startsWith('examples/example-code')) setSelectedExampleTreePath(target.path) - else setSelectedTreePath(target.path) - setOpenProjectFolders((folders) => ({ ...folders, [target.path]: true })) - } - setTreeContextMenu(null) - } - const copyTreePathToClipboard = async (): Promise => { - if (!treeContextMenu) return - const clipboardPath = getTreeDisplayPath(treeContextMenu.path) - try { - await navigator.clipboard.writeText(clipboardPath) - } catch { - console.warn('[tree] clipboard write failed') - } - setTreeContextMenu(null) - } - const performRenameTreeTarget = async (currentPath: string, nextNameInput: string): Promise => { - if (!selectedModule) return - const nextName = nextNameInput.trim() - if (!nextName || nextName === getPathName(currentPath)) { - setTreeDialog(null) - return - } - const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}` - try { - await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName) - } catch (error) { - console.error('[module-files] rename failed', error) - return - } - - setEditorFiles((files) => { - const nextFiles: Record = {} - Object.entries(files).forEach(([path, content]) => { - nextFiles[replacePathPrefix(path, currentPath, nextPath)] = content - }) - return nextFiles - }) - setSavedEditorFiles((files) => { - const nextFiles: Record = {} - Object.entries(files).forEach(([path, content]) => { - nextFiles[replacePathPrefix(path, currentPath, nextPath)] = content - }) - return nextFiles - }) - setDirtyEditorFiles((dirtyFiles) => { - const nextDirtyFiles: Record = {} - Object.entries(dirtyFiles).forEach(([path, dirty]) => { - if (dirty) nextDirtyFiles[replacePathPrefix(path, currentPath, nextPath)] = true - }) - return nextDirtyFiles - }) - updateModulePaths((paths) => Array.from(new Set(paths.map((path) => replacePathPrefix(path, currentPath, nextPath))))) - updateOpenPathReferences(currentPath, nextPath) - setTreeDialog(null) - } - const requestRenameTreeTarget = (): void => { - if (!treeContextMenu) return - setTreeDialog({ type: 'rename', path: treeContextMenu.path, kind: treeContextMenu.kind, value: getPathName(treeContextMenu.path) }) - setTreeContextMenu(null) - } - const performDeleteTreeTarget = async (targetPath: string): Promise => { - if (!selectedModule) return - try { - await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName) - } catch (error) { - console.error('[module-files] delete failed', error) - return - } - setEditorFiles((files) => Object.fromEntries(Object.entries(files).filter(([path]) => path !== targetPath && !path.startsWith(`${targetPath}/`)))) - setSavedEditorFiles((files) => Object.fromEntries(Object.entries(files).filter(([path]) => path !== targetPath && !path.startsWith(`${targetPath}/`)))) - setDirtyEditorFiles((dirtyFiles) => Object.fromEntries(Object.entries(dirtyFiles).filter(([path]) => path !== targetPath && !path.startsWith(`${targetPath}/`)))) - updateModulePaths((paths) => paths.filter((path) => path !== targetPath && !path.startsWith(`${targetPath}/`))) - setOpenEditorTabs((tabs) => tabs.filter((tab) => tab !== targetPath && !tab.startsWith(`${targetPath}/`))) - setActiveEditorFile((path) => path && (path === targetPath || path.startsWith(`${targetPath}/`)) ? null : path) - setSelectedTreePath((path) => path && (path === targetPath || path.startsWith(`${targetPath}/`)) ? null : path) - setTreeDialog(null) - } - const requestDeleteTreeTarget = (): void => { - if (!treeContextMenu) return - setTreeDialog({ type: 'delete', path: treeContextMenu.path, kind: treeContextMenu.kind }) - setTreeContextMenu(null) - } - const pasteTreeClipboard = async (): Promise => { - if (!treeContextMenu || !treeClipboard || !selectedModule) return - const sourcePath = treeClipboard.path - const pasteParent = treeContextMenu.kind === 'folder' ? treeContextMenu.path : getPathParent(treeContextMenu.path) - if (pasteParent === sourcePath || pasteParent.startsWith(`${sourcePath}/`)) return - const rootPath = `modules/${selectedModule.name}` - const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))])) - const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders) - try { - await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName) - } catch (error) { - console.error('[module-files] copy failed', error) - return - } - - setEditorFiles((files) => { - const nextFiles = { ...files } - Object.entries(files).forEach(([path, content]) => { - if (path === sourcePath || path.startsWith(`${sourcePath}/`)) { - nextFiles[replacePathPrefix(path, sourcePath, targetBasePath)] = content - } - }) - if (treeClipboard.mode === 'cut') { - Object.keys(nextFiles).forEach((path) => { - if (path === sourcePath || path.startsWith(`${sourcePath}/`)) delete nextFiles[path] - }) - } - return nextFiles - }) - updateModulePaths((paths) => { - const copiedPaths = paths - .filter((path) => path === sourcePath || path.startsWith(`${sourcePath}/`)) - .map((path) => replacePathPrefix(path, sourcePath, targetBasePath)) - const movedPaths = treeClipboard.mode === 'cut' ? paths.filter((path) => path !== sourcePath && !path.startsWith(`${sourcePath}/`)) : paths - return Array.from(new Set([...movedPaths, ...copiedPaths])) - }) - setOpenProjectFolders((folders) => ({ ...folders, [pasteParent]: true, [targetBasePath]: treeClipboard.kind === 'folder' })) - if (treeClipboard.mode === 'cut') { - updateOpenPathReferences(sourcePath, targetBasePath) - setTreeClipboard(null) - } - setTreeContextMenu(null) - } - const refreshModuleFiles = (): void => { - if (!selectedModule) return - setNewFileDraft(null) - void loadModuleFiles(selectedModule) - } useEffect(() => { if (!selectedModule) return @@ -2622,158 +1407,6 @@ function LocalPcWorkspaceMockup({ void applySourceSettings(normalizedSettings, false) } - const loadAvailableModules = async ({ silent = false }: { silent?: boolean } = {}): Promise => { - if (!silent) setModulesLoading(true) - try { - const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumWeek.modules ?? []) - if (remoteWorkspace) { - const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] })) - const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module])) - result.modules = result.modules.map((module) => { - const remoteModule = remoteStateByName.get(module.name) - return remoteModule?.state === 'running' - ? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime } - : module - }) - } - setModuleListError(null) - setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)) - } catch (error) { - console.error('[modules] list failed', error) - const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.' - if (!silent) { - setModuleListError(message) - setAvailableModules(makeUninstalledModuleRows(curriculumWeek.modules)) - } - } finally { - if (!silent) setModulesLoading(false) - } - } - const loadRemoteModules = async (): Promise<{ modules: ModuleRow[] }> => { - const target = resolveRemoteWorkspaceTarget() - if (!target) return { modules: [] } - return window.remoteWorkspace.modules.list(target, curriculumWeek.modules ?? []) - } - const curriculumModuleKey = (curriculumWeek.modules ?? []) - .map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}`) - .join('|') - const refreshVisualizationStatus = async (): Promise => { - const status = await window.localWorkspace.visualization.status().catch((error) => ({ - ok: false, - running: false, - stack: null, - message: error instanceof Error ? error.message : String(error) - })) - setVisualizationRunning(Boolean(status.running)) - if (status.stack) setVisualizationStack(status.stack) - setVisualizationStatusMessage(status.running - ? formatVisualizationStackLabel(status.stack) - : status.message - ? status.message - : '대기') - } - const startVisualizationBridge = async (): Promise => { - if (visualizationBusy) return - if (!activeEnvironmentProfile) { - setVisualizationStatusMessage('지원 안함') - onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning') - return - } - setVisualizationBusy(true) - setVisualizationStatusMessage('시작 중') - try { - const status = await window.localWorkspace.visualization.start(visualizationStack, clonePathPreview, sourceSettings.rosDomainId, activeEnvironmentProfile?.config) - if (!status.ok) throw new Error(status.message ?? '시작 실패') - setVisualizationRunning(Boolean(status.running)) - if (status.stack) setVisualizationStack(status.stack) - setVisualizationStatusMessage(formatVisualizationStackLabel(status.stack)) - onTitlebarNotice('화면이 시작되었습니다', 'info') - } catch (error) { - setVisualizationRunning(false) - setVisualizationStatusMessage(error instanceof Error ? error.message : '시작 실패') - onTitlebarNotice('화면 시작에 실패했습니다', 'error') - } finally { - setVisualizationBusy(false) - } - } - const stopVisualizationBridge = async (): Promise => { - if (visualizationBusy) return - setVisualizationBusy(true) - setVisualizationStatusMessage('종료 중') - try { - const status = await window.localWorkspace.visualization.stop(activeEnvironmentProfile?.config) - if (!status.ok) throw new Error(status.message ?? '종료 실패') - setVisualizationRunning(false) - setVisualizationStatusMessage('대기') - onTitlebarNotice('화면이 종료되었습니다', 'info') - } catch (error) { - setVisualizationStatusMessage(error instanceof Error ? error.message : '종료 실패') - onTitlebarNotice('화면 종료에 실패했습니다', 'error') - } finally { - setVisualizationBusy(false) - } - } - const installModuleOnce = async (module: ModuleRow, requestAccessToken: string): Promise => { - const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, { - method: 'POST', - headers: { - Authorization: `Bearer ${requestAccessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - courseId: courseKey, - week: curriculumWeek.week, - moduleName: module.name, - folderName: module.folderName, - templateRepositoryUrl: module.templateRepositoryUrl - }) - }) - if (!response.ok) throw new Error(await response.text()) - const prepared = await response.json() as { - repositoryUrlWithToken?: string - templateRepositoryUrlWithToken?: string - templateRepositoryUrl?: string - } - - const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl - await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken) - } - const installModule = async (module: ModuleRow): Promise => { - if (installingModuleName) return - setInstallingModuleName(module.name) - setModuleInstallError(null) - try { - const requestAccessToken = await getFreshAccessToken() - await installModuleOnce(module, requestAccessToken) - await loadAvailableModules({ silent: true }) - } catch (error) { - console.error('[modules] install failed', error) - setModuleInstallError(error instanceof Error ? error.message : '모듈 설치 실패') - } finally { - setInstallingModuleName(null) - } - } - const installAllModules = async (): Promise => { - if (installingModuleName) return - const targets = availableModules.filter((module) => module.state === 'uninstalled').sort(compareModulePriority) - if (!targets.length) return - - setInstallingModuleName('__all__') - setModuleInstallError(null) - try { - const requestAccessToken = await getFreshAccessToken() - for (const module of targets) { - await installModuleOnce(module, requestAccessToken) - } - await loadAvailableModules({ silent: true }) - } catch (error) { - console.error('[modules] install all failed', error) - setModuleInstallError(error instanceof Error ? error.message : '전체 설치 실패') - await loadAvailableModules({ silent: true }) - } finally { - setInstallingModuleName(null) - } - } const saveDirtyFilesForModule = async (module: ModuleRow): Promise => { const moduleRoot = getModuleFilePrefix(module.name) const dirtyFiles = Object.keys(dirtyEditorFiles).filter((file) => ( @@ -2791,218 +1424,47 @@ function LocalPcWorkspaceMockup({ return nextDirtyFiles }) } - const prepareRemoteRuntimeModule = async (module: ModuleRow): Promise => { - const requestAccessToken = await getFreshAccessToken() - await saveDirtyFilesForModule(module) - - const response = await fetch(`${LMS_API_BASE_URL}/studio/repositories/prepare`, { - method: 'POST', - headers: { - Authorization: `Bearer ${requestAccessToken}`, - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - courseId: courseKey, - week: curriculumWeek.week, - moduleName: module.name, - folderName: module.folderName, - templateRepositoryUrl: module.templateRepositoryUrl - }) - }) - if (!response.ok) throw new Error(await response.text()) - const prepared = await response.json() as { - repositoryUrlWithToken?: string - templateRepositoryUrlWithToken?: string - templateRepositoryUrl?: string - } - - const submitResult = await window.localWorkspace.git.submit( - module.name, - clonePathPreview, - module.folderName, - `Run: ${weekNumber}주차 ${module.name}`, - undefined, - undefined, - undefined - ) - if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) { - throw new Error(`[${module.name}] ${submitResult.message}`) - } - - const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl - await window.remoteWorkspace.modules.install( - getRemoteWorkspaceTarget(), - module.name, - module.folderName, - templateRepositoryUrl, - prepared.repositoryUrlWithToken - ) - } - const remoteModuleStart = async (module: ModuleRow): Promise<{ ok: boolean; message?: string }> => { - await prepareRemoteRuntimeModule(module) - return window.remoteWorkspace.modules.start( - getRemoteWorkspaceTarget(), - module.name, - module.folderName, - getModuleRosParams(module), - sourceSettings.gpuEnabled, - sourceSettings.rosDomainId - ) - } - const remoteModuleStop = async (module: ModuleRow): Promise<{ ok: boolean; message?: string }> => { - return window.remoteWorkspace.modules.stop(getRemoteWorkspaceTarget(), module.name, module.folderName) - } - const remoteModuleRestart = async (module: ModuleRow): Promise<{ ok: boolean; message?: string }> => { - await remoteModuleStop(module).catch(() => ({ ok: false, message: '모듈 중지 실패' })) - return remoteModuleStart(module) - } - const stopRunningWorkspaceModules = useCallback(async (): Promise => { - if (remoteWorkspace) { - const moduleMap = new Map() - for (const module of availableModules) { - moduleMap.set(module.name, { - moduleName: module.name, - folderName: module.folderName, - sortOrder: module.sortOrder - }) - } - for (const module of curriculumWeek.modules ?? []) { - if (!moduleMap.has(module.name)) { - moduleMap.set(module.name, { - moduleName: module.name, - folderName: module.folderName, - sortOrder: module.sortOrder - }) - } - } - const modules = Array.from(moduleMap.values()) - .sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0)) - .map(({ moduleName, folderName }) => ({ moduleName, folderName })) - if (!modules.length) return - await window.remoteWorkspace.modules.resetSession(getRemoteWorkspaceTarget(), modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' })) - return - } - await window.localWorkspace.modules.stopRunning().catch(() => ({ ok: false, message: '모듈 중지 실패' })) - }, [availableModules, curriculumWeek.modules, remoteWorkspace, remoteWorkspaceTargetKey]) - useEffect(() => { - onRegisterStopRunningModules?.(stopRunningWorkspaceModules) - return () => onRegisterStopRunningModules?.(null) - }, [onRegisterStopRunningModules, stopRunningWorkspaceModules]) - const startAllModules = async (): Promise => { - if (installingModuleName) return - const targets = availableModules.filter((module) => module.state === 'stopped').sort(compareModulePriority) - if (!targets.length) { - onTitlebarNotice('시작할 모듈이 없습니다', 'warning') - return - } - - setInstallingModuleName('__start_all__') - setModuleInstallError(null) - try { - for (const module of targets) { - const result = remoteWorkspace - ? await remoteModuleStart(module) - : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId) - if (!result.ok) throw new Error(result.message) - } - await loadAvailableModules({ silent: true }) - onTitlebarNotice('모듈이 전체 시작되었습니다', 'info') - } catch (error) { - console.error('[modules] start all failed', error) - setModuleInstallError(error instanceof Error ? error.message : '전체 실행 실패') - onTitlebarNotice('모듈 전체 시작에 실패했습니다', 'error') - await loadAvailableModules({ silent: true }) - } finally { - setInstallingModuleName(null) - } - } - const stopAllModules = async (): Promise => { - if (installingModuleName) return - const targets = availableModules.filter((module) => module.state === 'running').sort(compareModuleShutdownPriority) - if (!targets.length) { - onTitlebarNotice('종료할 모듈이 없습니다', 'warning') - return - } - - setInstallingModuleName('__stop_all__') - setModuleInstallError(null) - try { - if (remoteWorkspace) { - for (const module of targets) { - const result = await remoteModuleStop(module) - if (!result.ok) throw new Error(result.message) - } - } else { - const result = await window.localWorkspace.modules.stopMany( - targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })), - clonePathPreview - ) - if (!result.ok) throw new Error(result.message) - } - await loadAvailableModules({ silent: true }) - onTitlebarNotice('모듈이 전체 종료되었습니다', 'info') - } catch (error) { - console.error('[modules] stop all failed', error) - setModuleInstallError(error instanceof Error ? error.message : '전체 중지 실패') - onTitlebarNotice('모듈 전체 종료에 실패했습니다', 'error') - await loadAvailableModules({ silent: true }) - } finally { - setInstallingModuleName(null) - } - } - const runModuleControl = async (module: ModuleRow, action: 'start' | 'stop' | 'restart'): Promise => { - if (installingModuleName || controllingModuleNames.includes(module.name)) return - - setControllingModuleNames((names) => (names.includes(module.name) ? names : [...names, module.name])) - setModuleInstallError(null) - try { - const result = action === 'stop' - ? remoteWorkspace - ? await remoteModuleStop(module) - : await window.localWorkspace.modules.stop(module.name, clonePathPreview, module.folderName) - : action === 'restart' - ? remoteWorkspace - ? await remoteModuleRestart(module) - : await window.localWorkspace.modules.restart(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId) - : remoteWorkspace - ? await remoteModuleStart(module) - : await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId) - if (!result.ok) throw new Error(result.message) - await loadAvailableModules({ silent: true }) - const actionLabel = action === 'stop' - ? '종료되었습니다' - : action === 'restart' - ? '재시작되었습니다' - : '실행되었습니다' - onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'info') - } catch (error) { - console.error(`[modules] ${action} failed`, error) - setModuleInstallError(error instanceof Error ? error.message : '모듈 제어 실패') - const actionLabel = action === 'stop' - ? '종료 실패' - : action === 'restart' - ? '재시작 실패' - : '실행 실패' - onTitlebarNotice(`[${module.name}] ${actionLabel}`, 'error') - await loadAvailableModules({ silent: true }) - } finally { - setControllingModuleNames((names) => names.filter((name) => name !== module.name)) - } - } - const updateModuleParameters = async (module: ModuleRow, parameters: ModuleParameterPair[]): Promise => { - setModuleInstallError(null) - try { - const normalizedParameters = parameters - .map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() })) - .filter((parameter) => parameter.name) - storeModuleParameters(getModuleParameterStorageKey(module), normalizedParameters) - const nextTokens = serializeModuleParameters(normalizedParameters).split(/\s+/).filter(Boolean) - setAvailableModules((modules) => modules.map((item) => item.name === module.name ? { ...item, parameters: nextTokens } : item)) - } catch (error) { - console.error('[modules] update parameters failed', error) - setModuleInstallError(error instanceof Error ? error.message : '파라미터 저장 실패') - } - } + const { prepareRemoteRuntimeModule } = useRemoteWorkspaceRuntime({ + availableModules, + curriculumModules: curriculumWeek.modules, + courseKey, + weekNumber, + clonePathPreview, + remoteWorkspace, + remoteWorkspaceTargetKey, + activeRuntimeImages, + getFreshAccessToken, + getRemoteWorkspaceTarget, + saveDirtyFilesForModule, + onRegisterStopRunningModules + }) + const { + installingModuleName, + setInstallingModuleName, + controllingModuleNames, + moduleInstallError, + setModuleInstallError, + installModule, + installAllModules, + startAllModules, + stopAllModules, + runModuleControl, + updateModuleParameters + } = useWorkspaceModuleControl({ + availableModules, + setAvailableModules, + loadAvailableModules, + courseKey, + weekNumber, + clonePathPreview, + remoteWorkspace, + sourceSettings, + activeRuntimeImages, + getFreshAccessToken, + getRemoteWorkspaceTarget, + prepareRemoteRuntimeModule, + onTitlebarNotice + }) const deleteModule = async (module: ModuleRow): Promise => { setModuleInstallError(null) try { @@ -3047,35 +1509,19 @@ function LocalPcWorkspaceMockup({ onTitlebarNotice(`[${module.name}] 삭제에 실패했습니다`, 'error') } } - useEffect(() => { - void loadAvailableModules() - }, [clonePathPreview, curriculumWeek.week, curriculumModuleKey, remoteWorkspaceTargetKey]) useEffect(() => { if (!workspaceRestoreLoading) return const timer = window.setTimeout(() => setWorkspaceRestoreLoading(false), 3000) return () => window.clearTimeout(timer) }, [workspaceRestoreLoading]) - useEffect(() => { - if (activeIdeActivity !== 'modules') return - - const intervalId = window.setInterval(() => { - void loadAvailableModules({ silent: true }) - }, 5000) - - return () => window.clearInterval(intervalId) - }, [activeIdeActivity, clonePathPreview, curriculumWeek.week, curriculumModuleKey, remoteWorkspaceTargetKey]) useEffect(() => { if (!gitActive) return void refreshGitStatuses(selectedGitTarget) - }, [gitActive, clonePathPreview, availableModules]) + }, [gitActive, clonePathPreview, remoteWorkspaceTargetKey, refreshGitStatuses]) useEffect(() => { if (!gitActive || gitPanelTab !== 'history') return void refreshGitSubmissions() - }, [gitActive, gitPanelTab, clonePathPreview, availableModules]) - useEffect(() => { - if (!gitActive || gitPanelTab !== 'history') return - void loadGitSubmissionDiff(selectedGitSubmission) - }, [gitActive, gitPanelTab, selectedGitSubmission?.moduleName, selectedGitSubmission?.commit.hash, clonePathPreview, availableModules]) + }, [gitActive, gitPanelTab, clonePathPreview, remoteWorkspaceTargetKey, refreshGitSubmissions]) const completeWorkspaceRestoreLoading = (): void => { const remainingMs = Math.max(0, 2000 - (Date.now() - workspaceRestoreStartedAtRef.current)) window.setTimeout(() => setWorkspaceRestoreLoading(false), remainingMs) @@ -3315,8 +1761,8 @@ function LocalPcWorkspaceMockup({ discardDirtyFiles(Object.keys(dirtyEditorFiles).filter((file) => dirtyEditorFiles[file])) onClose() } - const revealSearchMatch = (lineNumber: number, query: string): void => { - if (!editorRef.current) return + const revealSearchMatch = (lineNumber: number, query: string): boolean => { + if (!editorRef.current) return false const editor = editorRef.current const model = editor.getModel() const lineText = model?.getLineContent(lineNumber) ?? '' @@ -3341,38 +1787,11 @@ function LocalPcWorkspaceMockup({ editor.revealLineInCenter(lineNumber) editor.setPosition({ lineNumber, column: startColumn }) editor.focus() + return true } const handleEditorMount: OnMount = (editor) => { editorRef.current = editor - if (pendingReveal) { - revealSearchMatch(pendingReveal.lineNumber, pendingReveal.query) - setPendingReveal(null) - } } - const startTerminalResize = (event: React.MouseEvent): void => { - event.preventDefault() - const startY = event.clientY - const startHeight = terminalPanelHeight - - const relayoutWorkspace = (): void => { - terminalFitRef.current?.fit() - layoutEditorToPane() - } - const onMouseMove = (moveEvent: MouseEvent): void => { - const nextHeight = Math.max(150, Math.min(460, startHeight - (moveEvent.clientY - startY))) - setTerminalPanelHeight(nextHeight) - window.requestAnimationFrame(relayoutWorkspace) - } - const onMouseUp = (): void => { - document.removeEventListener('mousemove', onMouseMove) - document.removeEventListener('mouseup', onMouseUp) - window.requestAnimationFrame(relayoutWorkspace) - } - - document.addEventListener('mousemove', onMouseMove) - document.addEventListener('mouseup', onMouseUp) - } - useEffect(() => { const handleEditorShortcut = (event: KeyboardEvent): void => { if (!(event.ctrlKey || event.metaKey)) return @@ -3413,201 +1832,26 @@ function LocalPcWorkspaceMockup({ void refreshVisualizationStatus() }, [previewOpen]) - useEffect(() => { - if (!tabContextMenu) return - - const closeOnOutsideClick = (event: MouseEvent): void => { - if (!tabContextMenuRef.current?.contains(event.target as Node)) { - setTabContextMenu(null) - } - } - - document.addEventListener('mousedown', closeOnOutsideClick) - return () => document.removeEventListener('mousedown', closeOnOutsideClick) - }, [tabContextMenu]) + useWorkspaceContextMenus({ + tabOpen: Boolean(tabContextMenu), + tabRef: tabContextMenuRef, + closeTab: () => setTabContextMenu(null), + moduleTabOpen: Boolean(moduleTabContextMenu), + moduleTabRef: moduleTabContextMenuRef, + closeModuleTab: () => setModuleTabContextMenu(null), + treeOpen: Boolean(treeContextMenu), + treeRef: treeContextMenuRef, + closeTree: () => setTreeContextMenu(null), + terminalOpen: Boolean(terminalContextMenu), + terminalRef: terminalContextMenuRef, + closeTerminal: () => setTerminalContextMenu(null), + gitFileOpen: Boolean(gitFileContextMenu), + gitFileRef: gitFileContextMenuRef, + closeGitFile: () => setGitFileContextMenu(null) + }) useEffect(() => { - if (!moduleTabContextMenu) return - - const closeOnOutsideClick = (event: MouseEvent): void => { - if (!moduleTabContextMenuRef.current?.contains(event.target as Node)) { - setModuleTabContextMenu(null) - } - } - - document.addEventListener('mousedown', closeOnOutsideClick) - return () => document.removeEventListener('mousedown', closeOnOutsideClick) - }, [moduleTabContextMenu]) - - useEffect(() => { - if (!treeContextMenu) return - - const closeOnOutsideClick = (event: MouseEvent): void => { - if (!treeContextMenuRef.current?.contains(event.target as Node)) { - setTreeContextMenu(null) - } - } - - document.addEventListener('mousedown', closeOnOutsideClick) - return () => document.removeEventListener('mousedown', closeOnOutsideClick) - }, [treeContextMenu]) - - useEffect(() => { - if (!terminalContextMenu) return - - const closeOnOutsideClick = (event: MouseEvent): void => { - if (!terminalContextMenuRef.current?.contains(event.target as Node)) { - setTerminalContextMenu(null) - } - } - - document.addEventListener('mousedown', closeOnOutsideClick) - return () => document.removeEventListener('mousedown', closeOnOutsideClick) - }, [terminalContextMenu]) - - useEffect(() => { - if (!gitFileContextMenu) return - - const closeOnOutsideClick = (event: MouseEvent): void => { - if (!gitFileContextMenuRef.current?.contains(event.target as Node)) { - setGitFileContextMenu(null) - } - } - - document.addEventListener('mousedown', closeOnOutsideClick) - return () => document.removeEventListener('mousedown', closeOnOutsideClick) - }, [gitFileContextMenu]) - - useEffect(() => { - if (!pendingReveal || !editorRef.current) return - revealSearchMatch(pendingReveal.lineNumber, pendingReveal.query) - setPendingReveal(null) - }, [activeEditorFile, pendingReveal]) - - useEffect(() => { - if (searchActive) { - window.setTimeout(() => moduleSearchInputRef.current?.focus(), 0) - } - }, [searchActive]) - - useEffect(() => { - const timer = window.setTimeout(() => { - setDebouncedModuleSearchQuery(moduleSearchQuery) - }, 350) - - return () => window.clearTimeout(timer) - }, [moduleSearchQuery]) - - useEffect(() => { - const handleSearchShortcut = (event: KeyboardEvent): void => { - if (!selectedModule || event.key.toLowerCase() !== 'f' || !event.ctrlKey || !event.shiftKey) return - event.preventDefault() - setActiveIdeActivity('search') - window.setTimeout(() => moduleSearchInputRef.current?.focus(), 0) - } - - window.addEventListener('keydown', handleSearchShortcut) - return () => window.removeEventListener('keydown', handleSearchShortcut) - }, [selectedModule]) - - useEffect(() => { - const moduleName = selectedModule?.name - if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return - const terminalSessionId = getModuleTerminalSessionId(moduleName) - - const term = new XTerm({ - cursorBlink: true, - convertEol: true, - fontFamily: 'JetBrains Mono, Consolas, Monaco, monospace', - fontSize: 11, - lineHeight: 1.25, - theme: { - background: dark ? '#171717' : '#f9fafc', - foreground: dark ? 'rgba(255,255,255,0.72)' : '#2f3747', - cursor: '#93c5fd', - selectionBackground: 'rgba(59,130,246,0.28)' - } - }) - const fitAddon = new FitAddon() - term.loadAddon(fitAddon) - term.open(terminalHostRef.current) - fitAddon.fit() - const terminalElement = terminalHostRef.current - const handleTerminalContextMenu = (event: MouseEvent): void => { - const selection = term.getSelection().trim() - if (!selection) return - event.preventDefault() - setTerminalContextMenu({ - text: selection, - x: event.clientX, - y: event.clientY - }) - } - terminalElement.addEventListener('contextmenu', handleTerminalContextMenu) - let terminalReceivedVisibleData = Boolean(terminalOutputBufferRef.current[terminalSessionId]) - if (terminalOutputBufferRef.current[terminalSessionId]) { - term.write(terminalOutputBufferRef.current[terminalSessionId]) - } - terminalRef.current = term - terminalFitRef.current = fitAddon - - const terminalApi = window.localWorkspace.terminal - const removeDataListener = terminalApi.onData((data) => { - const suppressQueue = terminalSuppressQueueRef.current - if (suppressQueue.length && suppressQueue[0] === data) { - suppressQueue.shift() - return - } - - const nextData = stripInternalTerminalCommands(data) - if (nextData) { - terminalReceivedVisibleData = true - terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${nextData}`.slice(-60000) - term.write(nextData) - } - }, terminalSessionId) - const removeSuppressListener = terminalApi.onSuppressData((data) => { - terminalSuppressQueueRef.current.push(data) - }, terminalSessionId) - const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId)) - - let fallbackPromptTimer: number | null = null - const startTerminal = window.localWorkspace.terminal.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, selectedModule?.folderName) - void startTerminal.then((started) => { - fitAddon.fit() - if (!started) return - fallbackPromptTimer = window.setTimeout(() => { - if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return - if (terminalReceivedVisibleData || terminalOutputBufferRef.current[terminalSessionId]) return - const activeLine = term.buffer.active.getLine(term.buffer.active.cursorY)?.translateToString(true).trim() - if (activeLine || term.buffer.active.baseY > 0 || term.buffer.active.cursorX > 0) return - const fallbackPrompt = `${moduleName.replace(/[^a-zA-Z0-9_.-]+/g, '-')}: ` - terminalReceivedVisibleData = true - terminalFallbackPromptShownRef.current.add(terminalSessionId) - terminalOutputBufferRef.current[terminalSessionId] = fallbackPrompt - term.write(fallbackPrompt) - }, 500) - }) - - const onResize = (): void => fitAddon.fit() - window.addEventListener('resize', onResize) - - return () => { - window.removeEventListener('resize', onResize) - terminalElement.removeEventListener('contextmenu', handleTerminalContextMenu) - removeDataListener() - removeSuppressListener() - inputDisposable.dispose() - if (fallbackPromptTimer !== null) window.clearTimeout(fallbackPromptTimer) - term.dispose() - terminalRef.current = null - terminalFitRef.current = null - terminalSuppressQueueRef.current = [] - } - }, [clonePathPreview, dark, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule?.folderName, selectedModule?.name, terminalActive]) - - useEffect(() => { - terminalFitRef.current?.fit() + fitTerminal() layoutEditorToPane() }, [leftPanelWidth, rightPanelWidth, projectTreeWidth, leftPanelMinimized, rightPanelMinimized, terminalPanelHeight]) useEffect(() => { @@ -3619,7 +1863,7 @@ function LocalPcWorkspaceMockup({ window.cancelAnimationFrame(frame) frame = window.requestAnimationFrame(() => { layoutEditorToPane() - terminalFitRef.current?.fit() + fitTerminal() }) } const observer = new ResizeObserver(relayout) @@ -3632,9 +1876,6 @@ function LocalPcWorkspaceMockup({ } }, [currentActiveEditorFile, terminalActive]) - const toggleProjectFolder = (path: string): void => { - setOpenProjectFolders((folders) => ({ ...folders, [path]: !folders[path] })) - } const projectTreeItems: Array< { kind: 'folder'; name: string; path: string; parent?: string; depth: number } | { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number } > = [ @@ -3677,8 +1918,20 @@ function LocalPcWorkspaceMockup({ ] const workspaceRootPath = exampleActive ? 'examples/example-code' : selectedModule ? `modules/${selectedModule.name}` : 'lms' const workspaceRootLabel = exampleActive ? 'example-code' : selectedModule ? selectedModule.name : 'lms' - const moduleLogEntries = selectedModule ? parseModuleLogEntries(selectedModule, moduleLogText) : [] - const visibleModuleLogEntries = moduleLogEntries.slice(-maxVisibleModuleLogEntries) + const { + moduleLogsLoading, + moduleLogMessage, + visibleModuleLogEntries, + moduleLogScrollRef, + updateModuleLogStickToBottom + } = useWorkspaceModuleLogs({ + selectedModule, + logActive, + clonePathPreview, + remoteWorkspace, + remoteWorkspaceTargetKey, + getRemoteWorkspaceTarget + }) const moduleFilePaths = exampleActive ? Object.keys(exampleCodeFiles) : selectedModule @@ -3687,119 +1940,35 @@ function LocalPcWorkspaceMockup({ ...Object.keys(editorFiles).filter((file) => file.startsWith(`modules/${selectedModule.name}/`)) ])) : [] - const moduleFilePathKey = moduleFilePaths.join('\n') - - const updateModuleLogStickToBottom = (): void => { - const container = moduleLogScrollRef.current - if (!container) return - const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight - moduleLogStickToBottomRef.current = distanceToBottom < 24 - } + const { + moduleSearchInputRef, + moduleSearchQuery, + setModuleSearchQuery, + activeModuleSearchQuery, + moduleSearchRunning, + moduleSearchResults, + openSearchResult + } = useWorkspaceSearch({ + searchActive, + selectedModule, + editorFiles, + moduleFilePaths, + activeEditorFile, + onOpenEditorFileAtLine: openEditorFileAtLine, + onRevealSearchMatch: revealSearchMatch + }) useEffect(() => { - if (!logActive) return - moduleLogStickToBottomRef.current = true - }, [logActive, selectedModule?.name]) - - useEffect(() => { - if (!logActive || !moduleLogStickToBottomRef.current) return - const container = moduleLogScrollRef.current - if (!container) return - - window.requestAnimationFrame(() => { - container.scrollTop = container.scrollHeight - }) - }, [logActive, moduleLogText, moduleLogsLoading]) - - useEffect(() => { - if (!logActive || !selectedModule) { - setModuleLogText('') - setModuleLogMessage(null) - setModuleLogsLoading(false) - return + const handleSearchShortcut = (event: KeyboardEvent): void => { + if (!selectedModule || event.key.toLowerCase() !== 'f' || !event.ctrlKey || !event.shiftKey) return + event.preventDefault() + setActiveIdeActivity('search') + window.setTimeout(() => moduleSearchInputRef.current?.focus(), 0) } - let cancelled = false - const loadLogs = async (quiet = false): Promise => { - if (!quiet) setModuleLogsLoading(true) - try { - const result = remoteWorkspace - ? await window.remoteWorkspace.modules.logs(getRemoteWorkspaceTarget(), selectedModule.name, selectedModule.folderName) - : await window.localWorkspace.modules.logs(selectedModule.name, clonePathPreview, selectedModule.folderName) - if (cancelled) return - setModuleLogText(result.logs) - setModuleLogMessage(result.ok ? (result.message ?? null) : (result.message ?? '로그를 불러오지 못했습니다.')) - } catch (error) { - if (cancelled) return - setModuleLogText('') - setModuleLogMessage(error instanceof Error ? error.message : '로그를 불러오지 못했습니다.') - } finally { - if (!cancelled) setModuleLogsLoading(false) - } - } - - void loadLogs(false) - const intervalId = selectedModule.state === 'running' - ? window.setInterval(() => { - void loadLogs(true) - }, 5000) - : null - - return () => { - cancelled = true - if (intervalId !== null) window.clearInterval(intervalId) - } - }, [logActive, selectedModule?.name, selectedModule?.folderName, selectedModule?.state, clonePathPreview, remoteWorkspace]) - - useEffect(() => { - const query = debouncedModuleSearchQuery.trim() - if (!searchActive || !selectedModule || query.length < 2) { - setModuleSearchResults([]) - setModuleSearchRunning(false) - return - } - - let cancelled = false - let fileIndex = 0 - const normalizedQuery = query.toLowerCase() - const nextResults: Array<{ file: string; lineNumber: number; lineText: string }> = [] - setModuleSearchResults([]) - setModuleSearchRunning(true) - - const searchChunk = (): void => { - const chunkEnd = Math.min(moduleFilePaths.length, fileIndex + 12) - - for (; fileIndex < chunkEnd; fileIndex += 1) { - const file = moduleFilePaths[fileIndex] - const content = editorFiles[file] ?? '' - const lines = content.split('\n') - - for (let index = 0; index < lines.length; index += 1) { - const line = lines[index] - if (!line.toLowerCase().includes(normalizedQuery)) continue - nextResults.push({ file, lineNumber: index + 1, lineText: line.trim() || line }) - if (nextResults.length >= 200) break - } - - if (nextResults.length >= 200) break - } - - if (cancelled) return - - setModuleSearchResults([...nextResults]) - if (fileIndex < moduleFilePaths.length && nextResults.length < 200) { - window.setTimeout(searchChunk, 0) - } else { - setModuleSearchRunning(false) - } - } - - const timer = window.setTimeout(searchChunk, 0) - return () => { - cancelled = true - window.clearTimeout(timer) - } - }, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule]) + window.addEventListener('keydown', handleSearchShortcut) + return () => window.removeEventListener('keydown', handleSearchShortcut) + }, [moduleSearchInputRef, selectedModule]) const workspaceTreeItems = exampleActive ? buildPathTreeItems('examples/example-code', Object.keys(exampleCodeFiles), exampleCodeFolders) @@ -3809,7 +1978,6 @@ function LocalPcWorkspaceMockup({ ...createdModuleFolders.filter((folder) => folder.startsWith(`modules/${selectedModule.name}/`)) ]) : projectTreeItems - const activeModuleSearchQuery = debouncedModuleSearchQuery.trim() const visibleProjectTreeItems = workspaceTreeItems.filter((item) => { if (!item.parent) return true const parentParts = item.parent.split('/') @@ -3822,24 +1990,6 @@ function LocalPcWorkspaceMockup({ Object.prototype.hasOwnProperty.call(loadedModuleRoots, selectedModule.name) )) const moduleFileTreeLoading = Boolean(selectedModule && !exampleActive && moduleFileLoadingByName[selectedModule.name] && !selectedModuleTreeLoaded) - const gitChangedModules = installedGitModules.filter((module) => gitModuleStatuses[module.name]?.files.length) - const gitChangedFileCount = gitChangedModules.reduce((count, module) => count + (gitModuleStatuses[module.name]?.files.length ?? 0), 0) - const selectedGitStatus = selectedGitTarget ? gitModuleStatuses[selectedGitTarget.moduleName] : null - const selectedGitFile = selectedGitTarget - ? selectedGitStatus?.files.find((file) => file.path === selectedGitTarget.filePath) ?? null - : null - const gitSubmissionGroups = gitSubmissions.reduce>((groups, submission) => { - const existing = groups.find((group) => group.id === submission.id) - if (existing) { - existing.submissions.push(submission) - if (new Date(submission.submittedAt).getTime() > new Date(existing.submittedAt).getTime()) { - existing.submittedAt = submission.submittedAt - } - return groups - } - groups.push({ id: submission.id, submittedAt: submission.submittedAt, submissions: [submission] }) - return groups - }, []).sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime()) const getGitStatusLabel = (status: GitChangedFile['status']): string => { if (status === 'added') return '추가' if (status === 'deleted') return '삭제' @@ -3924,7 +2074,7 @@ function LocalPcWorkspaceMockup({ previewOpen={previewOpen} showControlCountdown={showControlCountdown} controlCountdownDanger={Boolean(controlCountdownDanger)} - controlEndsAt={controlEndsAt} + controlEndsAt={controlEndsAt ?? null} submitVisible={curriculumWeek.state !== 'done'} submitDisabled={gitSubmitting || ((curriculumWeek.detail.requiresSourceSubmission ?? true) && installedGitModules.length === 0)} submitting={gitSubmitting} @@ -4052,7 +2202,7 @@ function LocalPcWorkspaceMockup({ className="grid h-[calc(100%-40px)]" style={{ gridTemplateColumns: `${leftPanelMinimized ? 0 : leftPanelWidth}px ${leftPanelMinimized ? 0 : 10}px 48px ${workspaceActive ? projectTreeWidth : 0}px ${workspaceActive ? 6 : 0}px minmax(0,1fr) ${rightPanelMinimized ? 0 : 10}px ${rightPanelMinimized ? 0 : rightPanelWidth}px`, - gridTemplateRows: workspaceActive ? (gitActive ? '61px minmax(0,1fr)' : (exampleActive || settingsActive || !selectedModule ? '69px minmax(0,1fr)' : '38px minmax(0,1fr)')) : 'minmax(0,1fr)' + gridTemplateRows: workspaceActive ? (gitActive ? '69px minmax(0,1fr)' : (exampleActive || settingsActive || !selectedModule ? '69px minmax(0,1fr)' : '38px minmax(0,1fr)')) : 'minmax(0,1fr)' }} > { if (exampleActive) setSelectedExampleTreePath(workspaceRootPath) else setSelectedTreePath(workspaceRootPath) @@ -4378,6 +2528,7 @@ function LocalPcWorkspaceMockup({ menu={terminalContextMenu} menuRef={terminalContextMenuRef} onCopy={copyTerminalSelection} + onPaste={pasteTerminalClipboard} /> { authSessionRef.current = null - window.sessionStorage.removeItem(LMS_AUTH_SESSION_STORAGE_KEY) + clearStoredAuthSession() setAuthSession(null) }, []) useEffect(() => { @@ -4789,9 +2940,7 @@ export default function App(): React.JSX.Element { if (!authReady || !authSession?.accessToken) return try { - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`) - const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null - const courses = payload?.courses ?? [] + const courses = await fetchStudentCourses(fetchWithFreshAuth) setStudentCourses(courses) setStudentCoursesLoaded(true) @@ -4832,13 +2981,9 @@ export default function App(): React.JSX.Element { const loadCurriculum = useCallback(async (): Promise => { if (!authReady || !studentCoursesLoaded || courseSelectionVisible || !selectedCourseId) return - const params = new URLSearchParams() - if (selectedCourseId) params.set('courseId', selectedCourseId) - const query = params.toString() ? `?${params.toString()}` : '' try { - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum${query}`) - const payload = response.ok ? await response.json() as CurriculumResponse : null + const payload = await fetchCurriculum(fetchWithFreshAuth, selectedCourseId) const nextWeeks = payload?.weeks ?? [] const serverActiveWeek = payload?.activeWeek ?? null const previousWeekStates = previousWeekStateRef.current @@ -4905,17 +3050,9 @@ export default function App(): React.JSX.Element { let cancelled = false const refreshPlatformStatuses = async () => { try { - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/statuses`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ platformIds: remotePlatformIds }) - }, { invalidateOnRefreshFailure: false }) - if (!response.ok) return - const payload = await response.json() as { platforms?: Array & { id: string }> } - if (cancelled || !payload.platforms?.length) return - const statusById = new Map(payload.platforms.map((platform) => [platform.id, platform])) + const platforms = await fetchPlatformStatuses(fetchWithFreshAuth, remotePlatformIds) + if (cancelled || !platforms.length) return + const statusById = new Map(platforms.map((platform) => [platform.id, platform])) const mergePlatformStatus = (platform: PlatformOption): PlatformOption => { const status = statusById.get(platform.id) return status ? { ...platform, ...status } : platform @@ -5060,18 +3197,6 @@ export default function App(): React.JSX.Element { available: platformOptions.filter((platform) => platform.state === 'ready').length, unavailable: platformOptions.filter((platform) => platform.state !== 'ready').length } - const isPlatformUnavailable = (platform: PlatformOption): boolean => ( - platform.state === 'locked' || - platform.action === '연결 불가' || - platform.status.includes('연결 불가') || - platform.status.includes('연결 끊김') || - (platform.connection ? !platform.connection.headscaleOnline || !platform.connection.agentHealthy : false) - ) - const getPlatformDotClass = (platform: PlatformOption): string => { - if (isPlatformUnavailable(platform)) return 'bg-[#ef4444] shadow-[0_0_6px_rgba(239,68,68,0.55)]' - if (platform.state === 'reserved' || platform.control?.status === 'QUEUED') return 'bg-[#f59e0b] shadow-[0_0_6px_rgba(245,158,11,0.55)]' - return 'bg-[#2fbda0] shadow-[0_0_6px_rgba(47,189,160,0.55)]' - } const localPcPlatform = platformOptions.find((platform) => platform.platformKey === 'futuredrive-local-pc') const openPlatformSettings = (platform: PlatformOption): void => { const defaults = normalizeStudioSourceSettings({ @@ -5433,10 +3558,7 @@ export default function App(): React.JSX.Element { await stopConnectedWorkspaceModules() await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' })) if (authSession?.accessToken) { - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformToRelease.id)}/release`, { - method: 'POST', - }) - if (!response.ok) throw new Error('platform-release failed') + await releasePlatformControl(fetchWithFreshAuth, platformToRelease.id) released = true setCurriculumRefreshSignal((current) => current + 1) } @@ -5462,9 +3584,7 @@ export default function App(): React.JSX.Element { const expireBackgroundPlatformControl = async (platform: PlatformOption): Promise => { await stopRemotePlatformModules(platform) if (authSession?.accessToken) { - await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platform.id)}/release`, { - method: 'POST', - }).catch(() => null) + await releasePlatformControl(fetchWithFreshAuth, platform.id).catch(() => null) } removePlatformSession(platform, { clearWorkspace: true }) setControlWarningNotice(null) @@ -5620,11 +3740,7 @@ export default function App(): React.JSX.Element { if (platform.control?.status !== 'ACTIVE') return platform if (isPlatformUnavailable(platform)) return platform - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platform.id)}/connect`, { - method: 'POST', - }) - if (!response.ok) throw new Error('platform-connect failed') - const payload = await response.json() as { environmentConfigOverride?: unknown } + const payload = await connectPlatformControl(fetchWithFreshAuth, platform.id) return mergePlatformEnvironmentOverride(platform, payload.environmentConfigOverride) } @@ -5744,10 +3860,6 @@ export default function App(): React.JSX.Element { if (!authSession?.accessToken) return const controlStatus = platform.control?.status - const method = controlStatus === 'QUEUED' ? 'DELETE' : 'POST' - const suffix = controlStatus === 'QUEUED' - ? 'queue' - : 'request' const copy = controlStatus === 'QUEUED' ? { title: `${platform.title} 대기 예약 취소 중`, @@ -5763,16 +3875,13 @@ export default function App(): React.JSX.Element { setPlatformLoading(true) let shouldWaitForControlPaint = false try { - const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platform.id)}/${suffix}`, { - method, - }) - if (!response.ok) throw new Error('platform-control failed') - const payload = await response.json().catch(() => null) as PlatformControlActionResponse | null + const result = await requestOrCancelPlatformControl(fetchWithFreshAuth, platform) + const payload = result.payload if (payload) { applyPlatformControlResponse(platform.id, payload) shouldWaitForControlPaint = true } - if (method === 'DELETE') { + if (result.method === 'DELETE') { showTitlebarNotice(`${platform.title} 대기 예약이 취소되었습니다`, 'info', true) } else if (payload?.result === 'GRANTED' || payload?.result === 'ALREADY_GRANTED') { showTitlebarNotice(`${platform.title} 제어권이 부여되었습니다`, 'info', true) diff --git a/src/renderer/src/config/appConfig.ts b/src/renderer/src/config/appConfig.ts new file mode 100644 index 0000000..b91cf46 --- /dev/null +++ b/src/renderer/src/config/appConfig.ts @@ -0,0 +1,17 @@ +export const LMS_API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:4400/api').replace(/\/+$/, '') +export const LMS_SOCKET_BASE_URL = LMS_API_BASE_URL.replace(/\/api$/, '') +export const LAUNCHER_API_BASE_URL = (import.meta.env.VITE_LAUNCHER_API_BASE_URL || 'http://localhost:4200/api').replace(/\/+$/, '') + +export const SELECTED_COURSE_STORAGE_KEY = 'lms-selected-course-id' +export const LMS_AUTH_SESSION_STORAGE_KEY = 'autonomy-studio.lms.auth-session' +export const STUDIO_SOURCE_SETTINGS_STORAGE_KEY = 'autonomy-studio.lms.source-settings' +export const STUDIO_RUNTIME_SESSION_STORAGE_KEY = 'autonomy-studio.lms.runtime-session' +export const STUDIO_MODULE_PARAMETERS_STORAGE_KEY = 'autonomy-studio.lms.module-parameters' + +export function launcherApiUrl(path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + if (normalizedPath.startsWith('/api/')) { + return `${LAUNCHER_API_BASE_URL.replace(/\/api$/, '')}${normalizedPath}` + } + return `${LAUNCHER_API_BASE_URL}${normalizedPath}` +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 628720f..e264632 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -9,6 +9,28 @@ interface ImportMeta { readonly env: ImportMetaEnv } +type ModulePrebuiltArtifact = { + type?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + extractPath?: string | null + required?: boolean | null +} + +type RuntimeImageArtifact = { + image?: string | null + url?: string | null + fileName?: string | null + checksumSha256?: string | null + required?: boolean | null +} + +type ModuleInstallOptions = { + prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + runtimeImages?: RuntimeImageArtifact[] | null +} + interface Window { launcherAuth?: { appUrl: string | null @@ -37,7 +59,7 @@ interface Window { delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> } modules: { - list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => Promise<{ + list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => Promise<{ cacheRoot: string workspaceRoot: string | null modules: Array<{ @@ -55,7 +77,7 @@ interface Window { runtime: string }> }> - install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => Promise<{ + install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{ ok: boolean module: { state: 'running' | 'stopped' | 'uninstalled' @@ -73,11 +95,11 @@ interface Window { } }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> - start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }> + start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> stop: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }> stopRunning: () => Promise<{ ok: boolean; message: string }> - restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }> + restart: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> logs: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> updateParameters: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string) => Promise<{ ok: boolean; parameters: string[] }> } @@ -194,8 +216,8 @@ interface Window { delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType } modules: { - list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ReturnType - install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => Promise<{ + list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType + install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{ ok: boolean module: { state: 'running' | 'stopped' | 'uninstalled' @@ -212,11 +234,11 @@ interface Window { runtime: string } }> - start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }> + start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }> stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }> - restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => Promise<{ ok: boolean; message: string }> + restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }> } git: { diff --git a/src/renderer/src/features/auth/authSession.ts b/src/renderer/src/features/auth/authSession.ts new file mode 100644 index 0000000..8097583 --- /dev/null +++ b/src/renderer/src/features/auth/authSession.ts @@ -0,0 +1,125 @@ +import { LMS_API_BASE_URL, LMS_AUTH_SESSION_STORAGE_KEY } from '../../config/appConfig' + +export type LmsAuthSession = { + accessToken: string + accessTokenExpiresIn?: number + refreshToken?: string + refreshTokenExpiresIn?: number + tokenType: 'Bearer' + user: { + id: string + email: string + name: string | null + role: string + roles?: string[] + primaryAffiliation?: { + id: string + organizationId: string + organizationName: string + organizationType: string + department: string | null + position: string | null + } | null + currentLicense?: { + id: string + code: string + name: string + startsAt: string + expiresAt: string | null + } | null + } +} + +let handoffExchangePromise: Promise | null = null + +export function readStoredAuthSession(): LmsAuthSession | null { + try { + const rawSession = window.sessionStorage.getItem(LMS_AUTH_SESSION_STORAGE_KEY) + if (!rawSession) return null + + const session = JSON.parse(rawSession) as LmsAuthSession + return session?.accessToken ? session : null + } catch { + return null + } +} + +export function storeAuthSession(session: LmsAuthSession): void { + try { + window.sessionStorage.setItem(LMS_AUTH_SESSION_STORAGE_KEY, JSON.stringify(session)) + } catch { + // Session persistence is best-effort; the handoff flow can still continue without it. + } +} + +export function clearStoredAuthSession(): void { + window.sessionStorage.removeItem(LMS_AUTH_SESSION_STORAGE_KEY) +} + +export function exchangeLauncherHandoff(): Promise { + const storedSession = readStoredAuthSession() + if (storedSession) { + console.info('[lms-auth] stored session restored') + return Promise.resolve(storedSession) + } + + const handoffToken = window.launcherAuth?.handoffToken + const audienceApp = window.launcherAuth?.audienceApp ?? 'LMS' + + console.info('[lms-auth] launcher handoff state', { + hasHandoffToken: Boolean(handoffToken), + audienceApp + }) + + if (!handoffToken) return Promise.resolve(null) + if (handoffExchangePromise) return handoffExchangePromise + + handoffExchangePromise = fetch(`${LMS_API_BASE_URL}/auth/handoff/exchange`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ handoffToken, audienceApp }) + }) + .then(async (response) => { + if (!response.ok) { + const detail = await response.text().catch(() => '') + console.warn('[lms-auth] launcher handoff exchange failed', response.status, detail) + return null + } + + const session = await response.json() as LmsAuthSession + console.info('[lms-auth] launcher handoff exchange succeeded', { + userEmail: session.user?.email ?? null, + hasRefreshToken: Boolean(session.refreshToken) + }) + if (session?.accessToken) storeAuthSession(session) + return session + }) + .catch((error) => { + console.warn('[lms-auth] launcher handoff exchange error', error) + return null + }) + + return handoffExchangePromise +} + +export async function refreshStoredAuthSession(session: LmsAuthSession): Promise { + if (!session.refreshToken) return null + + const response = await fetch(`${LMS_API_BASE_URL}/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ refreshToken: session.refreshToken }) + }) + + if (!response.ok) return null + + const nextSession = await response.json() as LmsAuthSession + if (!nextSession?.accessToken) return null + + storeAuthSession(nextSession) + return nextSession +} diff --git a/src/renderer/src/features/curriculum/LearningInfoCard.tsx b/src/renderer/src/features/curriculum/LearningInfoCard.tsx index 0cbd227..c0c609a 100644 --- a/src/renderer/src/features/curriculum/LearningInfoCard.tsx +++ b/src/renderer/src/features/curriculum/LearningInfoCard.tsx @@ -31,7 +31,7 @@ export default function LearningInfoCard({ }: LearningInfoCardProps): React.JSX.Element { return (
- + 학습 정보 diff --git a/src/renderer/src/features/curriculum/curriculumApi.ts b/src/renderer/src/features/curriculum/curriculumApi.ts new file mode 100644 index 0000000..796bdea --- /dev/null +++ b/src/renderer/src/features/curriculum/curriculumApi.ts @@ -0,0 +1,47 @@ +import { LMS_API_BASE_URL, launcherApiUrl } from '../../config/appConfig' +import type { CurriculumResponse, DownloadResource, StudentCourseOption } from './types' + +type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise + +export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise { + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum/courses`) + const payload = response.ok ? await response.json() as { courses?: StudentCourseOption[] } : null + return payload?.courses ?? [] +} + +export async function fetchCurriculum(fetchWithFreshAuth: AuthFetch, courseId: string): Promise { + const params = new URLSearchParams() + if (courseId) params.set('courseId', courseId) + const query = params.toString() ? `?${params.toString()}` : '' + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/curriculum${query}`) + return response.ok ? await response.json() as CurriculumResponse : null +} + +export function resolveDownloadUrl(resource: DownloadResource): string { + if (/^https?:\/\//i.test(resource.downloadUrl)) return resource.downloadUrl + if (resource.downloadUrl.startsWith('/curriculum/')) return `${LMS_API_BASE_URL}${resource.downloadUrl}` + return launcherApiUrl(resource.downloadUrl) +} + +export function getDownloadResourceWeek(resource: DownloadResource): number | null { + const explicitWeek = + resource.week ?? + resource.weekNumber ?? + resource.metadata?.week ?? + resource.metadata?.weekNumber + const parsedExplicitWeek = Number(explicitWeek) + if (Number.isInteger(parsedExplicitWeek) && parsedExplicitWeek > 0) return parsedExplicitWeek + + const searchable = [resource.contextKey, resource.title, resource.fileName].filter(Boolean).join(' ') + const match = searchable.match(/(?:week|주차)[\s_-]*(\d+)|(\d+)[\s_-]*주차/i) + const parsedMatch = Number(match?.[1] ?? match?.[2]) + return Number.isInteger(parsedMatch) && parsedMatch > 0 ? parsedMatch : null +} + +export function getDownloadResourceSizeLabel(resource: DownloadResource): string { + const bytes = Number(resource.fileSizeBytes) + if (!Number.isFinite(bytes) || bytes <= 0) return resource.fileName + if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB` + if (bytes >= 1024) return `${Math.round(bytes / 1024)} KB` + return `${bytes} B` +} diff --git a/src/renderer/src/features/curriculum/types.ts b/src/renderer/src/features/curriculum/types.ts new file mode 100644 index 0000000..24012c3 --- /dev/null +++ b/src/renderer/src/features/curriculum/types.ts @@ -0,0 +1,88 @@ +import type { LearningStatItem } from './LearningStatsCard' +import type { EnvironmentProfile, PlatformOption } from '../platforms/types' +import type { ModulePrebuiltArtifact } from '../workspace/types' + +export type DownloadResource = { + id: string + appKey?: string + contextKey?: string + title: string + description?: string | null + fileName: string + contentType?: string | null + fileSizeBytes?: string | null + week?: number | string | null + weekNumber?: number | string | null + metadata?: { + week?: number | string | null + weekNumber?: number | string | null + } | null + downloadUrl: string + createdAt?: string +} + +export type LearningStat = LearningStatItem + +export type CurriculumWeek = { + week: number + title: string + tags: string[] + progress: number + state: 'done' | 'current' | 'locked' | 'ready' + topics: string[] + duration: string + github: string + detail: { + overview: string + topicDetails: string[] + topicVideoUrls?: string[] + practiceContents?: Array<{ + title: string + description: string + }> + references?: DownloadResource[] + exampleCodes?: DownloadResource[] + deliverable: string + requiresSourceSubmission: boolean + sourceSubmitted?: boolean + sourceSubmittedAt?: string | null + } + modules?: Array<{ + id: string + sortOrder: number + category: string + name: string + folderName: string + templateRepositoryUrl?: string | null + sourceType?: string | null + prebuiltArtifacts?: ModulePrebuiltArtifact[] | null + dockerImage?: string | null + }> + environmentProfiles?: EnvironmentProfile[] +} + +export type CurriculumResponse = { + course?: { + id: string + slug: string + title: string + subtitle?: string | null + description?: string | null + organizationName: string + teacher?: { + id: string + email: string + name: string | null + primaryOrganizationName?: string | null + } + } + weeks?: CurriculumWeek[] + activeWeek?: number | null + activeLearningStep?: string | null + learningStats?: LearningStat[] + platforms?: PlatformOption[] +} + +export type StudentCourseOption = NonNullable & { + progress?: number +} diff --git a/src/renderer/src/features/platforms/platformControl.ts b/src/renderer/src/features/platforms/platformControl.ts new file mode 100644 index 0000000..bcce189 --- /dev/null +++ b/src/renderer/src/features/platforms/platformControl.ts @@ -0,0 +1,91 @@ +import { LMS_API_BASE_URL } from '../../config/appConfig' +import type { PlatformControlActionResponse, PlatformOption } from './types' + +type AuthFetch = (input: RequestInfo | URL, init?: RequestInit, options?: { invalidateOnRefreshFailure?: boolean }) => Promise + +export type PlatformControlWarningPayload = { + type: 'platform.control.warning' + sessionId: string + platformId?: string | null + instructorPlatformId?: string | null + endsAt: string + warningAt: string +} + +export type PlatformControlGrantedPayload = { + type: 'platform.control.granted' + sessionId: string + startedAt: string + endsAt: string + warningAt: string +} + +export type PlatformControlTerminatedPayload = { + type: 'platform.control.terminated' + sessionId: string + platformId?: string | null + instructorPlatformId?: string | null + reason: string + message: string + endedAt: string +} + +export function isPlatformUnavailable(platform: PlatformOption): boolean { + return ( + platform.state === 'locked' || + platform.action === '연결 불가' || + platform.status.includes('연결 불가') || + platform.status.includes('연결 끊김') || + (platform.connection ? !platform.connection.headscaleOnline || !platform.connection.agentHealthy : false) + ) +} + +export function getPlatformDotClass(platform: PlatformOption): string { + if (isPlatformUnavailable(platform)) return 'bg-[#ef4444] shadow-[0_0_6px_rgba(239,68,68,0.55)]' + if (platform.state === 'reserved' || platform.control?.status === 'QUEUED') return 'bg-[#f59e0b] shadow-[0_0_6px_rgba(245,158,11,0.55)]' + return 'bg-[#2fbda0] shadow-[0_0_6px_rgba(47,189,160,0.55)]' +} + +export async function fetchPlatformStatuses(fetchWithFreshAuth: AuthFetch, platformIds: string[]): Promise & { id: string }>> { + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/statuses`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ platformIds }) + }, { invalidateOnRefreshFailure: false }) + + if (!response.ok) return [] + const payload = await response.json() as { platforms?: Array & { id: string }> } + return payload.platforms ?? [] +} + +export async function connectPlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise<{ environmentConfigOverride?: unknown }> { + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/connect`, { + method: 'POST', + }) + if (!response.ok) throw new Error('platform-connect failed') + return response.json() as Promise<{ environmentConfigOverride?: unknown }> +} + +export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, platformId: string): Promise { + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/release`, { + method: 'POST', + }) + if (!response.ok) throw new Error('platform-release failed') +} + +export async function requestOrCancelPlatformControl( + fetchWithFreshAuth: AuthFetch, + platform: PlatformOption +): Promise<{ method: 'POST' | 'DELETE'; payload: PlatformControlActionResponse | null }> { + const controlStatus = platform.control?.status + const method = controlStatus === 'QUEUED' ? 'DELETE' : 'POST' + const suffix = controlStatus === 'QUEUED' ? 'queue' : 'request' + const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platform.id)}/${suffix}`, { + method, + }) + if (!response.ok) throw new Error('platform-control failed') + const payload = await response.json().catch(() => null) as PlatformControlActionResponse | null + return { method, payload } +} diff --git a/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx b/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx index fed8855..1436a36 100644 --- a/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx +++ b/src/renderer/src/features/workspace/WorkspaceActivityHeader.tsx @@ -4,7 +4,7 @@ type WorkspaceActivityHeaderProps = { dark: boolean textStrong: string mode: 'settings' | 'examples' | 'git' | 'empty' | 'module' - gitPanelTab: 'changes' | 'history' + gitPanelTab: 'source' | 'history' gitSubmissionCount: number gitChangedFileCount: number children?: React.ReactNode diff --git a/src/renderer/src/features/workspace/WorkspaceModuleTabs.tsx b/src/renderer/src/features/workspace/WorkspaceModuleTabs.tsx index 6922a8c..12c8a2d 100644 --- a/src/renderer/src/features/workspace/WorkspaceModuleTabs.tsx +++ b/src/renderer/src/features/workspace/WorkspaceModuleTabs.tsx @@ -7,13 +7,15 @@ type WorkspaceModuleTab = { state: 'running' | 'stopped' | 'uninstalled' } +type WorkspaceModuleTabActivity = Extract + type WorkspaceModuleTabsProps = { dark: boolean modules: WorkspaceModuleTab[] selectedModuleName?: string | null activeActivity: WorkspaceActivity controllingModuleNames: string[] - onActivate: (module: WorkspaceModuleTab, activity: WorkspaceActivity) => void + onActivate: (module: WorkspaceModuleTab, activity: WorkspaceModuleTabActivity) => void onClose: (moduleName: string) => void onOpenContextMenu: (moduleName: string, x: number, y: number) => void } diff --git a/src/renderer/src/features/workspace/WorkspaceNav.tsx b/src/renderer/src/features/workspace/WorkspaceNav.tsx index cc96295..a0bb1f0 100644 --- a/src/renderer/src/features/workspace/WorkspaceNav.tsx +++ b/src/renderer/src/features/workspace/WorkspaceNav.tsx @@ -48,7 +48,7 @@ export default function WorkspaceNav({ return (