This commit is contained in:
2026-08-20 16:52:45 +09:00
parent fdffd4617a
commit 342fcaeb34
16 changed files with 306 additions and 84 deletions
+55 -14
View File
@@ -1,6 +1,6 @@
import { app, BrowserWindow, Menu, globalShortcut, ipcMain, screen } 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 type { IPty } from 'node-pty'
import { appendFileSync, createWriteStream, existsSync, mkdirSync } from 'fs'
@@ -245,7 +245,7 @@ type RosNetworkConfig = {
peer?: RemoteWorkspaceTarget | null
}
type VisualizationStack = 'autoware' | 'nav2' | 'slam'
type VisualizationStack = 'autoware' | 'nav2' | 'slam' | 'slam3d'
type VisualizationBridgeStatus = {
ok: boolean
@@ -1212,8 +1212,9 @@ async function resolveRemoteCustomModuleRuntimeImage(
folderName: string,
fallbackImage: string,
): Promise<string> {
const output = await runSshOutput(target, [
`ROOT=${quoteShellSingle(target.workspaceRoot)}`,
const normalizedTarget = normalizeRemoteTarget(target)
const output = await runSshOutput(normalizedTarget, [
`ROOT=${quoteShellSingle(normalizedTarget.workspaceRoot)}`,
...expandRemotePathVar('ROOT'),
`CONFIG="$ROOT/${folderName}/.autonomy-studio-module.json"`,
'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)
}
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 rosGraphProbeScript = [
'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> {
const key = `${target.user}@${target.host}:${target.port}`
const previous = remoteSshQueues.get(key) ?? Promise.resolve()
let release: (() => void) | null = null
let release: () => void = () => undefined
const current = new Promise<void>((resolvePromise) => {
release = resolvePromise
})
@@ -1810,7 +1836,7 @@ async function queueRemoteSsh<T>(target: ReturnType<typeof normalizeRemoteTarget
try {
return await task()
} finally {
release?.()
release()
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> {
const endpoint = stack === 'slam' || stack === 'nav2' ? 'health' : 'stack'
const endpoint = stack === 'slam' || stack === 'slam3d' || stack === 'nav2' ? 'health' : 'stack'
const startedAt = Date.now()
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(
targetInput: RemoteWorkspaceTarget,
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 {
const timer = terminalStopTimers.get(terminalKey)
if (!timer) return
@@ -5743,6 +5782,7 @@ function registerLocalWorkspaceHandlers(): void {
closeSplashAndShowMain()
})
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: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) => {
@@ -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-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: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:logs', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => readRemoteWorkspaceModuleLogs(target, moduleName, folderName))
ipcMain.handle('remote-workspace:visualization:status', (_event, target: RemoteWorkspaceTarget) => readRemoteVisualizationBridgeStatus(target))
+7 -3
View File
@@ -178,7 +178,7 @@ contextBridge.exposeInMainWorld('terminal', {
})
contextBridge.exposeInMainWorld('visualization', {
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)
})
contextBridge.exposeInMainWorld('localWorkspace', {
@@ -193,6 +193,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
},
modules: {
list: (workspaceRoot?: string, modules?: Array<{ sortOrder?: number | null; category: string; name: string; folderName: string; templateRepositoryUrl?: string | null }>) => ipcRenderer.invoke('local-workspace:modules:list', workspaceRoot, modules),
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),
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),
@@ -225,7 +226,7 @@ contextBridge.exposeInMainWorld('localWorkspace', {
},
visualization: {
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)
},
git: {
@@ -290,9 +291,12 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
rosGraph: {
snapshot: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros-graph', target)
},
ros: {
resetSession: (target: RemoteWorkspaceTarget) => ipcRenderer.invoke('remote-workspace:ros:reset-session', target)
},
visualization: {
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)
},
git: {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 MiB

After

Width:  |  Height:  |  Size: 2.5 MiB

+58 -15
View File
@@ -1285,15 +1285,19 @@ function LocalPcWorkspaceMockup({
onTitlebarNotice(payload.message || '맵 저장에 실패했습니다.', 'error')
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 () => {
try {
let databasePath = payload.map.databasePath ?? null
let databasePath = savedMapDatabasePath
const savesThreeDimensionalMap = remoteWorkspace
&& visualizationBridgeLocation === 'local'
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
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(
getRemoteWorkspaceTarget(),
localVisualizationWorkspaceRoot,
@@ -1302,8 +1306,8 @@ function LocalPcWorkspaceMockup({
databasePath = copied.databasePath
}
const sourceArtifacts = [
{ name: 'yaml' as const, path: payload.map.yamlPath },
{ name: 'pgm' as const, path: payload.map.pgmPath },
{ name: 'yaml' as const, path: yamlPath },
{ name: 'pgm' as const, path: pgmPath },
...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
]
// 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('runtimeKind', selectedWorldMap.runtimeKind
?? (databasePath ? 'PLATFORM_3D' : remoteWorkspace ? 'PLATFORM_2D' : 'SIMULATION'))
form.set('yamlPath', payload.map.yamlPath)
form.set('pgmPath', payload.map.pgmPath)
if (payload.map.previewPgm) form.set('previewPgm', payload.map.previewPgm)
form.set('yamlPath', yamlPath)
form.set('pgmPath', pgmPath)
if (savedMapPreviewPgm) form.set('previewPgm', savedMapPreviewPgm)
if (databasePath) form.set('databasePath', databasePath)
artifacts.forEach((artifact) => {
const binary = atob(artifact.base64)
@@ -1648,7 +1652,6 @@ function LocalPcWorkspaceMockup({
setLoadedModuleRoots,
createdModuleFolders,
setCreatedModuleFolders,
openProjectFolders,
setOpenProjectFolders,
treeContextMenu,
setTreeContextMenu,
@@ -3331,7 +3334,7 @@ function LocalPcWorkspaceMockup({
packageName: payload.packageName ?? input.packageName,
launchFile: payload.launchFile ?? input.launchFile,
runtimeTarget: payload.runtimeTarget ?? customModuleRuntimeTarget,
} as const
} satisfies NonNullable<CurriculumWeek['modules']>[number]
if (remoteWorkspace) {
await window.remoteWorkspace.modules.createCustom(getRemoteWorkspaceTarget(), customModule)
} else {
@@ -3949,7 +3952,6 @@ function LocalPcWorkspaceMockup({
{pendingWorldMapRestart && (
<WorldMapRestartConfirmModal
map={pendingWorldMapRestart}
onCancel={() => setPendingWorldMapRestart(null)}
onConfirm={() => setPendingWorldMapRestart(null)}
/>
)}
@@ -4107,6 +4109,7 @@ export default function App(): React.JSX.Element {
const [platformModalMode, setPlatformModalMode] = useState<'select' | 'settings'>('select')
const [platformSettingsTarget, setPlatformSettingsTarget] = useState<PlatformOption | null>(null)
const [platformSettingsDraft, setPlatformSettingsDraft] = useState<StudioSourceSettings>(defaultStudioSourceSettings)
const [remoteRosResetting, setRemoteRosResetting] = useState(false)
const [recentSessionModalOpen, setRecentSessionModalOpen] = useState(false)
const [instructorStudioModalOpen, setInstructorStudioModalOpen] = useState(false)
const [materialModalOpen, setMaterialModalOpen] = useState(false)
@@ -4299,6 +4302,31 @@ export default function App(): React.JSX.Element {
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 target = makeRemoteWorkspaceTargetForPlatform(platform)
if (!target) return
@@ -4653,7 +4681,7 @@ export default function App(): React.JSX.Element {
const nextRunningPlatforms = restored.runningPlatformIds
.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
? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null
: nextRunningPlatforms[0] ?? null
@@ -4897,7 +4925,7 @@ export default function App(): React.JSX.Element {
const canExtendConnectedPlatform = Boolean(
canReleaseConnectedPlatform
&& (connectedPlatform?.control?.warning || controlWarningMatchesConnectedPlatform)
&& (connectedPlatform.control.session?.extensionCount ?? 0) < 1,
&& (connectedPlatform?.control?.session?.extensionCount ?? 0) < 1,
)
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
? connectedPlatform?.control?.session?.endsAt ?? null
@@ -5270,7 +5298,8 @@ export default function App(): React.JSX.Element {
setControlExtensionSubmitting(true)
try {
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 => {
if (platform.id !== connectedPlatform.id || !platform.control) return platform
@@ -5279,7 +5308,7 @@ export default function App(): React.JSX.Element {
control: {
...platform.control,
warning: false,
session: result.session,
session: extendedSession,
},
}
}
@@ -5575,6 +5604,15 @@ export default function App(): React.JSX.Element {
setPlatformLoading(true)
try {
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
window.setTimeout(() => {
setConnectedPlatform(connected)
@@ -5949,7 +5987,12 @@ export default function App(): React.JSX.Element {
clonePath={selectedWeek.isTeamProject && studentGroup?.id
? makeTeamWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, studentGroup.id, courseKey, selectedWeek.week)
: makeWeekWorkspaceRoot(platformSettingsDraft.workspaceRoot, makeStudentSlug(authSession?.user.email ?? 'student'), courseKey, selectedWeek.week)}
canResetRosSession={Boolean(platformSettingsTarget && hasCurrentUserPlatformControl(platformSettingsTarget))}
resettingRosSession={remoteRosResetting}
onDraftChange={setPlatformSettingsDraft}
onResetRosSession={() => {
if (platformSettingsTarget) void resetRemotePlatformRosSession(platformSettingsTarget)
}}
onClose={closePlatformSettings}
onSave={savePlatformSettings}
/>
+11 -7
View File
@@ -108,6 +108,7 @@ interface Window {
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<{
ok: boolean
module: {
@@ -232,19 +233,19 @@ interface Window {
status: () => Promise<{
ok: boolean
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
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
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
message?: string
}>
stop: (environmentProfile?: unknown) => Promise<{
ok: boolean
running: boolean
stack: 'autoware' | 'nav2' | 'slam' | null
stack: 'autoware' | 'nav2' | 'slam' | 'slam3d' | null
message?: string
}>
}
@@ -336,10 +337,13 @@ interface Window {
message?: string
}>
}
ros: {
resetSession: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; message: string }>
}
visualization: {
status: (target: RemoteWorkspaceTarget) => Promise<{ ok: boolean; running: boolean; stack: 'autoware' | 'nav2' | 'slam' | 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 }>
stop: (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' | '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' | 'slam3d' | null; message?: string }>
}
git: {
status: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, selectedFilePath?: string | null) => ReturnType<Window['git']['status']>
+1 -1
View File
@@ -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>}
</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>}
</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>
@@ -7,6 +7,7 @@ import WeekCard from './WeekCard'
type CurriculumHomeWeek = {
week: number
title: string
duration: string
tags: string[]
progress: number
state: 'done' | 'current' | 'locked' | 'ready'
@@ -59,7 +59,7 @@ export default function LearningInfoCard({
{[
{ label: '플랫폼', value: week.tags[0] ?? '-', Icon: Car },
{ label: '예상 학습 시간', value: week.duration, Icon: Clock3 }
].map(({ label, value, Icon, valueClassName }) => (
].map(({ label, value, Icon }) => (
<div
key={label}
className={[
@@ -71,7 +71,7 @@ export default function LearningInfoCard({
<Icon size={14} className="shrink-0 text-[#6d7788]" />
<span className="truncate">{label}</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}
</strong>
</div>
@@ -6,7 +6,10 @@ type PlatformSettingsPanelProps = {
target: PlatformOption | null
draft: StudioSourceSettings
clonePath: string
canResetRosSession: boolean
resettingRosSession: boolean
onDraftChange: (updater: (settings: StudioSourceSettings) => StudioSourceSettings) => void
onResetRosSession: () => void
onClose: () => void
onSave: () => void
}
@@ -15,7 +18,10 @@ export default function PlatformSettingsPanel({
target,
draft,
clonePath,
canResetRosSession,
resettingRosSession,
onDraftChange,
onResetRosSession,
onClose,
onSave
}: 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>
{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>
) : (
<>
@@ -1,5 +1,5 @@
import { ChevronLeft, ChevronRight, X } from 'lucide-react'
import { useEffect, useState } from 'react'
import { BookOpen, ChevronLeft, ChevronRight, PackagePlus, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
import ModuleDeleteConfirm from './ModuleDeleteConfirm'
import ModuleListEmptyState from './ModuleListEmptyState'
@@ -87,6 +87,8 @@ export default function ModuleListView({
const [parameterDialog, setParameterDialog] = useState<{ module: ModuleRow; parameters: ModuleParameterPair[] } | null>(null)
const [moduleAddDialogOpen, setModuleAddDialogOpen] = 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 [dropTargetModuleName, setDropTargetModuleName] = useState<string | 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(() => {
if (!parkingHeightStorageKey) {
setParkingHeight(232)
@@ -290,26 +311,6 @@ export default function ModuleListView({
) : filteredModuleRows.length === 0 ? (
<ModuleListEmptyState dark={dark} />
) : 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>
@@ -348,6 +349,61 @@ export default function ModuleListView({
</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 && (
<ModuleDeleteConfirm
dark={dark}
@@ -404,7 +460,7 @@ const moduleGuidePages = [
{
environment: '로컬 환경',
imageAlt: '로컬 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-local.png',
imageSrc: './assets/images/module-guide-local.png',
slamModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-SLAM'],
navigationModules: ['NAV2-Gazebo', 'NAV2-TurtleBot3Burger', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
@@ -416,7 +472,7 @@ const moduleGuidePages = [
{
environment: '원격 플랫폼',
imageAlt: '원격 플랫폼 환경 모듈 가이드',
imageSrc: '/assets/images/module-guide-remote-platform.png',
imageSrc: './assets/images/module-guide-remote-platform.png',
slamModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-SLAM'],
navigationModules: ['NAV2-MKMini', 'NAV2-Perception', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
@@ -428,7 +484,7 @@ const moduleGuidePages = [
{
environment: '원격 플랫폼 3D',
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'],
navigationModules: ['NAV2-MKMini', 'NAV2-Perception3D', 'NAV2-Localization', 'NAV2-Planning', 'NAV2-Control'],
parameters: [
@@ -493,12 +549,15 @@ function ModuleGuideDialog({
<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>
<div className="mt-2 grid gap-2">
{page.parameters.map(([label, value]) => (
<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>
<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>
))}
{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'}>
<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>
</fieldset>
)
})}
</div>
</section>
</div>
@@ -517,7 +576,7 @@ function ModuleGuideDialog({
<div className="flex items-center justify-center gap-1.5" aria-label={`가이드 ${pageIndex + 1} / ${moduleGuidePages.length}`}>
{moduleGuidePages.map((guidePage, index) => (
<button
key={guidePage.title}
key={guidePage.environment}
type="button"
onClick={() => setPageIndex(index)}
className={[
@@ -566,9 +625,14 @@ function ModuleAddDialog({
onClose: () => void
onCreate: (input: CustomModuleInput) => Promise<void>
}): React.JSX.Element {
const directDockerImageOption = '__direct_input__'
const [name, setName] = useState('')
const [category, setCategory] = useState('')
const [dockerImage, setDockerImage] = useState('')
const [dockerImageSelection, setDockerImageSelection] = useState('')
const [dockerImages, setDockerImages] = useState<string[]>([])
const [dockerImagesLoading, setDockerImagesLoading] = useState(true)
const [dockerImagesMessage, setDockerImagesMessage] = useState<string | null>(null)
const [packageName, setPackageName] = useState('')
const [launchFile, setLaunchFile] = useState('')
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'
].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 (
<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(' ')}>
@@ -587,7 +672,7 @@ function ModuleAddDialog({
<h2 className="text-[14px] font-bold">모듈 정보 입력</h2>
<p className={['mt-1 text-[11px] font-medium', dark ? 'text-white/42' : 'text-[#727b8c]'].join(' ')}>추가할 ROS2 모듈의 기본 정보를 입력합니다.</p>
</div>
<button type="button" onClick={onClose} className={['grid h-7 w-7 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" />
</button>
</header>
@@ -622,8 +707,28 @@ function ModuleAddDialog({
</label>
<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>
{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">
<label className={['grid gap-1 text-[11px] font-bold', dark ? 'text-white/58' : 'text-[#596477]'].join(' ')}>
패키지명
@@ -636,8 +741,8 @@ function ModuleAddDialog({
</div>
{error && <p className={dark ? 'text-[11px] font-semibold text-[#ff9c9c]' : 'text-[11px] font-semibold text-[#b42323]'}>{error}</p>}
<footer className="mt-1 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onClose} className={['h-8 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="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="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 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d4ed8] disabled:cursor-not-allowed disabled:opacity-45">{submitting ? '추가 중' : '확인'}</button>
</footer>
</form>
</section>
@@ -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'
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>>>
createdModuleFolders: string[]
setCreatedModuleFolders: Dispatch<SetStateAction<string[]>>
openProjectFolders: Record<string, boolean>
setOpenProjectFolders: Dispatch<SetStateAction<Record<string, boolean>>>
treeContextMenu: TreeContextMenuState | null
setTreeContextMenu: Dispatch<SetStateAction<TreeContextMenuState | null>>
@@ -103,7 +102,6 @@ export function useWorkspaceFileActions({
setLoadedModuleRoots,
createdModuleFolders,
setCreatedModuleFolders,
openProjectFolders,
setOpenProjectFolders,
treeContextMenu,
setTreeContextMenu,