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
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,