patch..
This commit is contained in:
@@ -3,4 +3,5 @@ out
|
|||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
*.log
|
*.log
|
||||||
|
*.tsbuildinfo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "lms",
|
"name": "lms",
|
||||||
"version": "1.1.16",
|
"version": "1.1.17",
|
||||||
"description": "Autonomy Studio LMS Electron app",
|
"description": "Autonomy Studio LMS Electron app",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
+55
-14
@@ -1,6 +1,6 @@
|
|||||||
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron'
|
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } from 'electron'
|
||||||
import type { WebContents } from 'electron'
|
import type { WebContents } from 'electron'
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from 'child_process'
|
import { spawn } from 'child_process'
|
||||||
import * as pty from 'node-pty'
|
import * as pty from 'node-pty'
|
||||||
import type { IPty } from 'node-pty'
|
import type { IPty } from 'node-pty'
|
||||||
import { appendFileSync, createWriteStream, existsSync, mkdirSync } from 'fs'
|
import { appendFileSync, createWriteStream, existsSync, mkdirSync } from 'fs'
|
||||||
@@ -245,7 +245,7 @@ type RosNetworkConfig = {
|
|||||||
peer?: RemoteWorkspaceTarget | null
|
peer?: RemoteWorkspaceTarget | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type VisualizationStack = 'autoware' | 'nav2' | 'slam'
|
type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
|
||||||
|
|
||||||
type VisualizationBridgeStatus = {
|
type VisualizationBridgeStatus = {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
@@ -1212,8 +1212,9 @@ async function resolveRemoteCustomModuleRuntimeImage(
|
|||||||
folderName: string,
|
folderName: string,
|
||||||
fallbackImage: string,
|
fallbackImage: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const output = await runSshOutput(target, [
|
const normalizedTarget = normalizeRemoteTarget(target)
|
||||||
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
|
const output = await runSshOutput(normalizedTarget, [
|
||||||
|
`ROOT=${quoteShellSingle(normalizedTarget.workspaceRoot)}`,
|
||||||
...expandRemotePathVar('ROOT'),
|
...expandRemotePathVar('ROOT'),
|
||||||
`CONFIG="$ROOT/${folderName}/.autonomy-studio-module.json"`,
|
`CONFIG="$ROOT/${folderName}/.autonomy-studio-module.json"`,
|
||||||
'if [ -f "$CONFIG" ]; then python3 - "$CONFIG" <<\'PY\'',
|
'if [ -f "$CONFIG" ]; then python3 - "$CONFIG" <<\'PY\'',
|
||||||
@@ -1524,6 +1525,31 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function listLocalDockerImages(): Promise<{ images: string[]; message?: string }> {
|
||||||
|
try {
|
||||||
|
const output = await runCommandOutput('docker', ['image', 'ls', '--format', '{{json .}}'], undefined, undefined, 10_000)
|
||||||
|
const images = Array.from(new Set(output.split('\n').flatMap((line) => {
|
||||||
|
if (!line.trim()) return []
|
||||||
|
try {
|
||||||
|
const image = JSON.parse(line) as { Repository?: unknown; Tag?: unknown }
|
||||||
|
const repository = typeof image.Repository === 'string' ? image.Repository.trim() : ''
|
||||||
|
const tag = typeof image.Tag === 'string' ? image.Tag.trim() : ''
|
||||||
|
if (!repository || !tag || repository === '<none>' || tag === '<none>') return []
|
||||||
|
return [`${repository}:${tag}`]
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}))).sort((left, right) => left.localeCompare(right))
|
||||||
|
|
||||||
|
return { images }
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
images: [],
|
||||||
|
message: error instanceof Error ? error.message : '로컬 Docker 이미지를 불러오지 못했습니다.'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const rosGraphMarker = '__AUTONOMY_STUDIO_ROS_GRAPH__'
|
const rosGraphMarker = '__AUTONOMY_STUDIO_ROS_GRAPH__'
|
||||||
const rosGraphProbeScript = [
|
const rosGraphProbeScript = [
|
||||||
'import json, rclpy',
|
'import json, rclpy',
|
||||||
@@ -1799,7 +1825,7 @@ function remoteSshDestination(target: RemoteWorkspaceTarget): string {
|
|||||||
async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget>, task: () => Promise<T>): Promise<T> {
|
async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget>, task: () => Promise<T>): Promise<T> {
|
||||||
const key = `${target.user}@${target.host}:${target.port}`
|
const key = `${target.user}@${target.host}:${target.port}`
|
||||||
const previous = remoteSshQueues.get(key) ?? Promise.resolve()
|
const previous = remoteSshQueues.get(key) ?? Promise.resolve()
|
||||||
let release: (() => void) | null = null
|
let release: () => void = () => undefined
|
||||||
const current = new Promise<void>((resolvePromise) => {
|
const current = new Promise<void>((resolvePromise) => {
|
||||||
release = resolvePromise
|
release = resolvePromise
|
||||||
})
|
})
|
||||||
@@ -1810,7 +1836,7 @@ async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget
|
|||||||
try {
|
try {
|
||||||
return await task()
|
return await task()
|
||||||
} finally {
|
} finally {
|
||||||
release?.()
|
release()
|
||||||
if (remoteSshQueues.get(key) === queued) remoteSshQueues.delete(key)
|
if (remoteSshQueues.get(key) === queued) remoteSshQueues.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3219,7 +3245,7 @@ async function readVisualizationBridgeStatus(): Promise<VisualizationBridgeStatu
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function waitForVisualizationBridgeReady(stack: VisualizationStack, bridgePort: number, timeoutMs = 20000): Promise<void> {
|
async function waitForVisualizationBridgeReady(stack: VisualizationStack, bridgePort: number, timeoutMs = 20000): Promise<void> {
|
||||||
const endpoint = stack === 'slam' || stack === 'nav2' ? 'health' : 'stack'
|
const endpoint = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' ? 'health' : 'stack'
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
let lastMessage = ''
|
let lastMessage = ''
|
||||||
|
|
||||||
@@ -4993,6 +5019,26 @@ async function stopRemoteWorkspaceModules(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetRemoteRosSession(
|
||||||
|
targetInput: RemoteWorkspaceTarget,
|
||||||
|
): Promise<{ ok: boolean; message: string }> {
|
||||||
|
try {
|
||||||
|
const target = normalizeRemoteTarget(targetInput)
|
||||||
|
await runSsh(target, [
|
||||||
|
'set -e',
|
||||||
|
// Module containers are always named from their LMS module folder. Remove every
|
||||||
|
// one so a prior control session cannot keep publishing on the shared domain.
|
||||||
|
'MODULE_CONTAINERS="$(docker ps -a --format \"{{.ID}} {{.Names}}\" 2>/dev/null | awk \'$2 ~ /^lms-/ { print $1 }\')"',
|
||||||
|
'if [ -n "$MODULE_CONTAINERS" ]; then docker rm -f $MODULE_CONTAINERS >/dev/null 2>&1 || true; fi',
|
||||||
|
'docker rm -f nav2-ros-bridge autoware-ros-bridge slam-ros-bridge >/dev/null 2>&1 || true',
|
||||||
|
'find "$HOME/autonomy-studio/runtime" "$HOME/.autonomy-studio/runtime" -type f -path "*/docker/fastdds-peer-*.xml" -delete 2>/dev/null || true',
|
||||||
|
].join('\n'), 45_000)
|
||||||
|
return { ok: true, message: '원격 ROS 연결을 초기화했습니다.' }
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, message: error instanceof Error ? error.message : String(error) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resetRemoteWorkspaceSession(
|
async function resetRemoteWorkspaceSession(
|
||||||
targetInput: RemoteWorkspaceTarget,
|
targetInput: RemoteWorkspaceTarget,
|
||||||
modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>,
|
modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>,
|
||||||
@@ -5328,13 +5374,6 @@ function stopTerminalsForWebContents(webContentsId: number): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleStopTerminal(terminalKey: string): void {
|
|
||||||
const currentTimer = terminalStopTimers.get(terminalKey)
|
|
||||||
if (currentTimer) clearTimeout(currentTimer)
|
|
||||||
const timer = setTimeout(() => stopTerminal(terminalKey), 300)
|
|
||||||
terminalStopTimers.set(terminalKey, timer)
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelStopTerminal(terminalKey: string): void {
|
function cancelStopTerminal(terminalKey: string): void {
|
||||||
const timer = terminalStopTimers.get(terminalKey)
|
const timer = terminalStopTimers.get(terminalKey)
|
||||||
if (!timer) return
|
if (!timer) return
|
||||||
@@ -5743,6 +5782,7 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
closeSplashAndShowMain()
|
closeSplashAndShowMain()
|
||||||
})
|
})
|
||||||
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:docker-images', () => listLocalDockerImages())
|
||||||
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
||||||
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: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:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => {
|
ipcMain.handle('local-workspace:modules:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => {
|
||||||
@@ -5815,6 +5855,7 @@ function registerLocalWorkspaceHandlers(): void {
|
|||||||
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, folderName))
|
ipcMain.handle('remote-workspace:modules:stop', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => stopRemoteWorkspaceModule(target, moduleName, 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; templateRepositoryUrl?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
|
ipcMain.handle('remote-workspace:modules:reset-session', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => resetRemoteWorkspaceSession(target, modules))
|
||||||
|
ipcMain.handle('remote-workspace:ros:reset-session', (_event, target: RemoteWorkspaceTarget) => resetRemoteRosSession(target))
|
||||||
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, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
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, rosNetwork?: RosNetworkConfig | null) => restartRemoteWorkspaceModule(target, moduleName, folderName, rosParams, gpuEnabled, rosDomainId, runtimeImages, rosNetwork))
|
||||||
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
|
ipcMain.handle('remote-workspace:modules:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
|
||||||
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
|
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ contextBridge.exposeInMainWorld('terminal', {
|
|||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('visualization', {
|
contextBridge.exposeInMainWorld('visualization', {
|
||||||
status: () => ipcRenderer.invoke('visualization:status'),
|
status: () => ipcRenderer.invoke('visualization:status'),
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork),
|
||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
||||||
})
|
})
|
||||||
contextBridge.exposeInMainWorld('localWorkspace', {
|
contextBridge.exposeInMainWorld('localWorkspace', {
|
||||||
@@ -193,6 +193,7 @@ 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),
|
||||||
|
listDockerImages: () => ipcRenderer.invoke('local-workspace:modules:docker-images'),
|
||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => ipcRenderer.invoke('local-workspace:modules:install', moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options),
|
||||||
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
|
createCustom: (workspaceRoot: string | undefined, input: { name: string; category: string; folderName: string; dockerImage?: string | null; packageName: string; launchFile: string }) => ipcRenderer.invoke('local-workspace:modules:create-custom', workspaceRoot, input),
|
||||||
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),
|
||||||
@@ -225,7 +226,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
|
|||||||
},
|
},
|
||||||
visualization: {
|
visualization: {
|
||||||
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
status: () => ipcRenderer.invoke('local-workspace:visualization:status'),
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath),
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => ipcRenderer.invoke('local-workspace:visualization:start', stack, workspaceRoot, rosDomainId, environmentProfile, rosNetwork, savedMapDatabasePath),
|
||||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('local-workspace:visualization:stop', environmentProfile)
|
||||||
},
|
},
|
||||||
git: {
|
git: {
|
||||||
@@ -290,9 +291,12 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
|||||||
rosGraph: {
|
rosGraph: {
|
||||||
snapshot: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros-graph', target)
|
snapshot: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros-graph', target)
|
||||||
},
|
},
|
||||||
|
ros: {
|
||||||
|
resetSession: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros:reset-session', target)
|
||||||
|
},
|
||||||
visualization: {
|
visualization: {
|
||||||
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
|
status: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:status', target),
|
||||||
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork),
|
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => ipcRenderer.invoke('remote-workspace:visualization:start', target, stack, rosDomainId, environmentProfile, rosNetwork),
|
||||||
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
|
stop: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:visualization:stop', target)
|
||||||
},
|
},
|
||||||
git: {
|
git: {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.6 MiB After Width: | Height: | Size: 2.5 MiB |
+58
-15
@@ -1285,15 +1285,19 @@ function LocalPcWorkspaceMockup({
|
|||||||
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
|
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (payload.type !== 'map-saved' || !payload.map?.yamlPath || !payload.map.pgmPath) return
|
const yamlPath = payload.map?.yamlPath
|
||||||
|
const pgmPath = payload.map?.pgmPath
|
||||||
|
const savedMapDatabasePath = payload.map?.databasePath ?? null
|
||||||
|
const savedMapPreviewPgm = payload.map?.previewPgm ?? null
|
||||||
|
if (payload.type !== 'map-saved' || typeof yamlPath !== 'string' || typeof pgmPath !== 'string') return
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
let databasePath = payload.map.databasePath ?? null
|
let databasePath = savedMapDatabasePath
|
||||||
const savesThreeDimensionalMap = remoteWorkspace
|
const savesThreeDimensionalMap = remoteWorkspace
|
||||||
&& visualizationBridgeLocation === 'local'
|
&& visualizationBridgeLocation === 'local'
|
||||||
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||||
if (savesThreeDimensionalMap && !databasePath) {
|
if (savesThreeDimensionalMap && !databasePath) {
|
||||||
const databaseFileName = payload.map.yamlPath.replace(/\.yaml$/i, '.db')
|
const databaseFileName = yamlPath.replace(/\.yaml$/i, '.db')
|
||||||
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
|
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
|
||||||
getRemoteWorkspaceTarget(),
|
getRemoteWorkspaceTarget(),
|
||||||
localVisualizationWorkspaceRoot,
|
localVisualizationWorkspaceRoot,
|
||||||
@@ -1302,8 +1306,8 @@ function LocalPcWorkspaceMockup({
|
|||||||
databasePath = copied.databasePath
|
databasePath = copied.databasePath
|
||||||
}
|
}
|
||||||
const sourceArtifacts = [
|
const sourceArtifacts = [
|
||||||
{ name: 'yaml' as const, path: payload.map.yamlPath },
|
{ name: 'yaml' as const, path: yamlPath },
|
||||||
{ name: 'pgm' as const, path: payload.map.pgmPath },
|
{ name: 'pgm' as const, path: pgmPath },
|
||||||
...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
|
...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
|
||||||
]
|
]
|
||||||
// A remote ROS session can still use a bridge running on this PC.
|
// A remote ROS session can still use a bridge running on this PC.
|
||||||
@@ -1323,9 +1327,9 @@ function LocalPcWorkspaceMockup({
|
|||||||
form.set('viewerModel', selectedWorldMap.viewerModel)
|
form.set('viewerModel', selectedWorldMap.viewerModel)
|
||||||
form.set('runtimeKind', selectedWorldMap.runtimeKind
|
form.set('runtimeKind', selectedWorldMap.runtimeKind
|
||||||
?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? 'PLATFORM_2D' : 'SIMULATION'))
|
?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? 'PLATFORM_2D' : 'SIMULATION'))
|
||||||
form.set('yamlPath', payload.map.yamlPath)
|
form.set('yamlPath', yamlPath)
|
||||||
form.set('pgmPath', payload.map.pgmPath)
|
form.set('pgmPath', pgmPath)
|
||||||
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
|
if (savedMapPreviewPgm) form.set('previewPgm', savedMapPreviewPgm)
|
||||||
if (databasePath) form.set('databasePath', databasePath)
|
if (databasePath) form.set('databasePath', databasePath)
|
||||||
artifacts.forEach((artifact) => {
|
artifacts.forEach((artifact) => {
|
||||||
const binary = atob(artifact.base64)
|
const binary = atob(artifact.base64)
|
||||||
@@ -1648,7 +1652,6 @@ function LocalPcWorkspaceMockup({
|
|||||||
setLoadedModuleRoots,
|
setLoadedModuleRoots,
|
||||||
createdModuleFolders,
|
createdModuleFolders,
|
||||||
setCreatedModuleFolders,
|
setCreatedModuleFolders,
|
||||||
openProjectFolders,
|
|
||||||
setOpenProjectFolders,
|
setOpenProjectFolders,
|
||||||
treeContextMenu,
|
treeContextMenu,
|
||||||
setTreeContextMenu,
|
setTreeContextMenu,
|
||||||
@@ -3331,7 +3334,7 @@ function LocalPcWorkspaceMockup({
|
|||||||
packageName: payload.packageName ?? input.packageName,
|
packageName: payload.packageName ?? input.packageName,
|
||||||
launchFile: payload.launchFile ?? input.launchFile,
|
launchFile: payload.launchFile ?? input.launchFile,
|
||||||
runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget,
|
runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget,
|
||||||
} as const
|
} satisfies NonNullable<CurriculumWeek['modules']>[number]
|
||||||
if (remoteWorkspace) {
|
if (remoteWorkspace) {
|
||||||
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
|
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
|
||||||
} else {
|
} else {
|
||||||
@@ -3949,7 +3952,6 @@ function LocalPcWorkspaceMockup({
|
|||||||
{pendingWorldMapRestart && (
|
{pendingWorldMapRestart && (
|
||||||
<WorldMapRestartConfirmModal
|
<WorldMapRestartConfirmModal
|
||||||
map={pendingWorldMapRestart}
|
map={pendingWorldMapRestart}
|
||||||
onCancel={() => setPendingWorldMapRestart(null)}
|
|
||||||
onConfirm={() => setPendingWorldMapRestart(null)}
|
onConfirm={() => setPendingWorldMapRestart(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -4107,6 +4109,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const [platformModalMode, setPlatformModalMode] = useState<'select' | 'settings'>('select')
|
const [platformModalMode, setPlatformModalMode] = useState<'select' | 'settings'>('select')
|
||||||
const [platformSettingsTarget, setPlatformSettingsTarget] = useState<PlatformOption | null>(null)
|
const [platformSettingsTarget, setPlatformSettingsTarget] = useState<PlatformOption | null>(null)
|
||||||
const [platformSettingsDraft, setPlatformSettingsDraft] = useState<StudioSourceSettings>(defaultStudioSourceSettings)
|
const [platformSettingsDraft, setPlatformSettingsDraft] = useState<StudioSourceSettings>(defaultStudioSourceSettings)
|
||||||
|
const [remoteRosResetting, setRemoteRosResetting] = useState(false)
|
||||||
const [recentSessionModalOpen, setRecentSessionModalOpen] = useState(false)
|
const [recentSessionModalOpen, setRecentSessionModalOpen] = useState(false)
|
||||||
const [instructorStudioModalOpen, setInstructorStudioModalOpen] = useState(false)
|
const [instructorStudioModalOpen, setInstructorStudioModalOpen] = useState(false)
|
||||||
const [materialModalOpen, setMaterialModalOpen] = useState(false)
|
const [materialModalOpen, setMaterialModalOpen] = useState(false)
|
||||||
@@ -4299,6 +4302,31 @@ export default function App(): React.JSX.Element {
|
|||||||
workspaceRoot: remoteRuntimeRoot
|
workspaceRoot: remoteRuntimeRoot
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const resetRemotePlatformRosSession = async (platform: PlatformOption, options: { notify?: boolean } = {}): Promise<boolean> => {
|
||||||
|
if (platform.platformKey === 'futuredrive-local-pc') return true
|
||||||
|
if (!hasCurrentUserPlatformControl(platform)) {
|
||||||
|
if (options.notify !== false) showTitlebarNotice('현재 제어권을 가진 사용자만 ROS 연결을 초기화할 수 있습니다.', 'warning')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const target = makeRemoteWorkspaceTargetForPlatform(platform)
|
||||||
|
if (!target) {
|
||||||
|
if (options.notify !== false) showTitlebarNotice('원격 플랫폼 연결 정보를 찾지 못했습니다.', 'error')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
setRemoteRosResetting(true)
|
||||||
|
try {
|
||||||
|
const result = await window.remoteWorkspace.ros.resetSession(target)
|
||||||
|
if (!result.ok) throw new Error(result.message || '원격 ROS 연결 초기화에 실패했습니다.')
|
||||||
|
if (options.notify !== false) showTitlebarNotice('원격 ROS 연결을 초기화했습니다.', 'info')
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (options.notify !== false) showTitlebarNotice(error instanceof Error ? error.message : '원격 ROS 연결 초기화에 실패했습니다.', 'error')
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
setRemoteRosResetting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
const stopRemotePlatformModules = async (platform: PlatformOption): Promise<void> => {
|
const stopRemotePlatformModules = async (platform: PlatformOption): Promise<void> => {
|
||||||
const target = makeRemoteWorkspaceTargetForPlatform(platform)
|
const target = makeRemoteWorkspaceTargetForPlatform(platform)
|
||||||
if (!target) return
|
if (!target) return
|
||||||
@@ -4653,7 +4681,7 @@ export default function App(): React.JSX.Element {
|
|||||||
|
|
||||||
const nextRunningPlatforms = restored.runningPlatformIds
|
const nextRunningPlatforms = restored.runningPlatformIds
|
||||||
.map((id) => platformOptions.find((platform) => platform.id === id))
|
.map((id) => platformOptions.find((platform) => platform.id === id))
|
||||||
.filter((platform): platform is PlatformOption => Boolean(platform) && hasCurrentUserPlatformControl(platform))
|
.filter((platform): platform is PlatformOption => platform !== undefined && hasCurrentUserPlatformControl(platform))
|
||||||
const nextConnectedPlatform = restored.connectedPlatformId
|
const nextConnectedPlatform = restored.connectedPlatformId
|
||||||
? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null
|
? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null
|
||||||
: nextRunningPlatforms[0] ?? null
|
: nextRunningPlatforms[0] ?? null
|
||||||
@@ -4897,7 +4925,7 @@ export default function App(): React.JSX.Element {
|
|||||||
const canExtendConnectedPlatform = Boolean(
|
const canExtendConnectedPlatform = Boolean(
|
||||||
canReleaseConnectedPlatform
|
canReleaseConnectedPlatform
|
||||||
&& (connectedPlatform?.control?.warning || controlWarningMatchesConnectedPlatform)
|
&& (connectedPlatform?.control?.warning || controlWarningMatchesConnectedPlatform)
|
||||||
&& (connectedPlatform.control.session?.extensionCount ?? 0) < 1,
|
&& (connectedPlatform?.control?.session?.extensionCount ?? 0) < 1,
|
||||||
)
|
)
|
||||||
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
|
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
|
||||||
? connectedPlatform?.control?.session?.endsAt ?? null
|
? connectedPlatform?.control?.session?.endsAt ?? null
|
||||||
@@ -5270,7 +5298,8 @@ export default function App(): React.JSX.Element {
|
|||||||
setControlExtensionSubmitting(true)
|
setControlExtensionSubmitting(true)
|
||||||
try {
|
try {
|
||||||
const result = await extendPlatformControl(fetchWithFreshAuth, connectedPlatform.id)
|
const result = await extendPlatformControl(fetchWithFreshAuth, connectedPlatform.id)
|
||||||
if (!result.session) throw new Error('연장된 제어 시간을 확인하지 못했습니다.')
|
const extendedSession = result.session
|
||||||
|
if (!extendedSession) throw new Error('연장된 제어 시간을 확인하지 못했습니다.')
|
||||||
|
|
||||||
const updatePlatform = (platform: PlatformOption): PlatformOption => {
|
const updatePlatform = (platform: PlatformOption): PlatformOption => {
|
||||||
if (platform.id !== connectedPlatform.id || !platform.control) return platform
|
if (platform.id !== connectedPlatform.id || !platform.control) return platform
|
||||||
@@ -5279,7 +5308,7 @@ export default function App(): React.JSX.Element {
|
|||||||
control: {
|
control: {
|
||||||
...platform.control,
|
...platform.control,
|
||||||
warning: false,
|
warning: false,
|
||||||
session: result.session,
|
session: extendedSession,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5575,6 +5604,15 @@ export default function App(): React.JSX.Element {
|
|||||||
setPlatformLoading(true)
|
setPlatformLoading(true)
|
||||||
try {
|
try {
|
||||||
const connected = await connectRemotePlatform(platform)
|
const connected = await connectRemotePlatform(platform)
|
||||||
|
if (connected.platformKey !== 'futuredrive-local-pc') {
|
||||||
|
setPlatformLoadingCopy({
|
||||||
|
title: `${connected.title} ROS 연결 초기화 중`,
|
||||||
|
description: '이전 제어 세션의 모듈과 피어 연결을 정리하고 있습니다.'
|
||||||
|
})
|
||||||
|
if (!await resetRemotePlatformRosSession(connected, { notify: false })) {
|
||||||
|
throw new Error('원격 ROS 연결 초기화에 실패했습니다.')
|
||||||
|
}
|
||||||
|
}
|
||||||
const workspaceWeekSnapshot = selectedWeekState
|
const workspaceWeekSnapshot = selectedWeekState
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setConnectedPlatform(connected)
|
setConnectedPlatform(connected)
|
||||||
@@ -5949,7 +5987,12 @@ export default function App(): React.JSX.Element {
|
|||||||
clonePath={selectedWeek.isTeamProject && studentGroup?.id
|
clonePath={selectedWeek.isTeamProject && studentGroup?.id
|
||||||
? makeTeamWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, studentGroup.id, courseKey, selectedWeek.week)
|
? makeTeamWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, studentGroup.id, courseKey, selectedWeek.week)
|
||||||
: makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), courseKey, selectedWeek.week)}
|
: makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), courseKey, selectedWeek.week)}
|
||||||
|
canResetRosSession={Boolean(platformSettingsTarget && hasCurrentUserPlatformControl(platformSettingsTarget))}
|
||||||
|
resettingRosSession={remoteRosResetting}
|
||||||
onDraftChange={setPlatformSettingsDraft}
|
onDraftChange={setPlatformSettingsDraft}
|
||||||
|
onResetRosSession={() => {
|
||||||
|
if (platformSettingsTarget) void resetRemotePlatformRosSession(platformSettingsTarget)
|
||||||
|
}}
|
||||||
onClose={closePlatformSettings}
|
onClose={closePlatformSettings}
|
||||||
onSave={savePlatformSettings}
|
onSave={savePlatformSettings}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Vendored
+11
-7
@@ -108,6 +108,7 @@ interface Window {
|
|||||||
runtime: string
|
runtime: string
|
||||||
}>
|
}>
|
||||||
}>
|
}>
|
||||||
|
listDockerImages: () => Promise<{ images: string[]; message?: string }>
|
||||||
install: (moduleName: string, workspaceRoot?: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | 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: {
|
||||||
@@ -232,19 +233,19 @@ interface Window {
|
|||||||
status: () => Promise<{
|
status: () => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
start: (stack?: 'autoware' | 'nav2' | 'slam', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{
|
start: (stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', workspaceRoot?: string, rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null, savedMapDatabasePath?: string | null) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
stop: (environmentProfile?: unknown) => Promise<{
|
stop: (environmentProfile?: unknown) => Promise<{
|
||||||
ok: boolean
|
ok: boolean
|
||||||
running: boolean
|
running: boolean
|
||||||
stack: 'autoware' | 'nav2' | 'slam' | null
|
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
|
||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
@@ -336,10 +337,13 @@ interface Window {
|
|||||||
message?: string
|
message?: string
|
||||||
}>
|
}>
|
||||||
}
|
}
|
||||||
|
ros: {
|
||||||
|
resetSession: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; message: string }>
|
||||||
|
}
|
||||||
visualization: {
|
visualization: {
|
||||||
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
|
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
||||||
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
|
start: (target: RemoteWorkspaceTarget, stack?: 'autoware' | 'nav2' | 'slam' | 'slam3d', rosDomainId?: string, environmentProfile?: unknown, rosNetwork?: RosNetworkConfig | null) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
||||||
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | null; message?: string }>
|
stop: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null; message?: string }>
|
||||||
}
|
}
|
||||||
git: {
|
git: {
|
||||||
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']>
|
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']>
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ export default function AiGuideView(props: Props): React.JSX.Element {
|
|||||||
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
|
{busy && <article className="w-full"><div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3 text-[12px] font-semibold text-violet-200"><LoaderCircle size={15} className="animate-spin" /> 생각 중</div></article>}
|
||||||
</div>}
|
</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact accent="cyan" busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
|
<div className="pointer-events-none absolute inset-x-0 bottom-3 z-10 px-6"><div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark accent="cyan" busy={busy} disabled={disabled || viewingHistory || switchingConversation} onStop={onStop} onSubmit={onSubmit} /></div></div>
|
||||||
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>}
|
{switchingConversation && <div className="absolute inset-0 z-20 grid place-items-center bg-[#10131b]/78 backdrop-blur-[2px]"><span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#252238] px-4 py-3 text-[12px] font-semibold text-white/76"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span></div>}
|
||||||
</main>
|
</main>
|
||||||
<aside data-help="ai-guide-questions" className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
|
<aside data-help="ai-guide-questions" className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#191b2a]"><div className="flex h-10 shrink-0 items-center border-b border-white/10 px-3"><span className="text-[11px] font-bold text-white/44">질문 목록</span></div><div className="thin-y-scrollbar min-h-0 flex-1 overflow-y-auto p-2">{questionMessages.length ? <div className="grid gap-1">{questionMessages.map((message) => <button key={message.id} type="button" onClick={() => { setSelectedQuestionId(message.id); questionRefs.current[message.id]?.scrollIntoView({ behavior: 'smooth', block: 'center' }) }} className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-cyan-400/10 text-cyan-100/85' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}><span className="flex w-full min-w-0 items-start gap-2"><span className="min-w-0 flex-1 truncate text-[11px] font-medium leading-relaxed">{message.content}</span><time className="shrink-0 pt-0.5 text-[10px] font-medium text-white/32">{formatTime(message.createdAt)}</time></span></button>)}</div> : <div className="grid min-h-[160px] place-items-center px-3 text-center text-[11px] leading-relaxed text-white/34">질문을 보내면 이곳에 표시됩니다.</div>}</div></aside>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import WeekCard from './WeekCard'
|
|||||||
type CurriculumHomeWeek = {
|
type CurriculumHomeWeek = {
|
||||||
week: number
|
week: number
|
||||||
title: string
|
title: string
|
||||||
|
duration: string
|
||||||
tags: string[]
|
tags: string[]
|
||||||
progress: number
|
progress: number
|
||||||
state: 'done' | 'current' | 'locked' | 'ready'
|
state: 'done' | 'current' | 'locked' | 'ready'
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export default function LearningInfoCard({
|
|||||||
{[
|
{[
|
||||||
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
|
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
|
||||||
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }
|
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }
|
||||||
].map(({ label, value, Icon, valueClassName }) => (
|
].map(({ label, value, Icon }) => (
|
||||||
<div
|
<div
|
||||||
key={label}
|
key={label}
|
||||||
className={[
|
className={[
|
||||||
@@ -71,7 +71,7 @@ export default function LearningInfoCard({
|
|||||||
<Icon size={14} className="shrink-0 text-[#6d7788]" />
|
<Icon size={14} className="shrink-0 text-[#6d7788]" />
|
||||||
<span className="truncate">{label}</span>
|
<span className="truncate">{label}</span>
|
||||||
</span>
|
</span>
|
||||||
<strong className={['shrink-0 text-right text-[12px] font-bold', valueClassName ?? 'text-[#151827]'].join(' ')}>
|
<strong className="shrink-0 text-right text-[12px] font-bold text-[#151827]">
|
||||||
{value}
|
{value}
|
||||||
</strong>
|
</strong>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ type PlatformSettingsPanelProps = {
|
|||||||
target: PlatformOption | null
|
target: PlatformOption | null
|
||||||
draft: StudioSourceSettings
|
draft: StudioSourceSettings
|
||||||
clonePath: string
|
clonePath: string
|
||||||
|
canResetRosSession: boolean
|
||||||
|
resettingRosSession: boolean
|
||||||
onDraftChange: (updater: (settings: StudioSourceSettings) => StudioSourceSettings) => void
|
onDraftChange: (updater: (settings: StudioSourceSettings) => StudioSourceSettings) => void
|
||||||
|
onResetRosSession: () => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
}
|
}
|
||||||
@@ -15,7 +18,10 @@ export default function PlatformSettingsPanel({
|
|||||||
target,
|
target,
|
||||||
draft,
|
draft,
|
||||||
clonePath,
|
clonePath,
|
||||||
|
canResetRosSession,
|
||||||
|
resettingRosSession,
|
||||||
onDraftChange,
|
onDraftChange,
|
||||||
|
onResetRosSession,
|
||||||
onClose,
|
onClose,
|
||||||
onSave
|
onSave
|
||||||
}: PlatformSettingsPanelProps): React.JSX.Element {
|
}: PlatformSettingsPanelProps): React.JSX.Element {
|
||||||
@@ -36,6 +42,27 @@ export default function PlatformSettingsPanel({
|
|||||||
<div className="mt-4 rounded-md bg-white px-3 py-2 text-[12px] font-semibold text-[#4f596b]">
|
<div className="mt-4 rounded-md bg-white px-3 py-2 text-[12px] font-semibold text-[#4f596b]">
|
||||||
실행 방식: 세션 단위 임시 런타임
|
실행 방식: 세션 단위 임시 런타임
|
||||||
</div>
|
</div>
|
||||||
|
{canResetRosSession && (
|
||||||
|
<div className="mt-4 flex items-center justify-between gap-4 rounded-md border border-[#dfe3ed] bg-white px-3 py-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-[12px] font-black text-[#273142]">ROS 연결 초기화</p>
|
||||||
|
<p className="mt-1 text-[11px] font-medium leading-relaxed text-[#697386]">실행 중인 모듈과 이전 피어 설정을 정리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onResetRosSession}
|
||||||
|
disabled={resettingRosSession}
|
||||||
|
className={[
|
||||||
|
'h-8 shrink-0 rounded-md border px-3 text-[11px] font-bold transition',
|
||||||
|
resettingRosSession
|
||||||
|
? 'cursor-not-allowed border-[#d7dde8] bg-[#f3f5f8] text-[#9aa4b2]'
|
||||||
|
: 'cursor-pointer border-[#d7dde8] bg-white text-[#4f596b] hover:border-[#2563eb] hover:text-[#2563eb]'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{resettingRosSession ? '초기화 중' : '초기화'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
|
import { BookOpen, ChevronLeft, ChevronRight, PackagePlus, X } from 'lucide-react'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
|
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
|
||||||
import ModuleListEmptyState from './ModuleListEmptyState'
|
import ModuleListEmptyState from './ModuleListEmptyState'
|
||||||
@@ -87,6 +87,8 @@ export default function ModuleListView({
|
|||||||
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
|
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
|
||||||
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
|
const [moduleAddDialogOpen, setModuleAddDialogOpen] = useState(false)
|
||||||
const [moduleGuideOpen, setModuleGuideOpen] = useState(false)
|
const [moduleGuideOpen, setModuleGuideOpen] = useState(false)
|
||||||
|
const [floatingActionMenuOpen, setFloatingActionMenuOpen] = useState(false)
|
||||||
|
const floatingActionMenuRef = useRef<HTMLDivElement>(null)
|
||||||
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
|
const [draggedModule, setDraggedModule] = useState<ModuleRow | null>(null)
|
||||||
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
|
const [dropTargetModuleName, setDropTargetModuleName] = useState<string | null>(null)
|
||||||
const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null)
|
const [dropZone, setDropZone] = useState<'active' | 'parking' | null>(null)
|
||||||
@@ -108,6 +110,25 @@ export default function ModuleListView({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!floatingActionMenuOpen) return
|
||||||
|
|
||||||
|
const dismissOnOutsidePointerDown = (event: PointerEvent): void => {
|
||||||
|
if (event.target instanceof Node && floatingActionMenuRef.current?.contains(event.target)) return
|
||||||
|
setFloatingActionMenuOpen(false)
|
||||||
|
}
|
||||||
|
const dismissOnEscape = (event: KeyboardEvent): void => {
|
||||||
|
if (event.key === 'Escape') setFloatingActionMenuOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('pointerdown', dismissOnOutsidePointerDown)
|
||||||
|
document.addEventListener('keydown', dismissOnEscape)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', dismissOnOutsidePointerDown)
|
||||||
|
document.removeEventListener('keydown', dismissOnEscape)
|
||||||
|
}
|
||||||
|
}, [floatingActionMenuOpen])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!parkingHeightStorageKey) {
|
if (!parkingHeightStorageKey) {
|
||||||
setParkingHeight(232)
|
setParkingHeight(232)
|
||||||
@@ -290,26 +311,6 @@ export default function ModuleListView({
|
|||||||
) : filteredModuleRows.length === 0 ? (
|
) : filteredModuleRows.length === 0 ? (
|
||||||
<ModuleListEmptyState dark={dark} />
|
<ModuleListEmptyState dark={dark} />
|
||||||
) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
|
) : filteredModuleRows.map((row) => renderModuleRow(row, false))}
|
||||||
{!loading && (
|
|
||||||
<div className={['flex items-center justify-between px-3 pb-6 pt-2', dark ? 'bg-[#242424]' : 'bg-[#fbfcff]'].join(' ')}>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setModuleGuideOpen(true)}
|
|
||||||
className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
|
|
||||||
>
|
|
||||||
모듈 가이드
|
|
||||||
</button>
|
|
||||||
{allowCustomModuleCreation && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setModuleAddDialogOpen(true)}
|
|
||||||
className={['inline-flex cursor-pointer items-center text-[12px] font-bold underline underline-offset-4 transition', dark ? 'text-[#6ea8ff] hover:text-white' : 'text-[#1d4ed8] hover:text-[#1e40af]'].join(' ')}
|
|
||||||
>
|
|
||||||
+ 모듈 추가
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -348,6 +349,61 @@ export default function ModuleListView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div ref={floatingActionMenuRef} className="absolute -left-7 bottom-6 z-[60] h-14 w-14">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="실행 방법 메뉴"
|
||||||
|
aria-expanded={floatingActionMenuOpen}
|
||||||
|
onClick={() => setFloatingActionMenuOpen((open) => !open)}
|
||||||
|
className={[
|
||||||
|
'grid h-14 w-14 cursor-pointer place-items-center rounded-full text-center text-[11px] font-bold shadow-[0_10px_26px_rgba(20,54,104,0.28)] transition hover:-translate-y-0.5 hover:scale-[1.05]',
|
||||||
|
floatingActionMenuOpen ? 'bg-[#4b5563]/85 text-[#c7cfda] hover:bg-[#3f4855]/90' : 'bg-[#2563eb] text-white hover:bg-[#1d4ed8]'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<span className="text-[24px] font-medium leading-none">+</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="실행 가이드"
|
||||||
|
onClick={() => {
|
||||||
|
setModuleGuideOpen(true)
|
||||||
|
setFloatingActionMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'absolute bottom-[68px] left-1/2 grid h-12 w-12 -translate-x-1/2 cursor-pointer place-items-center rounded-full bg-[#2563eb] text-white shadow-[0_8px_20px_rgba(20,54,104,0.24)] transition-all duration-200 ease-out hover:scale-105 hover:bg-[#1d4ed8]',
|
||||||
|
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100' : 'pointer-events-none translate-y-4 scale-50 opacity-0 delay-75'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<BookOpen size={17} />
|
||||||
|
<span className="pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md bg-[#2563eb] px-2 py-1 text-[11px] font-bold text-white shadow-[0_6px_18px_rgba(20,54,104,0.2)]">
|
||||||
|
실행 가이드
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="모듈 추가"
|
||||||
|
disabled={!allowCustomModuleCreation}
|
||||||
|
onClick={() => {
|
||||||
|
setModuleAddDialogOpen(true)
|
||||||
|
setFloatingActionMenuOpen(false)
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
'absolute bottom-[124px] left-1/2 grid h-12 w-12 -translate-x-1/2 place-items-center rounded-full transition-all duration-200 ease-out',
|
||||||
|
allowCustomModuleCreation
|
||||||
|
? 'cursor-pointer bg-[#2563eb] text-white shadow-[0_8px_20px_rgba(20,54,104,0.24)] hover:scale-105 hover:bg-[#1d4ed8]'
|
||||||
|
: 'cursor-not-allowed bg-[#3b4250] text-white/35 shadow-none',
|
||||||
|
floatingActionMenuOpen ? 'pointer-events-auto translate-y-0 scale-100 opacity-100 delay-75' : 'pointer-events-none translate-y-4 scale-50 opacity-0'
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
<PackagePlus size={18} />
|
||||||
|
<span className={[
|
||||||
|
'pointer-events-none absolute left-[calc(100%+8px)] whitespace-nowrap rounded-md px-2 py-1 text-[11px] font-bold shadow-[0_6px_18px_rgba(20,54,104,0.2)]',
|
||||||
|
allowCustomModuleCreation ? 'bg-[#2563eb] text-white' : 'bg-[#3b4250] text-white/35 shadow-none'
|
||||||
|
].join(' ')}>
|
||||||
|
모듈 추가
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{deleteConfirmModule && (
|
{deleteConfirmModule && (
|
||||||
<ModuleDeleteConfirm
|
<ModuleDeleteConfirm
|
||||||
dark={dark}
|
dark={dark}
|
||||||
@@ -404,7 +460,7 @@ const moduleGuidePages = [
|
|||||||
{
|
{
|
||||||
environment: '로컬 환경',
|
environment: '로컬 환경',
|
||||||
imageAlt: '로컬 환경 모듈 가이드',
|
imageAlt: '로컬 환경 모듈 가이드',
|
||||||
imageSrc: '/assets/images/module-guide-local.png',
|
imageSrc: './assets/images/module-guide-local.png',
|
||||||
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
|
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
|
||||||
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -416,7 +472,7 @@ const moduleGuidePages = [
|
|||||||
{
|
{
|
||||||
environment: '원격 플랫폼',
|
environment: '원격 플랫폼',
|
||||||
imageAlt: '원격 플랫폼 환경 모듈 가이드',
|
imageAlt: '원격 플랫폼 환경 모듈 가이드',
|
||||||
imageSrc: '/assets/images/module-guide-remote-platform.png',
|
imageSrc: './assets/images/module-guide-remote-platform.png',
|
||||||
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
|
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
|
||||||
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -428,7 +484,7 @@ const moduleGuidePages = [
|
|||||||
{
|
{
|
||||||
environment: '원격 플랫폼 3D',
|
environment: '원격 플랫폼 3D',
|
||||||
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
|
imageAlt: '원격 3D SLAM 환경 모듈 가이드',
|
||||||
imageSrc: '/assets/images/module-guide-remote-3d.png',
|
imageSrc: './assets/images/module-guide-remote-3d.png',
|
||||||
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
|
slamModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-SLAM3D'],
|
||||||
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
|
||||||
parameters: [
|
parameters: [
|
||||||
@@ -493,12 +549,15 @@ function ModuleGuideDialog({
|
|||||||
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
|
<section className={dark ? 'border-l border-white/10 pl-4' : 'border-l border-[#dfe4ec] pl-4'}>
|
||||||
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>파라미터</h3>
|
<h3 className={dark ? 'text-center text-[11px] font-bold text-white/48' : 'text-center text-[11px] font-bold text-[#6d7788]'}>파라미터</h3>
|
||||||
<div className="mt-2 grid gap-2">
|
<div className="mt-2 grid gap-2">
|
||||||
{page.parameters.map(([label, value]) => (
|
{page.parameters.map((parameter) => {
|
||||||
|
const [label, value] = parameter
|
||||||
|
return (
|
||||||
<fieldset key={label} className={dark ? 'min-w-0 rounded-md border border-white/12 px-2 pb-1.5' : 'min-w-0 rounded-md border border-[#d7dee9] px-2 pb-1.5'}>
|
<fieldset key={label} className={dark ? 'min-w-0 rounded-md border border-white/12 px-2 pb-1.5' : 'min-w-0 rounded-md border border-[#d7dee9] px-2 pb-1.5'}>
|
||||||
<legend className={dark ? 'ml-1 px-1 font-sans text-[10px] font-bold text-white/56' : 'ml-1 px-1 font-sans text-[10px] font-bold text-[#6d7788]'}>{label}</legend>
|
<legend className={dark ? 'ml-1 px-1 font-sans text-[10px] font-bold text-white/56' : 'ml-1 px-1 font-sans text-[10px] font-bold text-[#6d7788]'}>{label}</legend>
|
||||||
<code className={dark ? 'block pt-1 break-all text-[10px] leading-4 text-white/82' : 'block pt-1 break-all text-[10px] leading-4 text-[#374151]'}>{value}</code>
|
<code className={dark ? 'block pt-1 break-all text-[10px] leading-4 text-white/82' : 'block pt-1 break-all text-[10px] leading-4 text-[#374151]'}>{value}</code>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
))}
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -517,7 +576,7 @@ function ModuleGuideDialog({
|
|||||||
<div className="flex items-center justify-center gap-1.5" aria-label={`가이드 ${pageIndex + 1} / ${moduleGuidePages.length}`}>
|
<div className="flex items-center justify-center gap-1.5" aria-label={`가이드 ${pageIndex + 1} / ${moduleGuidePages.length}`}>
|
||||||
{moduleGuidePages.map((guidePage, index) => (
|
{moduleGuidePages.map((guidePage, index) => (
|
||||||
<button
|
<button
|
||||||
key={guidePage.title}
|
key={guidePage.environment}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setPageIndex(index)}
|
onClick={() => setPageIndex(index)}
|
||||||
className={[
|
className={[
|
||||||
@@ -566,9 +625,14 @@ function ModuleAddDialog({
|
|||||||
onClose: () => void
|
onClose: () => void
|
||||||
onCreate: (input: CustomModuleInput) => Promise<void>
|
onCreate: (input: CustomModuleInput) => Promise<void>
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
|
const directDockerImageOption = '__direct_input__'
|
||||||
const [name, setName] = useState('')
|
const [name, setName] = useState('')
|
||||||
const [category, setCategory] = useState('')
|
const [category, setCategory] = useState('')
|
||||||
const [dockerImage, setDockerImage] = useState('')
|
const [dockerImage, setDockerImage] = useState('')
|
||||||
|
const [dockerImageSelection, setDockerImageSelection] = useState('')
|
||||||
|
const [dockerImages, setDockerImages] = useState<string[]>([])
|
||||||
|
const [dockerImagesLoading, setDockerImagesLoading] = useState(true)
|
||||||
|
const [dockerImagesMessage, setDockerImagesMessage] = useState<string | null>(null)
|
||||||
const [packageName, setPackageName] = useState('')
|
const [packageName, setPackageName] = useState('')
|
||||||
const [launchFile, setLaunchFile] = useState('')
|
const [launchFile, setLaunchFile] = useState('')
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
@@ -579,6 +643,27 @@ function ModuleAddDialog({
|
|||||||
dark ? 'border-white/12 bg-[#1f1f1f] text-white/82 placeholder:text-white/28 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2f8dff]/55'
|
dark ? 'border-white/12 bg-[#1f1f1f] text-white/82 placeholder:text-white/28 focus:border-[#2f8dff]/55' : 'border-[#cfd6e4] bg-white text-[#111520] placeholder:text-[#9aa3b2] focus:border-[#2f8dff]/55'
|
||||||
].join(' ')
|
].join(' ')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
void window.localWorkspace.modules.listDockerImages()
|
||||||
|
.then(({ images, message }) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setDockerImages(images)
|
||||||
|
setDockerImagesMessage(message ?? null)
|
||||||
|
})
|
||||||
|
.catch((reason) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setDockerImagesMessage(reason instanceof Error ? reason.message : '로컬 Docker 이미지를 불러오지 못했습니다.')
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setDockerImagesLoading(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 정보 입력">
|
<div className="absolute inset-0 z-50 grid place-items-center bg-black/36 p-4" role="dialog" aria-modal="true" aria-label="모듈 정보 입력">
|
||||||
<section className={['w-full max-w-[440px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
|
<section className={['w-full max-w-[440px] overflow-hidden rounded-lg border shadow-[0_24px_70px_rgba(0,0,0,0.42)]', dark ? 'border-white/10 bg-[#252525] text-white' : 'border-[#d8dee9] bg-white text-[#111520]'].join(' ')}>
|
||||||
@@ -587,7 +672,7 @@ function ModuleAddDialog({
|
|||||||
<h2 className="text-[14px] font-bold">모듈 정보 입력</h2>
|
<h2 className="text-[14px] font-bold">모듈 정보 입력</h2>
|
||||||
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>추가할 ROS2 모듈의 기본 정보를 입력합니다.</p>
|
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>추가할 ROS2 모듈의 기본 정보를 입력합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={onClose} className={['grid h-7 w-7 place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label="닫기">
|
<button type="button" onClick={onClose} className={['grid h-7 w-7 cursor-pointer place-items-center rounded-md transition', dark ? 'text-white/46 hover:bg-white/8 hover:text-white' : 'text-[#7b8494] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')} aria-label="닫기">
|
||||||
<X size={15} aria-hidden="true" />
|
<X size={15} aria-hidden="true" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@@ -622,8 +707,28 @@ function ModuleAddDialog({
|
|||||||
</label>
|
</label>
|
||||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||||
실행 이미지
|
실행 이미지
|
||||||
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" />
|
<select
|
||||||
|
value={dockerImageSelection}
|
||||||
|
onChange={(event) => {
|
||||||
|
const selection = event.target.value
|
||||||
|
setDockerImageSelection(selection)
|
||||||
|
setDockerImage(selection === directDockerImageOption ? '' : selection)
|
||||||
|
}}
|
||||||
|
className={inputClassName}
|
||||||
|
>
|
||||||
|
<option value="" disabled>선택</option>
|
||||||
|
<option value={directDockerImageOption}>직접 입력</option>
|
||||||
|
{dockerImagesLoading && <option value="__loading__" disabled>로컬 Docker 이미지 불러오는 중</option>}
|
||||||
|
{dockerImages.map((image) => <option key={image} value={image}>{image}</option>)}
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
{dockerImageSelection === directDockerImageOption && (
|
||||||
|
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||||
|
직접 입력
|
||||||
|
<input value={dockerImage} onChange={(event) => setDockerImage(event.target.value)} className={inputClassName} placeholder="예: future-nav2:humble" autoFocus />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
{dockerImagesMessage && <p className={dark ? 'text-[11px] font-medium text-white/42' : 'text-[11px] font-medium text-[#7b8494]'}>{dockerImagesMessage}</p>}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
|
||||||
패키지명
|
패키지명
|
||||||
@@ -636,8 +741,8 @@ function ModuleAddDialog({
|
|||||||
</div>
|
</div>
|
||||||
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
|
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
|
||||||
<footer className="mt-1 flex justify-end gap-2">
|
<footer className="mt-1 flex justify-end gap-2">
|
||||||
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 rounded-md border px-3 text-[12px] font-bold transition', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>취소</button>
|
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 cursor-pointer rounded-md border px-3 text-[12px] font-bold transition disabled:cursor-not-allowed', dark ? 'border-white/12 text-white/62 hover:bg-white/8 hover:text-white' : 'border-[#cfd6e4] text-[#687284] hover:bg-[#eef1f7] hover:text-[#111520]'].join(' ')}>취소</button>
|
||||||
<button type="submit" disabled={submitting || !name.trim() || !category || !packageName.trim() || !launchFile.trim()} className="h-8 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '추가 중' : '확인'}</button>
|
<button type="submit" disabled={submitting || !name.trim() || !category || !packageName.trim() || !launchFile.trim()} className="h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '추가 중' : '확인'}</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Code2, FileCode2, GitBranch, Network, Radio, RefreshCw, Search, Settings, Terminal } from 'lucide-react'
|
import { Code2, FileCode2, GitBranch, Network, Radio, Search, Settings, Terminal } from 'lucide-react'
|
||||||
import type React from 'react'
|
import type React from 'react'
|
||||||
|
|
||||||
export type WorkspaceActivity = 'modules' | 'ros-connections' | 'workspace' | 'search' | 'logs' | 'examples' | 'git' | 'settings'
|
export type WorkspaceActivity = 'modules' | 'ros-connections' | 'workspace' | 'search' | 'logs' | 'examples' | 'git' | 'settings'
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ type UseWorkspaceFileActionsOptions = {
|
|||||||
setLoadedModuleRoots: Dispatch<SetStateAction<Record<string, string | null>>>
|
setLoadedModuleRoots: Dispatch<SetStateAction<Record<string, string | null>>>
|
||||||
createdModuleFolders: string[]
|
createdModuleFolders: string[]
|
||||||
setCreatedModuleFolders: Dispatch<SetStateAction<string[]>>
|
setCreatedModuleFolders: Dispatch<SetStateAction<string[]>>
|
||||||
openProjectFolders: Record<string, boolean>
|
|
||||||
setOpenProjectFolders: Dispatch<SetStateAction<Record<string, boolean>>>
|
setOpenProjectFolders: Dispatch<SetStateAction<Record<string, boolean>>>
|
||||||
treeContextMenu: TreeContextMenuState | null
|
treeContextMenu: TreeContextMenuState | null
|
||||||
setTreeContextMenu: Dispatch<SetStateAction<TreeContextMenuState | null>>
|
setTreeContextMenu: Dispatch<SetStateAction<TreeContextMenuState | null>>
|
||||||
@@ -103,7 +102,6 @@ export function useWorkspaceFileActions({
|
|||||||
setLoadedModuleRoots,
|
setLoadedModuleRoots,
|
||||||
createdModuleFolders,
|
createdModuleFolders,
|
||||||
setCreatedModuleFolders,
|
setCreatedModuleFolders,
|
||||||
openProjectFolders,
|
|
||||||
setOpenProjectFolders,
|
setOpenProjectFolders,
|
||||||
treeContextMenu,
|
treeContextMenu,
|
||||||
setTreeContextMenu,
|
setTreeContextMenu,
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1 +0,0 @@
|
|||||||
{"root":["./src/renderer/src/App.tsx","./src/renderer/src/env.d.ts","./src/renderer/src/main.tsx","./src/renderer/src/components/Sidebar.tsx","./src/renderer/src/components/TitleBar.tsx"],"version":"5.9.3"}
|
|
||||||
Reference in New Issue
Block a user