This commit is contained in:
chyoo
2026-06-30 17:07:53 +09:00
parent 87554e27a1
commit d05ca7582c
37 changed files with 3839 additions and 2282 deletions
+324 -23
View File
@@ -1,11 +1,14 @@
import { app, BrowserWindow, globalShortcut, ipcMain, screen } from 'electron' import { app, BrowserWindow, globalShortcut, ipcMain, screen } from 'electron'
import type { WebContents } from 'electron' import type { WebContents } from 'electron'
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process' 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 { cp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promises'
import { homedir } from 'os' import { homedir } from 'os'
import { basename, dirname, join, relative, resolve } from 'path' import { basename, dirname, join, relative, resolve } from 'path'
import { is } from '@electron-toolkit/utils' import { is } from '@electron-toolkit/utils'
import { createHash } from 'crypto'
import http from 'http'
import https from 'https'
let mainWindow: BrowserWindow | null = null let mainWindow: BrowserWindow | null = null
let splashWindow: BrowserWindow | null = null let splashWindow: BrowserWindow | null = null
@@ -13,6 +16,7 @@ const terminalProcesses = new Map<string, ChildProcessWithoutNullStreams>()
const terminalStopTimers = new Map<string, NodeJS.Timeout>() const terminalStopTimers = new Map<string, NodeJS.Timeout>()
const intentionalTerminalStops = new Set<string>() const intentionalTerminalStops = new Set<string>()
const moduleCacheRoot = resolve(homedir(), '.caches') const moduleCacheRoot = resolve(homedir(), '.caches')
const artifactCacheRoot = resolve(homedir(), '.cache', 'autonomy-studio', 'artifacts')
const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime') const studioRuntimeRoot = resolve(homedir(), 'autonomy-studio', 'runtime')
const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda' const autowareDockerImage = 'ghcr.io/autowarefoundation/autoware-universe:humble-20240301-cuda'
const autowareRosBridgeImage = 'autoware-ros-bridge' const autowareRosBridgeImage = 'autoware-ros-bridge'
@@ -83,6 +87,8 @@ type CachedModuleInfo = {
sourcePath: string sourcePath: string
workspacePath: string | null workspacePath: string | null
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
@@ -95,6 +101,30 @@ type CourseModuleInput = {
name: string name: string
folderName: string folderName: string
templateRepositoryUrl?: string | null 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 = { type RemoteWorkspaceTarget = {
@@ -144,6 +174,7 @@ type EnvironmentProfileConfig = {
logFile?: string logFile?: string
}> }>
} }
runtimeImages?: RuntimeImageArtifact[] | null
bridge?: { bridge?: {
name?: string name?: string
image?: string image?: string
@@ -337,6 +368,8 @@ async function collectAvailableModules(
sourcePath, sourcePath,
workspacePath, workspacePath,
templateRepositoryUrl: module.templateRepositoryUrl ?? null, templateRepositoryUrl: module.templateRepositoryUrl ?? null,
sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
parameters: splitRosParams(rosParams), parameters: splitRosParams(rosParams),
cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null, cpu: dockerStatus?.running ? dockerStat?.cpu ?? null : null,
memory: dockerStatus?.running ? dockerStat?.memory ?? 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) 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<void> {
if (redirects > 5) throw new Error('artifact redirect limit exceeded')
await mkdir(dirname(targetPath), { recursive: true })
await new Promise<void>((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<string | null> {
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<void> {
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<void> {
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<void> {
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 { function defaultSshIdentityFile(): string {
return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519') return join(homedir(), '.autonomy-studio', 'ssh', 'id_ed25519')
} }
@@ -1301,11 +1448,55 @@ function visualizationBridgeContainerName(stack: VisualizationStack): string {
} }
function visualizationBridgeImageName(stack: VisualizationStack): string { function visualizationBridgeImageName(stack: VisualizationStack): string {
if (stack === 'autoware') return autowareRosBridgeImage if (stack === 'autoware') return autowareDockerImage
if (stack === 'nav2') return nav2RosBridgeImage if (stack === 'nav2') return nav2RosBridgeImage
return slamRosBridgeImage 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 { function normalizeVisualizationStack(value?: string | null): VisualizationStack {
if (value === 'slam') return 'slam' if (value === 'slam') return 'slam'
return value === 'nav2' ? 'nav2' : 'autoware' return value === 'nav2' ? 'nav2' : 'autoware'
@@ -1554,7 +1745,7 @@ async function startVisualizationBridge(
const target = normalizeRuntimeTarget(resolvedProfile?.target) const target = normalizeRuntimeTarget(resolvedProfile?.target)
const bridge = resolvedProfile?.bridge const bridge = resolvedProfile?.bridge
const containerName = bridge?.name?.trim() || visualizationBridgeContainerName(stack) 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 bridgePort = bridge?.port ?? rosBridgePort
const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[]) const otherContainerNames = (['autoware', 'nav2', 'slam'] as VisualizationStack[])
.filter((item) => item !== stack) .filter((item) => item !== stack)
@@ -1585,17 +1776,21 @@ async function startVisualizationBridge(
await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined) await runCommand('docker', ['rm', '-f', containerName], undefined, undefined, 10000).catch(() => undefined)
} }
await prepareRuntimeImages(resolvedProfile?.runtimeImages)
if (resolvedProfile?.runtime?.containers?.length) { 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)
} }
if (bridge?.buildContext) { }
await ensureBridgeImage(bridgeImage, bridge.buildContext) await ensureEnvironmentRuntime(runtimeProfile, rosDomainId)
} else if (stack === 'autoware') { }
await ensureBridgeImage(bridgeImage, 'resources/autoware-ros-bridge') const bridgeContextPath = resolveResourceContextPath(visualizationBridgeResourceContext(stack, bridge) ?? undefined)
} else if (stack === 'nav2') { if (!bridgeContextPath) throw new Error(`브릿지 리소스를 찾을 수 없습니다: ${visualizationBridgeResourceContext(stack, bridge) ?? stack}`)
await ensureBridgeImage(bridgeImage, 'resources/nav2-ros-bridge') if (stack !== 'autoware' && stack !== 'slam') {
} else if (stack === 'slam') { await ensureBridgeImage(bridgeImage, visualizationBridgeResourceContext(stack, bridge))
await ensureBridgeImage(bridgeImage, 'resources/slam-ros-bridge')
} }
const args = [ const args = [
@@ -1606,6 +1801,8 @@ async function startVisualizationBridge(
'--net=host', '--net=host',
'-v', '-v',
'/dev/shm:/dev/shm', '/dev/shm:/dev/shm',
'-v',
`${bridgeContextPath}:${stack === 'slam' ? '/app' : '/ros_bridge'}:ro`,
'-e', '-e',
`ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`, `ROS_DOMAIN_ID=${String(rosDomainId ?? process.env.ROS_DOMAIN_ID ?? '0').trim() || '0'}`,
'-e', '-e',
@@ -1644,6 +1841,7 @@ async function startVisualizationBridge(
} }
args.push(bridgeImage) args.push(bridgeImage)
args.push('bash', '-lc', bridgeContainerCommand(stack))
await runCommand('docker', args, undefined, undefined, 15000) await runCommand('docker', args, undefined, undefined, 15000)
await waitForVisualizationBridgeReady(stack, bridgePort) await waitForVisualizationBridgeReady(stack, bridgePort)
return readVisualizationBridgeStatus() return readVisualizationBridgeStatus()
@@ -1724,6 +1922,7 @@ async function installModuleFromCache(
folderName?: string | null, folderName?: string | null,
templateRepositoryUrl?: string | null, templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null, studentRepositoryUrl?: string | null,
options?: ModuleInstallOptions | null,
): Promise<{ ok: boolean; module: CachedModuleInfo }> { ): Promise<{ ok: boolean; module: CachedModuleInfo }> {
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -1760,6 +1959,7 @@ async function installModuleFromCache(
await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true }) await cp(sourceRoot, targetRoot, { recursive: true, force: false, errorOnExist: true })
} }
} }
await applyModulePrebuiltArtifacts(targetRoot, options?.prebuiltArtifacts)
const { name, category } = parseModuleFolderName(targetFolderName) const { name, category } = parseModuleFolderName(targetFolderName)
return { return {
@@ -1773,6 +1973,8 @@ async function installModuleFromCache(
sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName), sourcePath: templateRepositoryUrl?.trim() ? templateRepositoryUrl.trim() : resolve(moduleCacheRoot, targetFolderName),
workspacePath: targetRoot, workspacePath: targetRoot,
templateRepositoryUrl: templateRepositoryUrl ?? null, templateRepositoryUrl: templateRepositoryUrl ?? null,
sourceType: 'GIT',
prebuiltArtifacts: options?.prebuiltArtifacts ?? null,
parameters: [], parameters: [],
cpu: null, cpu: null,
memory: null, memory: null,
@@ -1787,6 +1989,7 @@ async function installRemoteWorkspaceModule(
folderName?: string | null, folderName?: string | null,
templateRepositoryUrl?: string | null, templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null, studentRepositoryUrl?: string | null,
options?: ModuleInstallOptions | null,
): Promise<{ ok: boolean; module: CachedModuleInfo }> { ): Promise<{ ok: boolean; module: CachedModuleInfo }> {
const target = normalizeRemoteTarget(targetInput) const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName const targetFolderName = folderName?.trim() || moduleName
@@ -1803,6 +2006,16 @@ async function installRemoteWorkspaceModule(
? withGitCredential(remoteStudentRepositoryUrl) ? withGitCredential(remoteStudentRepositoryUrl)
: '' : ''
const targetRoot = remotePathJoin(target.workspaceRoot, targetFolderName) 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 = [ const script = [
'set -e', 'set -e',
`ROOT=${quoteShellSingle(target.workspaceRoot)}`, `ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -1811,6 +2024,7 @@ async function installRemoteWorkspaceModule(
`TARGET="$ROOT/$FOLDER"`, `TARGET="$ROOT/$FOLDER"`,
`TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`, `TEMPLATE_REPO="$(printf %s ${quoteShellSingle(encodeBase64(templateRepositoryWithCredential))} | base64 -d)"`,
`STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`, `STUDENT_REPO="$(printf %s ${quoteShellSingle(encodeBase64(studentRepositoryWithCredential))} | base64 -d)"`,
`PREBUILT_JSON="$(printf %s ${quoteShellSingle(encodeBase64(prebuiltJson))} | base64 -d)"`,
'mkdir -p "$ROOT"', 'mkdir -p "$ROOT"',
'case "$ROOT" in', 'case "$ROOT" in',
' /tmp/autonomy-studio/sessions/*) rm -rf "$TARGET" ;;', ' /tmp/autonomy-studio/sessions/*) rm -rf "$TARGET" ;;',
@@ -1829,6 +2043,39 @@ async function installRemoteWorkspaceModule(
' fi', ' fi',
'fi', 'fi',
'rm -rf "$TARGET/build" "$TARGET/install" "$TARGET/log"', '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"' 'printf "OK\\t%s\\n" "$TARGET"'
].join('\n') ].join('\n')
@@ -1845,6 +2092,8 @@ async function installRemoteWorkspaceModule(
sourcePath: normalizedTemplateRepositoryUrl, sourcePath: normalizedTemplateRepositoryUrl,
workspacePath: targetRoot, workspacePath: targetRoot,
templateRepositoryUrl: normalizedTemplateRepositoryUrl, templateRepositoryUrl: normalizedTemplateRepositoryUrl,
sourceType: 'GIT',
prebuiltArtifacts: options?.prebuiltArtifacts ?? null,
parameters: [], parameters: [],
cpu: null, cpu: null,
memory: null, memory: null,
@@ -2029,6 +2278,7 @@ async function startWorkspaceModule(
rosParamsOverride?: string | null, rosParamsOverride?: string | null,
gpuEnabled?: boolean | null, gpuEnabled?: boolean | null,
rosDomainId?: string | null, rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> { ): Promise<{ ok: boolean; message: string }> {
const resolvedWorkspaceRoot = expandUserPath(workspaceRoot) const resolvedWorkspaceRoot = expandUserPath(workspaceRoot)
if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.') if (!resolvedWorkspaceRoot) throw new Error('워크스페이스 경로가 설정되어 있지 않습니다.')
@@ -2042,6 +2292,7 @@ async function startWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName) const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
await prepareRuntimeImages(runtimeImages)
await ensureStudioRuntimeAssets() await ensureStudioRuntimeAssets()
const scriptPath = resolveAutowareDockerRunScript() const scriptPath = resolveAutowareDockerRunScript()
if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`) if (!existsSync(scriptPath)) throw new Error(`${scriptPath} 파일을 찾을 수 없습니다.`)
@@ -2084,6 +2335,7 @@ async function startRemoteWorkspaceModule(
rosParamsOverride?: string | null, rosParamsOverride?: string | null,
gpuEnabled?: boolean | null, gpuEnabled?: boolean | null,
rosDomainId?: string | null, rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> { ): Promise<{ ok: boolean; message: string }> {
const target = normalizeRemoteTarget(targetInput) const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() const targetFolderName = folderName?.trim()
@@ -2091,6 +2343,16 @@ async function startRemoteWorkspaceModule(
const dockerInfo = parseModuleDockerInfo(targetFolderName) const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.') 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, [ const existsOutput = await runSshOutput(target, [
`ROOT=${quoteShellSingle(target.workspaceRoot)}`, `ROOT=${quoteShellSingle(target.workspaceRoot)}`,
@@ -2102,6 +2364,43 @@ async function startRemoteWorkspaceModule(
if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.') if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.')
const runtimeRoot = await ensureRemoteStudioRuntimeAssets(target) 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) const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' } if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
if (currentStatus.trim()) { if (currentStatus.trim()) {
@@ -2292,9 +2591,10 @@ async function restartWorkspaceModule(
rosParamsOverride?: string | null, rosParamsOverride?: string | null,
gpuEnabled?: boolean | null, gpuEnabled?: boolean | null,
rosDomainId?: string | null, rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> { ): Promise<{ ok: boolean; message: string }> {
await stopWorkspaceModule(moduleName, workspaceRoot, folderName) 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( async function restartRemoteWorkspaceModule(
@@ -2304,9 +2604,10 @@ async function restartRemoteWorkspaceModule(
rosParamsOverride?: string | null, rosParamsOverride?: string | null,
gpuEnabled?: boolean | null, gpuEnabled?: boolean | null,
rosDomainId?: string | null, rosDomainId?: string | null,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ ok: boolean; message: string }> { ): Promise<{ ok: boolean; message: string }> {
await stopRemoteWorkspaceModule(target, folderName) await stopRemoteWorkspaceModule(target, folderName)
return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId) return startRemoteWorkspaceModule(target, moduleName, folderName, rosParamsOverride, gpuEnabled, rosDomainId, runtimeImages)
} }
async function readWorkspaceModuleLogs( async function readWorkspaceModuleLogs(
@@ -2874,22 +3175,22 @@ function createWindow(): void {
function registerLocalWorkspaceHandlers(): 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: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: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', (_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-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: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: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('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: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: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) => startRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId)) 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', (_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: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: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: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: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)) 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.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('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: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: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', (_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-many', (_event, modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string | null) => stopWorkspaceModules(modules, workspaceRoot))
ipcMain.handle('modules:stop-running', () => stopRunningWorkspaceContainers()) 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: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('modules:update-parameters', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, rosParams?: string) => updateWorkspaceModuleParameters(moduleName, workspaceRoot, folderName, rosParams))
ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus()) ipcMain.handle('visualization:status', () => readVisualizationBridgeStatus())
+46 -11
View File
@@ -15,6 +15,28 @@ type RemoteWorkspaceTarget = {
workspaceRoot?: string | null 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 decodeArgValue = (prefix: string): string | null => {
const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length) const rawValue = process.argv.find((arg) => arg.startsWith(prefix))?.slice(prefix.length)
if (!rawValue) return null if (!rawValue) return null
@@ -28,13 +50,21 @@ const decodeArgValue = (prefix: string): string | null => {
const readLauncherAuthFile = (): LauncherAuthPayload | null => { const readLauncherAuthFile = (): LauncherAuthPayload | null => {
const filePath = process.env.ATNMY_HANDOFF_FILE 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 { try {
const payload = JSON.parse(readFileSync(filePath, 'utf8')) as LauncherAuthPayload const payload = JSON.parse(readFileSync(filePath, 'utf8')) as LauncherAuthPayload
rmSync(filePath, { force: true }) rmSync(filePath, { force: true })
console.info('[lms-preload] launcher auth file loaded', {
hasHandoffToken: Boolean(payload.handoffToken),
audienceApp: payload.audienceApp ?? null
})
return payload return payload
} catch { } catch (error) {
console.warn('[lms-preload] launcher auth file read failed', error)
return null return null
} }
} }
@@ -47,6 +77,11 @@ const launcherAuth = {
audienceApp: launcherAuthFile?.audienceApp ?? decodeArgValue('--audience-app=') ?? process.env.ATNMY_AUDIENCE_APP ?? 'LMS' 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('electron', electronAPI)
contextBridge.exposeInMainWorld('launcherAuth', launcherAuth) contextBridge.exposeInMainWorld('launcherAuth', launcherAuth)
contextBridge.exposeInMainWorld('titlebar', { contextBridge.exposeInMainWorld('titlebar', {
@@ -67,13 +102,13 @@ contextBridge.exposeInMainWorld('moduleFiles', {
}) })
contextBridge.exposeInMainWorld('modules', { 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), 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), 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), 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), stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => ipcRenderer.invoke('modules:stop-many', modules, workspaceRoot),
stopRunning: () => ipcRenderer.invoke('modules:stop-running'), 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), 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) 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: { 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), 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), 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), 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), 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'), 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), 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) 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: { 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), 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), 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) => ipcRenderer.invoke('remote-workspace:modules:start', target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId), 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), 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), 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), 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) logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:modules:logs', target, moduleName, folderName)
}, },
git: { git: {
+333 -2224
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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}`
}
+30 -8
View File
@@ -9,6 +9,28 @@ interface ImportMeta {
readonly env: ImportMetaEnv 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 { interface Window {
launcherAuth?: { launcherAuth?: {
appUrl: string | null appUrl: string | null
@@ -37,7 +59,7 @@ interface Window {
delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
} }
modules: { 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 cacheRoot: string
workspaceRoot: string | null workspaceRoot: string | null
modules: Array<{ modules: Array<{
@@ -55,7 +77,7 @@ interface Window {
runtime: string 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 ok: boolean
module: { module: {
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
@@ -73,11 +95,11 @@ interface Window {
} }
}> }>
delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }> delete: (moduleName: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
start: (moduleName: string, workspaceRoot?: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string) => 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 }> 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 }> stopMany: (modules: Array<{ moduleName: string; folderName?: string | null }>, workspaceRoot?: string) => Promise<{ ok: boolean; message: string }>
stopRunning: () => 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 }> 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[] }> 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<Window['moduleFiles']['delete']> delete: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['delete']>
} }
modules: { modules: {
list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ReturnType<Window['modules']['list']> list: (target: RemoteWorkspaceTarget, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null; sourceType?: string | null; prebuiltArtifacts?: ModulePrebuiltArtifact[] | null }>) => ReturnType<Window['modules']['list']>
install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => Promise<{ install: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => Promise<{
ok: boolean ok: boolean
module: { module: {
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
@@ -212,11 +234,11 @@ interface Window {
runtime: string 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 }> 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 }> 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 }> 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 }> logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
} }
git: { git: {
@@ -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<LmsAuthSession | null> | 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<LmsAuthSession | null> {
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<LmsAuthSession | null> {
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
}
@@ -31,7 +31,7 @@ export default function LearningInfoCard({
}: LearningInfoCardProps): React.JSX.Element { }: LearningInfoCardProps): React.JSX.Element {
return ( return (
<article className="rounded-lg bg-white p-4"> <article className="rounded-lg bg-white p-4">
<span className="flex items-center gap-1.5 text-[11px] font-medium text-[#626b7a]"> <span className="flex items-center gap-1.5 text-[11px] font-semibold text-[#626b7a]">
<BookOpen size={13} className="text-[#714ef6]" /> <BookOpen size={13} className="text-[#714ef6]" />
학습 정보 학습 정보
</span> </span>
@@ -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<Response>
export async function fetchStudentCourses(fetchWithFreshAuth: AuthFetch): Promise<StudentCourseOption[]> {
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<CurriculumResponse | null> {
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`
}
@@ -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<CurriculumResponse['course']> & {
progress?: number
}
@@ -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<Response>
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<Array<Partial<PlatformOption> & { 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<Partial<PlatformOption> & { 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<void> {
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 }
}
@@ -4,7 +4,7 @@ type WorkspaceActivityHeaderProps = {
dark: boolean dark: boolean
textStrong: string textStrong: string
mode: 'settings' | 'examples' | 'git' | 'empty' | 'module' mode: 'settings' | 'examples' | 'git' | 'empty' | 'module'
gitPanelTab: 'changes' | 'history' gitPanelTab: 'source' | 'history'
gitSubmissionCount: number gitSubmissionCount: number
gitChangedFileCount: number gitChangedFileCount: number
children?: React.ReactNode children?: React.ReactNode
@@ -7,13 +7,15 @@ type WorkspaceModuleTab = {
state: 'running' | 'stopped' | 'uninstalled' state: 'running' | 'stopped' | 'uninstalled'
} }
type WorkspaceModuleTabActivity = Extract<WorkspaceActivity, 'workspace' | 'search' | 'logs'>
type WorkspaceModuleTabsProps = { type WorkspaceModuleTabsProps = {
dark: boolean dark: boolean
modules: WorkspaceModuleTab[] modules: WorkspaceModuleTab[]
selectedModuleName?: string | null selectedModuleName?: string | null
activeActivity: WorkspaceActivity activeActivity: WorkspaceActivity
controllingModuleNames: string[] controllingModuleNames: string[]
onActivate: (module: WorkspaceModuleTab, activity: WorkspaceActivity) => void onActivate: (module: WorkspaceModuleTab, activity: WorkspaceModuleTabActivity) => void
onClose: (moduleName: string) => void onClose: (moduleName: string) => void
onOpenContextMenu: (moduleName: string, x: number, y: number) => void onOpenContextMenu: (moduleName: string, x: number, y: number) => void
} }
@@ -48,7 +48,7 @@ export default function WorkspaceNav({
return ( return (
<nav <nav
data-help="workspace-nav" data-help="workspace-nav"
className={['flex flex-col items-center gap-3 border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#cfd6e4] bg-[#f3f5f9] text-[#687284]'].join(' ')} className={['relative z-30 flex flex-col items-center gap-3 overflow-visible border-r py-3', dark ? 'border-white/10 bg-[#303030] text-white/65' : 'border-[#cfd6e4] bg-[#f3f5f9] text-[#687284]'].join(' ')}
style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }} style={workspaceActive ? { gridColumn: 3, gridRow: '1 / span 2' } : { gridColumn: 3, gridRow: 1 }}
> >
{WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => { {WORKSPACE_NAV_ITEMS.map(({ id, Icon, label }) => {
@@ -70,7 +70,7 @@ export default function WorkspaceNav({
onSelect(id) onSelect(id)
}} }}
className={[ className={[
'group relative grid h-8 w-8 place-items-center rounded-lg transition', 'group relative z-0 grid h-8 w-8 place-items-center overflow-visible rounded-lg transition',
selectable ? 'cursor-pointer' : 'cursor-default opacity-55', selectable ? 'cursor-pointer' : 'cursor-default opacity-55',
active active
? dark ? dark
@@ -70,6 +70,8 @@ export default function WorkspaceQaPanel({
onClearConversation onClearConversation
}: WorkspaceQaPanelProps): React.JSX.Element { }: WorkspaceQaPanelProps): React.JSX.Element {
const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null) const [contextMenu, setContextMenu] = useState<{ x: number; y: number } | null>(null)
const activeMessages = qaMode === 'ai' ? visibleAiMessages : visibleTeacherMessages
const lastMessage = activeMessages[activeMessages.length - 1]
useEffect(() => { useEffect(() => {
if (!contextMenu) return if (!contextMenu) return
@@ -82,6 +84,52 @@ export default function WorkspaceQaPanel({
} }
}, [contextMenu]) }, [contextMenu])
useEffect(() => {
if (minimized || qaMessagesLoading) return
const target = workspaceChatScrollRef.current
if (!target) return
let disposed = false
const scrollToBottom = (): void => {
if (disposed) return
target.scrollTop = target.scrollHeight
}
const animationFrames: number[] = []
animationFrames.push(window.requestAnimationFrame(scrollToBottom))
animationFrames.push(window.requestAnimationFrame(() => {
animationFrames.push(window.requestAnimationFrame(scrollToBottom))
}))
const timeoutIds = [40, 120, 260, 520, 900].map((delay) => window.setTimeout(scrollToBottom, delay))
const observer = typeof ResizeObserver !== 'undefined'
? new ResizeObserver(scrollToBottom)
: null
observer?.observe(target)
if (target.firstElementChild) observer?.observe(target.firstElementChild)
scrollToBottom()
return () => {
disposed = true
animationFrames.forEach((frame) => window.cancelAnimationFrame(frame))
timeoutIds.forEach((timeoutId) => window.clearTimeout(timeoutId))
observer?.disconnect()
}
}, [
aiPending,
lastMessage?.content,
lastMessage?.id,
minimized,
qaMessagesLoading,
qaMode,
teacherThreadStatus,
visibleAiMessages.length,
visibleTeacherMessages.length,
workspaceChatScrollRef
])
const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => { const openContextMenu = (event: React.MouseEvent<HTMLDivElement>): void => {
const target = event.target as HTMLElement | null const target = event.target as HTMLElement | null
if (target?.closest('[data-qa-message="true"]')) return if (target?.closest('[data-qa-message="true"]')) return
@@ -1,4 +1,4 @@
import { Copy } from 'lucide-react' import { ClipboardPaste, Copy } from 'lucide-react'
type TerminalContextMenuState = { type TerminalContextMenuState = {
text: string text: string
@@ -11,15 +11,18 @@ type Props = {
menu: TerminalContextMenuState | null menu: TerminalContextMenuState | null
menuRef: React.RefObject<HTMLDivElement | null> menuRef: React.RefObject<HTMLDivElement | null>
onCopy: () => void onCopy: () => void
onPaste: () => void
} }
function WorkspaceTerminalContextMenu({ function WorkspaceTerminalContextMenu({
dark, dark,
menu, menu,
menuRef, menuRef,
onCopy onCopy,
onPaste
}: Props): React.JSX.Element | null { }: Props): React.JSX.Element | null {
if (!menu) return null if (!menu) return null
const hasSelection = Boolean(menu.text.trim())
return ( return (
<div <div
@@ -30,6 +33,7 @@ function WorkspaceTerminalContextMenu({
].join(' ')} ].join(' ')}
style={{ left: menu.x, top: menu.y }} style={{ left: menu.x, top: menu.y }}
> >
{hasSelection && (
<button <button
type="button" type="button"
onClick={onCopy} onClick={onCopy}
@@ -41,6 +45,18 @@ function WorkspaceTerminalContextMenu({
<Copy size={13} /> <Copy size={13} />
복사 복사
</button> </button>
)}
<button
type="button"
onClick={onPaste}
className={[
'flex h-8 w-full cursor-pointer items-center gap-2 px-3 text-left transition',
dark ? 'hover:bg-[#37373d] hover:text-white' : 'hover:bg-[#eef1f7] hover:text-[#111520]'
].join(' ')}
>
<ClipboardPaste size={13} />
붙여넣기
</button>
</div> </div>
) )
} }
@@ -0,0 +1,134 @@
export type GitChangedFile = {
path: string
status: 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked'
staged: boolean
unstaged: boolean
}
export type GitCommitInfo = {
hash: string
shortHash: string
author: string
date: string
message: string
}
export type GitStatusInfo = {
ok: boolean
branch: string | null
ahead: number
behind: number
files: GitChangedFile[]
selectedFilePath: string | null
diff: string
commits: GitCommitInfo[]
message?: string
}
export type GitSubmissionInfo = {
id: string
submittedAt: string
moduleName: string
commit: GitCommitInfo
}
export type DiffViewLine = {
key: string
type: 'file' | 'hunk' | 'context' | 'add' | 'delete' | 'note'
oldLine: number | null
newLine: number | null
content: string
}
export function 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
}
@@ -1,4 +1,7 @@
import type { ModuleParameterPair } from './types' import type { ModuleParameterPair } from './types'
import { STUDIO_MODULE_PARAMETERS_STORAGE_KEY } from '../../config/appConfig'
export type StoredModuleParameters = Record<string, ModuleParameterPair[]>
export function createParameterId(): string { export function createParameterId(): string {
return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}` return typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`
@@ -25,3 +28,30 @@ export function serializeModuleParameters(parameters: ModuleParameterPair[]): st
.filter(Boolean) .filter(Boolean)
.join(' ') .join(' ')
} }
export 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 {}
}
}
export 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.
}
}
export function makeModuleParameterKey(courseKey: string, week: number, folderName?: string): string {
return `${courseKey || 'default-course'}:week-${week}:${folderName || 'module'}`
}
@@ -0,0 +1,24 @@
const extractStringRecord = (value: unknown): Record<string, unknown> | null => (
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
)
export 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
}
export const parseHostFromUrl = (value?: string | null): string | null => {
if (!value?.trim()) return null
try {
return new URL(value).hostname
} catch {
return value.trim()
}
}
@@ -0,0 +1,69 @@
import { STUDIO_SOURCE_SETTINGS_STORAGE_KEY } from '../../config/appConfig'
import type { StudioSourceSettings } from '../platforms/types'
export const defaultStudioSourceSettings: StudioSourceSettings = {
workspaceRoot: '~/autonomy-studio/workspaces',
giteaBaseUrl: 'http://localhost:3000',
giteaOwner: 'student',
gpuEnabled: false,
rosDomainId: '77'
}
export 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}`
}
export 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}`
}
export function normalizeStudioSourceSettings(settings?: Partial<StudioSourceSettings> | 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}`
}
export 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<StudioSourceSettings>
return normalizeStudioSourceSettings({ ...defaults, ...settings })
} catch {
return defaults
}
}
export 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.
}
}
@@ -0,0 +1,49 @@
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
export type StudioRuntimeSessionState = {
localPcLayoutOpen: boolean
studioEntered: boolean
sessionStatus: 'idle' | 'running' | 'ended'
sessionStartedAt: string | null
sessionEndedAt: string | null
connectedPlatformId: string | null
runningPlatformIds: string[]
workspaceWeekNumber: number | null
}
export 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<StudioRuntimeSessionState>
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
}
}
export 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))
}
export function makeWorkspaceUiSessionKey(studentSlug: string, weekNumber: number, platformId: string): string {
return `${STUDIO_RUNTIME_SESSION_STORAGE_KEY}:workspace:${studentSlug}:week-${weekNumber}:platform-${platformId}`
}
@@ -13,8 +13,27 @@ export type ModuleRow = {
sourcePath?: string sourcePath?: string
workspacePath?: string | null workspacePath?: string | null
templateRepositoryUrl?: string | null templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
parameters: string[] parameters: string[]
cpu: number | null cpu: number | null
memory: number | null memory: number | null
runtime: string runtime: string
} }
export type ModulePrebuiltArtifact = {
type?: string | null
url?: string | null
fileName?: string | null
checksumSha256?: string | null
extractPath?: string | null
required?: boolean | null
}
export type RuntimeImageArtifact = {
image?: string | null
url?: string | null
fileName?: string | null
checksumSha256?: string | null
required?: boolean | null
}
@@ -0,0 +1,137 @@
import { useCallback, useEffect } from 'react'
import { LMS_API_BASE_URL } from '../../config/appConfig'
import type { CurriculumWeek } from '../curriculum/types'
import type { ModulePrebuiltArtifact, ModuleRow, RuntimeImageArtifact } from './types'
type UseRemoteWorkspaceRuntimeOptions = {
availableModules: ModuleRow[]
curriculumModules: CurriculumWeek['modules']
courseKey: string
weekNumber: number
clonePathPreview: string
remoteWorkspace: boolean
remoteWorkspaceTargetKey: string
activeRuntimeImages: RuntimeImageArtifact[]
getFreshAccessToken: () => Promise<string>
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
saveDirtyFilesForModule: (module: ModuleRow) => Promise<void>
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
}
type UseRemoteWorkspaceRuntimeResult = {
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
}
export function useRemoteWorkspaceRuntime({
availableModules,
curriculumModules,
courseKey,
weekNumber,
clonePathPreview,
remoteWorkspace,
remoteWorkspaceTargetKey,
activeRuntimeImages,
getFreshAccessToken,
getRemoteWorkspaceTarget,
saveDirtyFilesForModule,
onRegisterStopRunningModules
}: UseRemoteWorkspaceRuntimeOptions): UseRemoteWorkspaceRuntimeResult {
const prepareRemoteRuntimeModule = useCallback(async (module: ModuleRow): Promise<void> => {
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: weekNumber,
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
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
}
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,
{
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
runtimeImages: activeRuntimeImages
}
)
}, [
activeRuntimeImages,
clonePathPreview,
courseKey,
getFreshAccessToken,
getRemoteWorkspaceTarget,
saveDirtyFilesForModule,
weekNumber
])
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
if (remoteWorkspace) {
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>()
for (const module of availableModules) {
moduleMap.set(module.name, {
moduleName: module.name,
folderName: module.folderName,
sortOrder: module.sortOrder
})
}
for (const module of curriculumModules ?? []) {
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, curriculumModules, getRemoteWorkspaceTarget, remoteWorkspace, remoteWorkspaceTargetKey])
useEffect(() => {
onRegisterStopRunningModules?.(stopRunningWorkspaceModules)
return () => onRegisterStopRunningModules?.(null)
}, [onRegisterStopRunningModules, stopRunningWorkspaceModules])
return {
prepareRemoteRuntimeModule
}
}
@@ -0,0 +1,209 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { ModuleRow } from './types'
import type { GitStatusInfo, GitSubmissionInfo } from './gitUtils'
export type GitTarget = {
moduleName: string
filePath: string
}
type GitModuleRef = {
moduleName: string
folderName?: string | null
}
type UseSourceControlOptions = {
modules: ModuleRow[]
readGitStatus: (module: ModuleRow, selectedPath: string | null) => Promise<GitStatusInfo>
readGitSubmissions: (modules: GitModuleRef[]) => Promise<{ ok: boolean; submissions: GitSubmissionInfo[]; message?: string }>
readGitCommitDiff: (moduleName: string, folderName: string | null | undefined, commitHash: string) => Promise<{ ok: boolean; diff: string; message?: string }>
}
export function useSourceControl({
modules,
readGitStatus,
readGitSubmissions,
readGitCommitDiff
}: UseSourceControlOptions) {
const [gitModuleStatuses, setGitModuleStatuses] = useState<Record<string, GitStatusInfo>>({})
const [gitLoading, setGitLoading] = useState(false)
const [gitError, setGitError] = useState<string | null>(null)
const [selectedGitTarget, setSelectedGitTarget] = useState<GitTarget | null>(null)
const [gitPanelTab, setGitPanelTab] = useState<'source' | 'history'>('source')
const [gitSubmissions, setGitSubmissions] = useState<GitSubmissionInfo[]>([])
const [gitSubmissionsLoading, setGitSubmissionsLoading] = useState(false)
const [selectedGitSubmission, setSelectedGitSubmission] = useState<GitSubmissionInfo | null>(null)
const [selectedGitSubmissionDiff, setSelectedGitSubmissionDiff] = useState('')
const [gitSubmissionDiffLoading, setGitSubmissionDiffLoading] = useState(false)
const [gitSubmitting, setGitSubmitting] = useState(false)
const moduleIdentityKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}:${module.state}`).join('|')
const moduleFolderKey = modules.map((module) => `${module.name}:${module.folderName ?? ''}`).join('|')
const installedGitModules = useMemo(
() => modules.filter((module) => module.state !== 'uninstalled'),
[moduleIdentityKey]
)
const moduleFolderByName = useMemo(
() => new Map(modules.map((module) => [module.name, module.folderName ?? null])),
[moduleFolderKey]
)
const refreshGitStatuses = useCallback(async (target = selectedGitTarget): Promise<void> => {
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)
}
}, [installedGitModules, readGitStatus, selectedGitTarget])
const selectGitFile = useCallback(async (moduleName: string, filePath: string): Promise<void> => {
const target = { moduleName, filePath }
setSelectedGitTarget(target)
await refreshGitStatuses(target)
}, [refreshGitStatuses])
const refreshGitSubmissions = useCallback(async (): Promise<void> => {
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)
}
}, [installedGitModules, readGitSubmissions])
const loadGitSubmissionDiff = useCallback(async (submission: GitSubmissionInfo | null): Promise<void> => {
if (!submission) {
setSelectedGitSubmissionDiff('')
return
}
const folderName = moduleFolderByName.get(submission.moduleName)
setGitSubmissionDiffLoading(true)
try {
const result = await readGitCommitDiff(submission.moduleName, 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)
}
}, [moduleFolderByName, readGitCommitDiff])
useEffect(() => {
void loadGitSubmissionDiff(selectedGitSubmission)
}, [loadGitSubmissionDiff, selectedGitSubmission])
const gitChangedModules = useMemo(
() => installedGitModules.filter((module) => gitModuleStatuses[module.name]?.files.length),
[gitModuleStatuses, installedGitModules]
)
const gitChangedFileCount = useMemo(
() => gitChangedModules.reduce((count, module) => count + (gitModuleStatuses[module.name]?.files.length ?? 0), 0),
[gitChangedModules, gitModuleStatuses]
)
const selectedGitStatus = selectedGitTarget ? gitModuleStatuses[selectedGitTarget.moduleName] : null
const selectedGitFile = selectedGitTarget
? selectedGitStatus?.files.find((file) => file.path === selectedGitTarget.filePath) ?? null
: null
const gitSubmissionGroups = useMemo(() => (
gitSubmissions.reduce<Array<{ id: string; submittedAt: string; submissions: GitSubmissionInfo[] }>>((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())
), [gitSubmissions])
return {
gitPanelTab,
setGitPanelTab,
gitModuleStatuses,
setGitModuleStatuses,
gitLoading,
gitError,
setGitError,
selectedGitTarget,
setSelectedGitTarget,
gitSubmissions,
setGitSubmissions,
gitSubmissionsLoading,
selectedGitSubmission,
setSelectedGitSubmission,
selectedGitSubmissionDiff,
setSelectedGitSubmissionDiff,
gitSubmissionDiffLoading,
gitSubmitting,
setGitSubmitting,
installedGitModules,
gitChangedModules,
gitChangedFileCount,
selectedGitStatus,
selectedGitFile,
gitSubmissionGroups,
refreshGitStatuses,
selectGitFile,
refreshGitSubmissions,
loadGitSubmissionDiff
}
}
@@ -0,0 +1,60 @@
import { useEffect, type RefObject } from 'react'
type MenuRef = RefObject<HTMLElement | null>
type UseWorkspaceContextMenusOptions = {
tabOpen: boolean
tabRef: MenuRef
closeTab: () => void
moduleTabOpen: boolean
moduleTabRef: MenuRef
closeModuleTab: () => void
treeOpen: boolean
treeRef: MenuRef
closeTree: () => void
terminalOpen: boolean
terminalRef: MenuRef
closeTerminal: () => void
gitFileOpen: boolean
gitFileRef: MenuRef
closeGitFile: () => void
}
function useCloseOnOutsideClick(open: boolean, ref: MenuRef, close: () => void): void {
useEffect(() => {
if (!open) return
const closeOnOutsideClick = (event: MouseEvent): void => {
if (!ref.current?.contains(event.target as Node)) {
close()
}
}
document.addEventListener('mousedown', closeOnOutsideClick)
return () => document.removeEventListener('mousedown', closeOnOutsideClick)
}, [open, ref, close])
}
export function useWorkspaceContextMenus({
tabOpen,
tabRef,
closeTab,
moduleTabOpen,
moduleTabRef,
closeModuleTab,
treeOpen,
treeRef,
closeTree,
terminalOpen,
terminalRef,
closeTerminal,
gitFileOpen,
gitFileRef,
closeGitFile
}: UseWorkspaceContextMenusOptions): void {
useCloseOnOutsideClick(tabOpen, tabRef, closeTab)
useCloseOnOutsideClick(moduleTabOpen, moduleTabRef, closeModuleTab)
useCloseOnOutsideClick(treeOpen, treeRef, closeTree)
useCloseOnOutsideClick(terminalOpen, terminalRef, closeTerminal)
useCloseOnOutsideClick(gitFileOpen, gitFileRef, closeGitFile)
}
@@ -0,0 +1,429 @@
import type { Dispatch, SetStateAction } from 'react'
import type { ModuleRow } from './types'
import type { EditorFilePath } from './workspaceFiles'
import type { NewFileDraftState, TreeClipboardState, TreeContextMenuState } from './useWorkspaceTreeState'
import type { WorkspaceActivity } from './WorkspaceNav'
import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from './workspaceTree'
type UseWorkspaceFileActionsOptions = {
selectedModule: ModuleRow | null
clonePathPreview: string
selectedTreePath: string | null
setSelectedTreePath: Dispatch<SetStateAction<string | null>>
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
setActiveIdeActivity: Dispatch<SetStateAction<WorkspaceActivity>>
editorFiles: Record<string, string>
setEditorFiles: Dispatch<SetStateAction<Record<string, string>>>
dirtyEditorFiles: Record<string, boolean>
setDirtyEditorFiles: Dispatch<SetStateAction<Record<string, boolean>>>
setSavedEditorFiles: Dispatch<SetStateAction<Record<string, string>>>
currentActiveEditorFile: EditorFilePath | null
isDirtyEditorFile: (file: EditorFilePath) => boolean
markEditorFileSaved: (file: EditorFilePath, content: string) => void
setActiveEditorFile: Dispatch<SetStateAction<EditorFilePath | null>>
setOpenEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>>
setActiveExampleEditorFile: Dispatch<SetStateAction<EditorFilePath | null>>
setOpenExampleEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>>
loadedModuleFolders: Record<string, string[]>
setLoadedModuleFolders: Dispatch<SetStateAction<Record<string, string[]>>>
setLoadedModuleFilePaths: Dispatch<SetStateAction<Record<string, string[]>>>
setModuleFileLoadingByName: Dispatch<SetStateAction<Record<string, boolean>>>
loadedModuleRoots: Record<string, string | null>
setLoadedModuleRoots: Dispatch<SetStateAction<Record<string, string | null>>>
createdModuleFolders: string[]
setCreatedModuleFolders: Dispatch<SetStateAction<string[]>>
openProjectFolders: Record<string, boolean>
setOpenProjectFolders: Dispatch<SetStateAction<Record<string, boolean>>>
treeContextMenu: TreeContextMenuState | null
setTreeContextMenu: Dispatch<SetStateAction<TreeContextMenuState | null>>
treeClipboard: TreeClipboardState | null
setTreeClipboard: Dispatch<SetStateAction<TreeClipboardState | null>>
setTreeDialog: Dispatch<SetStateAction<
| { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string }
| { type: 'delete'; path: string; kind: 'file' | 'folder' }
| null
>>
newFileDraft: NewFileDraftState | null
setNewFileDraft: Dispatch<SetStateAction<NewFileDraftState | null>>
installedGitModules: ModuleRow[]
selectedGitTarget: { moduleName: string; filePath: string } | null
refreshGitStatuses: (target?: { moduleName: string; filePath: string } | null) => Promise<void>
}
type UseWorkspaceFileActionsResult = {
loadModuleFiles: (module: ModuleRow) => Promise<void>
startCreateFile: (kind: 'file' | 'folder') => void
commitCreateFile: () => Promise<void>
getTreeDisplayPath: (path: string) => string
getModuleFilePrefix: (moduleName: string) => string
getDirtyModuleFiles: (moduleName: string) => EditorFilePath[]
saveEditorFile: (file: EditorFilePath) => Promise<void>
saveCurrentEditorFile: () => void
saveAllEditorFiles: () => void
updateModulePaths: (updater: (paths: string[]) => string[]) => void
updateOpenPathReferences: (fromPath: string, toPath: string) => void
openTreeContextTarget: (target?: TreeContextMenuState | null) => void
copyTreePathToClipboard: () => Promise<void>
performRenameTreeTarget: (currentPath: string, nextNameInput: string) => Promise<void>
requestRenameTreeTarget: () => void
performDeleteTreeTarget: (targetPath: string) => Promise<void>
requestDeleteTreeTarget: () => void
pasteTreeClipboard: () => Promise<void>
refreshModuleFiles: () => void
}
export function 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
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
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 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<void> => {
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 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<void> => {
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 dirtyFiles = getDirtyModuleFiles(selectedModule.name)
if (!dirtyFiles.length) return
void Promise.all(dirtyFiles.map((file) => saveEditorFile(file))).catch((error) => console.error('[module-files] save all failed', error))
}
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<string, boolean> = {}
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') {
if (target.path.startsWith('examples/example-code/')) {
setSelectedExampleTreePath(target.path)
setActiveExampleEditorFile(target.path)
setOpenExampleEditorTabs((tabs) => (tabs.includes(target.path) ? tabs : [...tabs, target.path]))
} else {
setSelectedTreePath(target.path)
setActiveEditorFile(target.path)
setOpenEditorTabs((tabs) => (tabs.includes(target.path) ? tabs : [...tabs, 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<void> => {
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<void> => {
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<string, string> = {}
Object.entries(files).forEach(([path, content]) => {
nextFiles[replacePathPrefix(path, currentPath, nextPath)] = content
})
return nextFiles
})
setSavedEditorFiles((files) => {
const nextFiles: Record<string, string> = {}
Object.entries(files).forEach(([path, content]) => {
nextFiles[replacePathPrefix(path, currentPath, nextPath)] = content
})
return nextFiles
})
setDirtyEditorFiles((dirtyFiles) => {
const nextDirtyFiles: Record<string, boolean> = {}
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<void> => {
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<void> => {
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)
}
return {
loadModuleFiles,
startCreateFile,
commitCreateFile,
getTreeDisplayPath,
getModuleFilePrefix,
getDirtyModuleFiles,
saveEditorFile,
saveCurrentEditorFile,
saveAllEditorFiles,
updateModulePaths,
updateOpenPathReferences,
openTreeContextTarget,
copyTreePathToClipboard,
performRenameTreeTarget,
requestRenameTreeTarget,
performDeleteTreeTarget,
requestDeleteTreeTarget,
pasteTreeClipboard,
refreshModuleFiles
}
}
@@ -0,0 +1,314 @@
import { useCallback, useState } from 'react'
import { LMS_API_BASE_URL } from '../../config/appConfig'
import type { StudioSourceSettings } from '../platforms/types'
import { serializeModuleParameters, storeModuleParameters, makeModuleParameterKey } from './moduleParameters'
import type { ModuleParameterPair, ModulePrebuiltArtifact, ModuleRow, RuntimeImageArtifact } from './types'
import { compareModulePriority, compareModuleShutdownPriority } from './workspaceModules'
type NoticeTone = 'info' | 'warning' | 'error'
type ModuleActionResult = {
ok: boolean
message?: string
}
type UseWorkspaceModuleControlOptions = {
availableModules: ModuleRow[]
setAvailableModules: React.Dispatch<React.SetStateAction<ModuleRow[]>>
loadAvailableModules: (options?: { silent?: boolean }) => Promise<void>
courseKey: string
weekNumber: number
clonePathPreview: string
remoteWorkspace: boolean
sourceSettings: StudioSourceSettings
activeRuntimeImages: RuntimeImageArtifact[]
getFreshAccessToken: () => Promise<string>
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
prepareRemoteRuntimeModule: (module: ModuleRow) => Promise<void>
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
}
type UseWorkspaceModuleControlResult = {
installingModuleName: string | null
setInstallingModuleName: React.Dispatch<React.SetStateAction<string | null>>
controllingModuleNames: string[]
moduleInstallError: string | null
setModuleInstallError: React.Dispatch<React.SetStateAction<string | null>>
installModule: (module: ModuleRow) => Promise<void>
installAllModules: () => Promise<void>
startAllModules: () => Promise<void>
stopAllModules: () => Promise<void>
runModuleControl: (module: ModuleRow, action: 'start' | 'stop' | 'restart') => Promise<void>
updateModuleParameters: (module: ModuleRow, parameters: ModuleParameterPair[]) => Promise<void>
}
export function useWorkspaceModuleControl({
availableModules,
setAvailableModules,
loadAvailableModules,
courseKey,
weekNumber,
clonePathPreview,
remoteWorkspace,
sourceSettings,
activeRuntimeImages,
getFreshAccessToken,
getRemoteWorkspaceTarget,
prepareRemoteRuntimeModule,
onTitlebarNotice
}: UseWorkspaceModuleControlOptions): UseWorkspaceModuleControlResult {
const [installingModuleName, setInstallingModuleName] = useState<string | null>(null)
const [controllingModuleNames, setControllingModuleNames] = useState<string[]>([])
const [moduleInstallError, setModuleInstallError] = useState<string | null>(null)
const getModuleRosParams = useCallback((module: ModuleRow): string => module.parameters.join(' '), [])
const installModuleOnce = useCallback(async (module: ModuleRow, requestAccessToken: string): Promise<void> => {
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: weekNumber,
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
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, {
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
runtimeImages: activeRuntimeImages
})
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module)
return window.remoteWorkspace.modules.start(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
getModuleRosParams(module),
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId,
activeRuntimeImages
)
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
const remoteModuleStop = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => (
window.remoteWorkspace.modules.stop(getRemoteWorkspaceTarget(), module.name, module.folderName)
), [getRemoteWorkspaceTarget])
const remoteModuleRestart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await remoteModuleStop(module).catch(() => ({ ok: false, message: '모듈 중지 실패' }))
return remoteModuleStart(module)
}, [remoteModuleStart, remoteModuleStop])
const installModule = useCallback(async (module: ModuleRow): Promise<void> => {
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)
}
}, [getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
const installAllModules = useCallback(async (): Promise<void> => {
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)
}
}, [availableModules, getFreshAccessToken, installModuleOnce, installingModuleName, loadAvailableModules])
const startAllModules = useCallback(async (): Promise<void> => {
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, activeRuntimeImages)
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)
}
}, [
activeRuntimeImages,
availableModules,
clonePathPreview,
getModuleRosParams,
installingModuleName,
loadAvailableModules,
onTitlebarNotice,
remoteModuleStart,
remoteWorkspace,
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId
])
const stopAllModules = useCallback(async (): Promise<void> => {
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)
}
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart'): Promise<void> => {
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, activeRuntimeImages)
: remoteWorkspace
? await remoteModuleStart(module)
: await window.localWorkspace.modules.start(module.name, clonePathPreview, module.folderName, getModuleRosParams(module), sourceSettings.gpuEnabled, sourceSettings.rosDomainId, activeRuntimeImages)
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))
}
}, [
activeRuntimeImages,
clonePathPreview,
controllingModuleNames,
getModuleRosParams,
installingModuleName,
loadAvailableModules,
onTitlebarNotice,
remoteModuleRestart,
remoteModuleStart,
remoteModuleStop,
remoteWorkspace,
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId
])
const updateModuleParameters = useCallback(async (module: ModuleRow, parameters: ModuleParameterPair[]): Promise<void> => {
setModuleInstallError(null)
try {
const normalizedParameters = parameters
.map((parameter) => ({ ...parameter, name: parameter.name.trim(), value: parameter.value.trim() }))
.filter((parameter) => parameter.name)
storeModuleParameters(makeModuleParameterKey(courseKey, weekNumber, module.folderName ?? module.name), 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 : '파라미터 저장 실패')
}
}, [courseKey, setAvailableModules, weekNumber])
return {
installingModuleName,
setInstallingModuleName,
controllingModuleNames,
moduleInstallError,
setModuleInstallError,
installModule,
installAllModules,
startAllModules,
stopAllModules,
runModuleControl,
updateModuleParameters
}
}
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import type { CurriculumWeek } from '../curriculum/types'
import type { ModuleRow } from './types'
import { compareModulePriority, makeUninstalledModuleRows } from './workspaceModules'
type LoadAvailableModulesOptions = {
silent?: boolean
}
type UseWorkspaceModuleListOptions = {
clonePathPreview: string
curriculumModules: CurriculumWeek['modules']
curriculumModuleKey: string
remoteWorkspace: boolean
remoteWorkspaceTargetKey: string
pollingActive: boolean
applyModuleParameterOverrides: (modules: ModuleRow[]) => ModuleRow[]
}
type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
modulesLoading: boolean
moduleListError: string | null
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
loadRemoteModules: () => Promise<{ modules: ModuleRow[] }>
}
function parseRemoteWorkspaceTarget(targetKey: string): RemoteWorkspaceTarget | null {
try {
const parsed = JSON.parse(targetKey) as RemoteWorkspaceTarget | null
return parsed && typeof parsed.host === 'string' ? parsed : null
} catch {
return null
}
}
export function useWorkspaceModuleList({
clonePathPreview,
curriculumModules,
curriculumModuleKey,
remoteWorkspace,
remoteWorkspaceTargetKey,
pollingActive,
applyModuleParameterOverrides
}: UseWorkspaceModuleListOptions): UseWorkspaceModuleListResult {
const [availableModules, setAvailableModules] = useState<ModuleRow[]>([])
const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
if (!remoteTarget) return { modules: [] }
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
}, [curriculumModuleKey, remoteTarget])
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
if (!silent) setModulesLoading(true)
try {
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
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(curriculumModules))
}
} finally {
if (!silent) setModulesLoading(false)
}
}, [
applyModuleParameterOverrides,
clonePathPreview,
curriculumModuleKey,
curriculumModules,
loadRemoteModules,
remoteWorkspace
])
useEffect(() => {
void loadAvailableModules()
}, [loadAvailableModules, remoteWorkspaceTargetKey])
useEffect(() => {
if (!pollingActive) return
const intervalId = window.setInterval(() => {
void loadAvailableModules({ silent: true })
}, 5000)
return () => window.clearInterval(intervalId)
}, [loadAvailableModules, pollingActive])
return {
availableModules,
setAvailableModules,
modulesLoading,
moduleListError,
loadAvailableModules,
loadRemoteModules
}
}
@@ -0,0 +1,113 @@
import { useEffect, useRef, useState } from 'react'
import type { ModuleRow } from './types'
import { maxVisibleModuleLogEntries, parseModuleLogEntries } from './workspaceLogs'
type UseWorkspaceModuleLogsOptions = {
selectedModule: ModuleRow | null
logActive: boolean
clonePathPreview: string
remoteWorkspace: boolean
remoteWorkspaceTargetKey: string
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
}
type UseWorkspaceModuleLogsResult = {
moduleLogsLoading: boolean
moduleLogMessage: string | null
visibleModuleLogEntries: ReturnType<typeof parseModuleLogEntries>
moduleLogScrollRef: React.RefObject<HTMLDivElement | null>
updateModuleLogStickToBottom: () => void
}
export function useWorkspaceModuleLogs({
selectedModule,
logActive,
clonePathPreview,
remoteWorkspace,
remoteWorkspaceTargetKey,
getRemoteWorkspaceTarget
}: UseWorkspaceModuleLogsOptions): UseWorkspaceModuleLogsResult {
const [moduleLogText, setModuleLogText] = useState('')
const [moduleLogMessage, setModuleLogMessage] = useState<string | null>(null)
const [moduleLogsLoading, setModuleLogsLoading] = useState(false)
const moduleLogScrollRef = useRef<HTMLDivElement>(null)
const moduleLogStickToBottomRef = useRef(true)
const getRemoteWorkspaceTargetRef = useRef(getRemoteWorkspaceTarget)
useEffect(() => {
getRemoteWorkspaceTargetRef.current = getRemoteWorkspaceTarget
}, [getRemoteWorkspaceTarget])
const moduleLogEntries = selectedModule ? parseModuleLogEntries(selectedModule, moduleLogText) : []
const visibleModuleLogEntries = moduleLogEntries.slice(-maxVisibleModuleLogEntries)
const updateModuleLogStickToBottom = (): void => {
const container = moduleLogScrollRef.current
if (!container) return
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight
moduleLogStickToBottomRef.current = distanceToBottom < 24
}
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
}
let cancelled = false
const loadLogs = async (quiet = false): Promise<void> => {
if (!quiet) setModuleLogsLoading(true)
try {
const result = remoteWorkspace
? await window.remoteWorkspace.modules.logs(getRemoteWorkspaceTargetRef.current(), 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)
}
}, [clonePathPreview, logActive, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule?.folderName, selectedModule?.name, selectedModule?.state])
return {
moduleLogsLoading,
moduleLogMessage,
visibleModuleLogEntries,
moduleLogScrollRef,
updateModuleLogStickToBottom
}
}
@@ -0,0 +1,147 @@
import { useEffect, useRef, useState } from 'react'
import type { EditorFilePath } from './workspaceFiles'
import type { ModuleRow } from './types'
export type ModuleSearchResult = {
file: string
lineNumber: number
lineText: string
}
type PendingReveal = {
lineNumber: number
query: string
}
type UseWorkspaceSearchOptions = {
searchActive: boolean
selectedModule: ModuleRow | null
editorFiles: Record<string, string>
moduleFilePaths: string[]
activeEditorFile: EditorFilePath | null
onOpenEditorFileAtLine: (file: EditorFilePath) => void
onRevealSearchMatch: (lineNumber: number, query: string) => boolean
}
type UseWorkspaceSearchResult = {
moduleSearchInputRef: React.RefObject<HTMLInputElement | null>
moduleSearchQuery: string
setModuleSearchQuery: React.Dispatch<React.SetStateAction<string>>
activeModuleSearchQuery: string
moduleSearchRunning: boolean
moduleSearchResults: ModuleSearchResult[]
requestSearchReveal: (lineNumber: number) => void
openSearchResult: (file: EditorFilePath, lineNumber: number) => void
}
export function useWorkspaceSearch({
searchActive,
selectedModule,
editorFiles,
moduleFilePaths,
activeEditorFile,
onOpenEditorFileAtLine,
onRevealSearchMatch
}: UseWorkspaceSearchOptions): UseWorkspaceSearchResult {
const [moduleSearchQuery, setModuleSearchQuery] = useState('')
const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('')
const [moduleSearchResults, setModuleSearchResults] = useState<ModuleSearchResult[]>([])
const [moduleSearchRunning, setModuleSearchRunning] = useState(false)
const [pendingReveal, setPendingReveal] = useState<PendingReveal | null>(null)
const moduleSearchInputRef = useRef<HTMLInputElement>(null)
const searchRunIdRef = useRef(0)
const moduleFilePathKey = moduleFilePaths.join('\n')
const activeModuleSearchQuery = debouncedModuleSearchQuery.trim()
const requestSearchReveal = (lineNumber: number): void => {
setPendingReveal({ lineNumber, query: moduleSearchQuery.trim() })
}
const openSearchResult = (file: EditorFilePath, lineNumber: number): void => {
onOpenEditorFileAtLine(file)
requestSearchReveal(lineNumber)
}
useEffect(() => {
if (!pendingReveal) return
const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query)
if (revealed) setPendingReveal(null)
}, [activeEditorFile, onRevealSearchMatch, 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 query = debouncedModuleSearchQuery.trim()
if (!searchActive || !selectedModule || query.length < 2) {
setModuleSearchResults([])
setModuleSearchRunning(false)
return
}
let cancelled = false
const searchRunId = searchRunIdRef.current + 1
searchRunIdRef.current = searchRunId
let fileIndex = 0
const normalizedQuery = query.toLowerCase()
const nextResults: ModuleSearchResult[] = []
setModuleSearchRunning(true)
const searchChunk = (): void => {
if (cancelled || searchRunIdRef.current !== searchRunId) return
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 || searchRunIdRef.current !== searchRunId) return
if (fileIndex < moduleFilePaths.length && nextResults.length < 200) {
window.setTimeout(searchChunk, 0)
} else {
setModuleSearchResults([...nextResults])
setModuleSearchRunning(false)
}
}
const timer = window.setTimeout(searchChunk, 0)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule])
return {
moduleSearchInputRef,
moduleSearchQuery,
setModuleSearchQuery,
activeModuleSearchQuery,
moduleSearchRunning,
moduleSearchResults,
requestSearchReveal,
openSearchResult
}
}
@@ -0,0 +1,211 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal as XTerm } from '@xterm/xterm'
import { useEffect, useRef, useState } from 'react'
import '@xterm/xterm/css/xterm.css'
import { getModuleTerminalSessionId, stripInternalTerminalCommands } from './workspaceLogs'
type TerminalContextMenu = {
text: string
x: number
y: number
}
type UseWorkspaceTerminalOptions = {
moduleName?: string | null
moduleFolderName?: string | null
clonePathPreview: string
dark: boolean
terminalActive: boolean
resetKey: string
layoutEditorToPane: () => void
}
type UseWorkspaceTerminalResult = {
terminalContextMenu: TerminalContextMenu | null
setTerminalContextMenu: React.Dispatch<React.SetStateAction<TerminalContextMenu | null>>
terminalContextMenuRef: React.RefObject<HTMLDivElement | null>
terminalHostRef: React.RefObject<HTMLDivElement | null>
terminalPanelHeight: number
fitTerminal: () => void
copyTerminalSelection: () => void
pasteTerminalClipboard: () => void
startTerminalResize: (event: React.MouseEvent<HTMLDivElement>) => void
}
export function useWorkspaceTerminal({
moduleName,
moduleFolderName,
clonePathPreview,
dark,
terminalActive,
resetKey,
layoutEditorToPane
}: UseWorkspaceTerminalOptions): UseWorkspaceTerminalResult {
const [terminalContextMenu, setTerminalContextMenu] = useState<TerminalContextMenu | null>(null)
const terminalContextMenuRef = useRef<HTMLDivElement>(null)
const terminalHostRef = useRef<HTMLDivElement | null>(null)
const terminalRef = useRef<XTerm | null>(null)
const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null)
const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
const fitTerminal = (): void => {
terminalFitRef.current?.fit()
}
const copyTerminalSelection = (): void => {
if (!terminalContextMenu?.text) return
void navigator.clipboard.writeText(terminalContextMenu.text).catch((error) => console.error('[terminal] copy failed', error))
setTerminalContextMenu(null)
}
const pasteTerminalClipboard = (): void => {
const terminalSessionId = terminalSessionIdRef.current
if (!terminalSessionId) {
setTerminalContextMenu(null)
return
}
void navigator.clipboard.readText()
.then((text) => {
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
})
.catch((error) => console.error('[terminal] paste failed', error))
.finally(() => setTerminalContextMenu(null))
}
const startTerminalResize = (event: React.MouseEvent<HTMLDivElement>): void => {
event.preventDefault()
const startY = event.clientY
const startHeight = terminalPanelHeight
const relayoutWorkspace = (): void => {
fitTerminal()
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(() => {
if (!moduleName || !terminalActive || !terminalHostRef.current || terminalRef.current) return
const terminalSessionId = getModuleTerminalSessionId(moduleName)
terminalSessionIdRef.current = terminalSessionId
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()
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, moduleFolderName ?? undefined)
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
terminalSessionIdRef.current = null
terminalSuppressQueueRef.current = []
}
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
return {
terminalContextMenu,
setTerminalContextMenu,
terminalContextMenuRef,
terminalHostRef,
terminalPanelHeight,
fitTerminal,
copyTerminalSelection,
pasteTerminalClipboard,
startTerminalResize
}
}
@@ -0,0 +1,89 @@
import { useRef, useState } from 'react'
import type { EditorFilePath } from './workspaceFiles'
export type TreeContextMenuState = {
path: string
kind: 'file' | 'folder'
x: number
y: number
}
export type TreeClipboardState = {
path: string
kind: 'file' | 'folder'
mode: 'cut' | 'copy'
}
export type TreeDialogState =
| { type: 'rename'; path: string; kind: 'file' | 'folder'; value: string }
| { type: 'delete'; path: string; kind: 'file' | 'folder' }
export type NewFileDraftState = {
kind: 'file' | 'folder'
parent: string
afterFile: EditorFilePath | null
depth: number
value: string
}
const defaultOpenProjectFolders: Record<string, boolean> = {
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
}
export function useWorkspaceTreeState() {
const [selectedTreePath, setSelectedTreePath] = useState<string | null>(null)
const [selectedExampleTreePath, setSelectedExampleTreePath] = useState<string | null>(null)
const [treeContextMenu, setTreeContextMenu] = useState<TreeContextMenuState | null>(null)
const [treeClipboard, setTreeClipboard] = useState<TreeClipboardState | null>(null)
const [treeDialog, setTreeDialog] = useState<TreeDialogState | null>(null)
const [newFileDraft, setNewFileDraft] = useState<NewFileDraftState | null>(null)
const [createdModuleFolders, setCreatedModuleFolders] = useState<string[]>([])
const [loadedModuleFolders, setLoadedModuleFolders] = useState<Record<string, string[]>>({})
const [loadedModuleFilePaths, setLoadedModuleFilePaths] = useState<Record<string, string[]>>({})
const [moduleFileLoadingByName, setModuleFileLoadingByName] = useState<Record<string, boolean>>({})
const [loadedModuleRoots, setLoadedModuleRoots] = useState<Record<string, string | null>>({})
const [openProjectFolders, setOpenProjectFolders] = useState<Record<string, boolean>>(defaultOpenProjectFolders)
const treeContextMenuRef = useRef<HTMLDivElement>(null)
const toggleProjectFolder = (path: string): void => {
setOpenProjectFolders((folders) => ({ ...folders, [path]: !folders[path] }))
}
return {
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
}
}
@@ -0,0 +1,164 @@
import { useEffect, useMemo, useState } from 'react'
import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
type NoticeTone = 'info' | 'warning' | 'error'
type UseWorkspaceVisualizationOptions = {
profiles: EnvironmentProfile[]
clonePathPreview: string
rosDomainId: string
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
}
type UseWorkspaceVisualizationResult = {
visualizationStacks: VisualizationStack[]
visualizationStack: VisualizationStack
setVisualizationStack: (stack: VisualizationStack) => void
visualizationRunning: boolean
visualizationBusy: boolean
visualizationViewerUrl: string
activeEnvironmentProfile: EnvironmentProfile | null
activeRuntimeImages: RuntimeImageArtifact[]
refreshVisualizationStatus: () => Promise<void>
startVisualizationBridge: () => Promise<void>
stopVisualizationBridge: () => Promise<void>
}
function formatVisualizationStackLabel(stack: VisualizationStack | null | undefined): string {
if (stack === 'nav2') return 'Nav2'
if (stack === 'slam') return 'SLAM'
return 'Autoware'
}
export function useWorkspaceVisualization({
profiles,
clonePathPreview,
rosDomainId,
onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const visualizationStacks = useMemo(() => (
profiles
.map((profile) => profile.profileType.toLowerCase())
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
), [profiles])
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
typeof image === 'object' && image !== null && typeof (image as RuntimeImageArtifact).image === 'string'
))
: []
const activeEnvironmentAgent = typeof activeEnvironmentConfig.agent === 'object' && activeEnvironmentConfig.agent !== null
? activeEnvironmentConfig.agent as Record<string, unknown>
: null
const activeEnvironmentAgentUrl = typeof activeEnvironmentAgent?.url === 'string'
? activeEnvironmentAgent.url.replace(/\/+$/, '')
: ''
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)}`
useEffect(() => {
const profileType = profiles[0]?.profileType?.toLowerCase()
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
setVisualizationStack(profileType)
}
}, [profiles.map((profile) => profile.id).join('|')])
const refreshVisualizationStatus = async (): Promise<void> => {
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<void> => {
if (visualizationBusy) return
if (!activeEnvironmentProfile) {
setVisualizationStatusMessage('지원 안함')
onTitlebarNotice('해당 주차는 시각화를 지원하지 않습니다', 'warning')
return
}
setVisualizationBusy(true)
setVisualizationStatusMessage('시작 중')
try {
const status = await window.localWorkspace.visualization.start(visualizationStack, clonePathPreview, 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<void> => {
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)
}
}
return {
visualizationStacks,
visualizationStack,
setVisualizationStack,
visualizationRunning,
visualizationBusy,
visualizationViewerUrl,
activeEnvironmentProfile,
activeRuntimeImages,
refreshVisualizationStatus,
startVisualizationBridge,
stopVisualizationBridge
}
}
@@ -0,0 +1,203 @@
import type { ModuleRow } from './types'
export type EditorFilePath = string
export type WorkspaceTreeItem =
| { kind: 'folder'; name: string; path: string; parent?: string; depth: number }
| { kind: 'file'; name: string; path: EditorFilePath; parent?: string; depth: number }
export function 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'
}
export function getModuleSourceFiles(module: ModuleRow): Record<string, string> {
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 <rclcpp/rclcpp.hpp>
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 <rclcpp/rclcpp.hpp>
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`]: `<?xml version="1.0"?>
<package format="3">
<name>${packageName}</name>
<version>0.1.0</version>
<description>${module.category} module for autonomy practice.</description>
<maintainer email="edu@autonomy.local">Autonomy Studio</maintainer>
<license>Apache-2.0</license>
<buildtool_depend>ament_cmake</buildtool_depend>
<depend>rclcpp</depend>
<export>
<build_type>ament_cmake</build_type>
</export>
</package>
`
}
}
export function buildModuleTreeItems(
module: ModuleRow,
filePaths = Object.keys(getModuleSourceFiles(module)),
folderPaths: string[] = []
): WorkspaceTreeItem[] {
const rootPath = `modules/${module.name}`
return buildPathTreeItems(rootPath, filePaths, folderPaths)
}
export function buildPathTreeItems(rootPath: string, filePaths: string[], folderPaths: string[] = []): WorkspaceTreeItem[] {
const sourceFiles = filePaths.filter((filePath) => filePath.startsWith(`${rootPath}/`))
const folderMap = new Map<string, { kind: 'folder'; name: string; path: string; parent?: string; depth: number }>()
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<string, WorkspaceTreeItem[]>()
;[...folderMap.values(), ...fileItems].forEach((item) => {
if (!item.parent) return
childrenByParent.set(item.parent, [...(childrenByParent.get(item.parent) ?? []), item])
})
const sortTreeItems = (a: WorkspaceTreeItem, b: WorkspaceTreeItem): number => {
if (a.kind !== b.kind) return a.kind === 'folder' ? -1 : 1
return a.name.localeCompare(b.name)
}
const orderedItems: WorkspaceTreeItem[] = []
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
}
@@ -0,0 +1,19 @@
export 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 })
}
export 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}`
}
@@ -0,0 +1,67 @@
import type { ModuleRow } from './types'
export const maxVisibleModuleLogEntries = 200
export type ModuleLogEntry = {
time: string
level: 'INFO' | 'WARN' | 'ERROR'
source: string
message: string
}
export function getModuleTerminalSessionId(moduleName: string): string {
return `lms-module-terminal:${moduleName}`
}
export 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
}
export function 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
}
})
}
@@ -0,0 +1,27 @@
import type { CurriculumWeek } from '../curriculum/types'
import type { ModuleRow } from './types'
export const compareModulePriority = (a: ModuleRow, b: ModuleRow): number => a.sortOrder - b.sortOrder
export const compareModuleShutdownPriority = (a: ModuleRow, b: ModuleRow): number => b.sortOrder - a.sortOrder
export 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,
sourceType: module.sourceType ?? null,
prebuiltArtifacts: module.prebuiltArtifacts ?? null,
parameters: [],
cpu: null,
memory: null,
runtime: '-'
}))
.sort(compareModulePriority)
}
@@ -0,0 +1,27 @@
export function getPathName(path: string): string {
return path.split('/').pop() ?? path
}
export function getPathParent(path: string): string {
return path.split('/').slice(0, -1).join('/')
}
export function replacePathPrefix(path: string, fromPath: string, toPath: string): string {
return path === fromPath ? toPath : path.startsWith(`${fromPath}/`) ? `${toPath}${path.slice(fromPath.length)}` : path
}
export function makeUniquePath(basePath: string, files: Record<string, string>, 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
}
}