This commit is contained in:
2026-08-03 14:06:06 +09:00
parent 107ccd151e
commit 99cfef1004
37 changed files with 3273 additions and 356 deletions
+48 -24
View File
@@ -1,6 +1,6 @@
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageCircleQuestion, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
import { BookOpen, ChevronRight, Code2, LoaderCircle, MessageSquareText, Plus, Sparkles, Trash2, X } from 'lucide-react'
import type React from 'react'
import { useRef, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { ExpandableMarkdownMessage, QaInputBox } from '@lms-renderer/components/ChatWidgets'
import type { QaChatMessage, QaChatThread } from '@lms-renderer/features/workspace/useQaChat'
@@ -73,6 +73,24 @@ export default function AiGuideView({
const [deleteConfirmThread, setDeleteConfirmThread] = useState<QaChatThread | null>(null)
const questionRefs = useRef<Record<string, HTMLElement | null>>({})
const conversationScrollRef = useRef<HTMLDivElement | null>(null)
const latestMessageIdRef = useRef<string | null>(null)
const followConversationRef = useRef(true)
const latestMessage = messages.at(-1)
useEffect(() => {
const latestMessageId = latestMessage?.id ?? null
const hasNewMessage = latestMessageId !== latestMessageIdRef.current
latestMessageIdRef.current = latestMessageId
if (hasNewMessage) followConversationRef.current = true
if (!hasNewMessage && !followConversationRef.current) return
const frame = window.requestAnimationFrame(() => {
const target = conversationScrollRef.current
if (!target) return
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
})
return () => window.cancelAnimationFrame(frame)
}, [busy, latestMessage?.content, latestMessage?.id, messages.length, typingMessageId])
const scrollToQuestion = (messageId: string): void => {
setSelectedQuestionId(messageId)
@@ -86,15 +104,18 @@ export default function AiGuideView({
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => {
const target = conversationScrollRef.current
if (target) target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
if (target) {
followConversationRef.current = true
target.scrollTo({ top: target.scrollHeight, behavior: 'auto' })
}
window.setTimeout(() => setSwitchingConversation(false), 1000)
})
})
}
return (
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#101827] text-white">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-white/10 bg-[#17243b] px-3">
<section className="relative flex h-full min-h-0 w-full flex-col overflow-hidden bg-[#10131b] text-white">
<header className="flex h-10 shrink-0 items-center justify-between border-b border-violet-200/15 bg-[#2a1d3f] px-3">
<strong className="truncate pl-4 text-[13px] font-bold text-white/90">AI 가이드</strong>
<button
type="button"
@@ -108,11 +129,11 @@ export default function AiGuideView({
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#14213a]">
<aside className="flex w-[252px] shrink-0 flex-col border-r border-white/10 bg-[#191b2a]">
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-3">
<>
<div className="rounded-lg border border-[#3b7ffa]/25 bg-[#3b7ffa]/[0.08] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-[#9bbdff]">현재 학습</span>
<div className="rounded-lg border border-violet-300/20 bg-violet-400/[0.08] px-3 pb-3 pt-2">
<span className="text-[10px] font-bold tracking-[0.08em] text-violet-200">현재 학습</span>
<strong className="mt-2 block text-[13px] leading-snug text-white/88">{contextLabel}</strong>
<p className="mt-1.5 text-[11px] leading-relaxed text-white/46">{courseTitle ?? '강좌를 선택하면 학습 내용에 맞춰 안내합니다.'}</p>
</div>
@@ -123,7 +144,7 @@ export default function AiGuideView({
onClick={onClear}
className="group mt-3 flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
>
<Plus size={15} className="shrink-0 text-[#82abff]" />
<Plus size={15} className="shrink-0 text-emerald-300" />
<span className="min-w-0 flex-1">새 채팅</span>
<ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" />
</button>
@@ -139,7 +160,7 @@ export default function AiGuideView({
onClick={() => onQuickSubmit(prompt)}
className="group flex min-h-10 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-[12px] font-semibold text-white/58 transition hover:bg-white/[0.07] hover:text-white disabled:cursor-not-allowed disabled:opacity-35"
>
<Icon size={15} className="shrink-0 text-[#82abff]" />
<Icon size={15} className="shrink-0 text-violet-300" />
<span className="min-w-0 flex-1">{label}</span>
<ChevronRight size={14} className="shrink-0 opacity-0 transition group-hover:opacity-70" />
</button>
@@ -156,14 +177,14 @@ export default function AiGuideView({
<button
type="button"
onClick={() => selectConversation(null)}
className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}
className={['min-w-0 cursor-pointer rounded-md px-2 py-2 text-left transition', !viewingHistory ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}
>
<span className="block truncate text-[11px] font-semibold">{activeConversation.title || '새 채팅'}</span>
<span className="mt-0.5 block text-[10px] text-white/32">현재 채팅</span>
</button>
) : null}
{history.map((thread) => (
<div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}>
<div key={thread.id} className={['group flex min-w-0 items-center rounded-md transition', selectedHistoryId === thread.id ? 'bg-violet-400/16 text-violet-100' : 'text-white/48 hover:bg-white/[0.06] hover:text-white/75'].join(' ')}>
<button
type="button"
onClick={() => selectConversation(thread.id)}
@@ -191,8 +212,11 @@ export default function AiGuideView({
</aside>
<main className="relative flex min-w-0 flex-1 flex-col bg-[#0d1524]">
<div ref={conversationScrollRef} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-28 pt-7">
<main className="relative flex min-w-0 flex-1 flex-col bg-[#10131b]">
<div ref={conversationScrollRef} onScroll={(event) => {
const target = event.currentTarget
followConversationRef.current = target.scrollHeight - target.scrollTop - target.clientHeight < 96
}} className="session-scrollbar min-h-0 flex-1 overflow-y-auto px-6 pb-32 pt-7">
{loading ? (
<div className="grid h-full min-h-[300px] place-items-center text-[12px] font-semibold text-white/40"><span className="flex items-center gap-2"><LoaderCircle size={15} className="animate-spin" /> 이전 채팅을 불러오는 중</span></div>
) : disabled ? (
@@ -207,7 +231,7 @@ export default function AiGuideView({
<div className="mx-auto flex w-full max-w-[860px] flex-col gap-5">
{messages.length === 0 && (
<div className="py-9 text-center">
<span className="mx-auto grid size-11 place-items-center rounded-xl border border-[#3b7ffa]/25 bg-[#3b7ffa]/10 text-[#96b8ff]"><Sparkles size={20} /></span>
<span className="mx-auto grid size-11 place-items-center rounded-xl border border-violet-300/25 bg-violet-400/10 text-violet-200"><Sparkles size={20} /></span>
<h2 className="mt-4 text-[16px] font-bold text-white/86">무엇이 궁금한가요?</h2>
<p className="mt-1 text-[12px] text-white/42">개념, 실습 순서, 오류 원인처럼 학습에 필요한 내용을 질문하세요.</p>
</div>
@@ -219,15 +243,15 @@ export default function AiGuideView({
className={message.role === 'user' ? 'flex w-full justify-end' : 'w-full'}
>
<div className={message.role === 'user'
? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#3b7ffa] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(32,91,204,0.24)]'
: 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#18253a] px-4 py-3'}
? 'max-w-[78%] rounded-xl rounded-br-sm bg-[#5b4aa8] px-3.5 py-2.5 text-[12px] font-medium leading-relaxed text-white shadow-[0_8px_20px_rgba(63,49,139,0.28)]'
: 'w-full rounded-xl rounded-tl-sm border border-white/10 bg-[#1d2230] px-4 py-3'}
>
{message.role === 'user' ? (
<p className="whitespace-pre-wrap">{message.content}</p>
) : (
<>
{(message.isTyping || message.id === typingMessageId) && (
<span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-[#8fb4ff]"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>
<span className="mb-2 flex items-center gap-1.5 text-[11px] font-semibold text-violet-200"><LoaderCircle size={13} className="animate-spin" /> 생각 중</span>
)}
<ExpandableMarkdownMessage
content={message.content || '응답을 작성하고 있습니다.'}
@@ -242,7 +266,7 @@ export default function AiGuideView({
))}
{busy && (
<article className="w-full">
<div className="inline-flex items-center gap-2 rounded-xl rounded-tl-sm border border-white/10 bg-[#18253a] px-4 py-3 text-[12px] font-semibold text-[#8fb4ff]">
<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>
@@ -255,15 +279,15 @@ export default function AiGuideView({
<div className="pointer-events-auto mx-auto max-w-[900px]"><QaInputBox dark compact 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-[#0d1524]/78 backdrop-blur-[2px]">
<span className="flex items-center gap-2 rounded-lg border border-white/10 bg-[#1b2a42] px-4 py-3 text-[12px] font-semibold text-white/76 shadow-[0_16px_32px_rgba(0,0,0,0.28)]"><LoaderCircle size={15} className="animate-spin text-[#8fb4ff]" /> 채팅 불러오는 중</span>
<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 shadow-[0_16px_32px_rgba(0,0,0,0.28)]"><LoaderCircle size={15} className="animate-spin text-violet-200" /> 채팅 불러오는 중</span>
</div>
)}
</main>
<aside className="flex w-[232px] shrink-0 flex-col border-l border-white/10 bg-[#14213a]">
<aside 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="flex items-center gap-1.5 text-[11px] font-bold tracking-[0.08em] text-white/44"><MessageCircleQuestion size={13} />질문 목록</span>
<span className="text-[11px] font-bold text-white/44">질문 목록</span>
</div>
<div className="session-scrollbar min-h-0 flex-1 overflow-y-auto p-2">
{questionMessages.length ? (
@@ -273,7 +297,7 @@ export default function AiGuideView({
key={message.id}
type="button"
onClick={() => scrollToQuestion(message.id)}
className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-[#3b7ffa]/16 text-[#c6d9ff]' : 'text-white/52 hover:bg-white/[0.06] hover:text-white/78'].join(' ')}
className={['w-full min-w-0 cursor-pointer overflow-hidden rounded-md px-2.5 py-2 text-left transition', selectedQuestionId === message.id ? 'bg-violet-400/16 text-violet-100' : '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>
+370 -53
View File
@@ -2,12 +2,12 @@ import { type BeforeMount, type OnMount } from '@monaco-editor/react'
import { driver } from 'driver.js'
import { CarFront, Laptop, Monitor } from 'lucide-react'
import type { LucideIcon } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { io } from 'socket.io-client'
import type { Socket } from 'socket.io-client'
import Sidebar from './components/Sidebar'
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
import { RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import { ControlExtensionConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
import TitleBar from './components/TitleBar'
import { LMS_API_BASE_URL, LMS_SOCKET_ORIGIN, LMS_SOCKET_PATH, SELECTED_COURSE_STORAGE_KEY, launcherApiUrl } from './config/appConfig'
import { clearStoredAuthSession, exchangeLauncherHandoff, readStoredAuthSession, refreshStoredAuthSession, storeAuthSession } from './features/auth/authSession'
@@ -26,7 +26,7 @@ import PlatformSelectionPanel from './features/platforms/PlatformSelectionPanel'
import PlatformSettingsPanel from './features/platforms/PlatformSettingsPanel'
import RecentSessionModal from './features/platforms/RecentSessionModal'
import type { RecentSessionItem } from './features/platforms/RecentSessionModal'
import { connectPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
import { connectPlatformControl, extendPlatformControl, fetchPlatformStatuses, getPlatformDotClass, isPlatformUnavailable, releasePlatformControl, requestOrCancelPlatformControl } from './features/platforms/platformControl'
import type { PlatformControlGrantedPayload, PlatformControlTerminatedPayload, PlatformControlWarningPayload } from './features/platforms/platformControl'
import type { EnvironmentProfile, PlatformControlActionResponse, PlatformOption, StudioSourceSettings } from './features/platforms/types'
import WorkspaceActivityHeader from './features/workspace/WorkspaceActivityHeader'
@@ -41,6 +41,8 @@ import type { WorkspaceGuideResourceMode } from './features/workspace/WorkspaceG
import WorkspaceLogView from './features/workspace/WorkspaceLogView'
import WorkspaceModuleTabContextMenu from './features/workspace/WorkspaceModuleTabContextMenu'
import ModuleListView from './features/workspace/ModuleListView'
import WorldEditorModal from './features/workspace/WorldEditorModal'
import WorldMapSelectorModal, { NAV2_MAP_OPTIONS, type WorkspaceMapOption } from './features/workspace/WorldMapSelectorModal'
import WorkspaceQaPanel from './features/workspace/WorkspaceQaPanel'
import WorkspaceSettingsView from './features/workspace/WorkspaceSettingsView'
import WorkspaceSourceControlView from './features/workspace/WorkspaceSourceControlView'
@@ -61,10 +63,11 @@ import { useWorkspaceTreeState } from './features/workspace/useWorkspaceTreeStat
import { useWorkspaceVisualization } from './features/workspace/useWorkspaceVisualization'
import { makeModuleParameterKey, readStoredModuleParameters, serializeModuleParameters } from './features/workspace/moduleParameters'
import { parseHostFromUrl, readNestedString } from './features/workspace/remoteWorkspaceTarget'
import { resolveRuntimeModules } from './features/workspace/runtimeModuleSet'
import { defaultStudioSourceSettings, makePlatformWorkspaceRoot, makeWeekWorkspaceRoot, normalizeStudioSourceSettings, readStudioSourceSettings, storeStudioSourceSettings } from './features/workspace/sourceSettings'
import { makeWorkspaceUiSessionKey, readStudioRuntimeSessionState, storeStudioRuntimeSessionState } from './features/workspace/studioRuntimeSession'
import type { StudioRuntimeSessionState } from './features/workspace/studioRuntimeSession'
import type { ModuleRow } from './features/workspace/types'
import type { ModuleRow, RuntimeImageArtifact } from './features/workspace/types'
import { formatControlRemainingTime, formatSessionClock } from './features/workspace/workspaceFormatters'
import { buildModuleTreeItems, buildPathTreeItems, getEditorLanguage } from './features/workspace/workspaceFiles'
import type { EditorFilePath } from './features/workspace/workspaceFiles'
@@ -81,6 +84,30 @@ type IdeActivity = WorkspaceActivity
const PUBLIC_GUIDE_COURSE_SLUG = 'autonomy-studio-lms-guide'
function resolveModuleRuntimeImages(
profiles: EnvironmentProfile[],
remoteWorkspace: boolean,
activeRuntimeImages: RuntimeImageArtifact[],
): RuntimeImageArtifact[] {
if (!remoteWorkspace) return activeRuntimeImages
const images = profiles
.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
.flatMap((profile) => Array.isArray(profile.config.runtimeImages) ? profile.config.runtimeImages : [])
.filter((image): image is RuntimeImageArtifact => (
typeof image === 'object'
&& image !== null
&& typeof (image as RuntimeImageArtifact).image === 'string'
&& Boolean((image as RuntimeImageArtifact).image?.trim())
// ROS bridges are started by the visualization flow, not module execution.
&& !/ros-bridge/i.test((image as RuntimeImageArtifact).image ?? '')
))
return images.filter((image, index) => (
images.findIndex((candidate) => candidate.image?.trim() === image.image?.trim()) === index
))
}
function makeStudentSlug(email?: string | null): string {
return ((email ?? '').split('@')[0] || 'student').replace(/[^a-zA-Z0-9_-]+/g, '-').toLowerCase()
}
@@ -343,7 +370,7 @@ const configureMonacoTheme: BeforeMount = (monaco) => {
})
}
const imagePreviewSources: Partial<Record<string, string>> = {
const initialImagePreviewSources: Partial<Record<string, string>> = {
'src/renderer/public/assets/icon.png': '/assets/icon.png',
'src/renderer/public/assets/splash.png': '/assets/splash.png',
'src/renderer/public/assets/images/cc_bg.png': '/assets/images/cc_bg.png',
@@ -361,9 +388,11 @@ function LocalPcWorkspaceMockup({
onClose,
onDisconnectSession,
onReleaseControl,
onRequestControlExtension,
onTitlebarNotice,
onRegisterStopRunningModules,
canReleaseControl,
canExtendControl,
controlEndsAt,
theme,
title,
@@ -387,9 +416,11 @@ function LocalPcWorkspaceMockup({
onClose: () => void
onDisconnectSession: () => void
onReleaseControl: () => void
onRequestControlExtension: () => void
onTitlebarNotice: (title: string, tone?: 'info' | 'warning' | 'error', addToList?: boolean) => void
onRegisterStopRunningModules?: (handler: (() => Promise<void>) | null) => void
canReleaseControl: boolean
canExtendControl: boolean
controlEndsAt?: string | null
theme: 'dark' | 'light'
title: string
@@ -453,6 +484,8 @@ function LocalPcWorkspaceMockup({
const [workspaceNavFlash, setWorkspaceNavFlash] = useState(false)
const [moduleWorkspaceSnapshots, setModuleWorkspaceSnapshots] = useState<Record<string, ModuleWorkspaceSnapshot>>({})
const [previewOpen, setPreviewOpen] = useState(false)
const [worldMapSelectorOpen, setWorldMapSelectorOpen] = useState(false)
const [worldEditorOpen, setWorldEditorOpen] = useState(false)
const [previewLarge, setPreviewLarge] = useState(false)
const [previewPosition, setPreviewPosition] = useState({ x: 1040, y: 72 })
const [previewSize, setPreviewSize] = useState({ width: 600, height: 600 })
@@ -494,6 +527,7 @@ function LocalPcWorkspaceMockup({
resetTabDrag
} = useWorkspaceEditorFiles(editorInitialFiles, exampleActive)
const [tabContextMenu, setTabContextMenu] = useState<{ file: EditorFilePath; x: number; y: number } | null>(null)
const [imagePreviewSources, setImagePreviewSources] = useState<Partial<Record<string, string>>>(initialImagePreviewSources)
const [moduleTabContextMenu, setModuleTabContextMenu] = useState<{ moduleName: string; x: number; y: number } | null>(null)
const [gitFileContextMenu, setGitFileContextMenu] = useState<{ moduleName: string; folderName?: string | null; filePath: string; status: GitChangedFile['status']; x: number; y: number } | null>(null)
const {
@@ -634,6 +668,27 @@ function LocalPcWorkspaceMockup({
const settingsActive = activeIdeActivity === 'settings'
const terminalActive = activeIdeActivity === 'workspace' || activeIdeActivity === 'search'
const remoteWorkspace = platformKey !== 'futuredrive-local-pc'
const worldMapStorageKey = `atnmy:lms:world-map:${courseKey}:${weekNumber}:${platformKey}`
const [selectedWorldMapKey, setSelectedWorldMapKey] = useState(() => (
window.localStorage.getItem(worldMapStorageKey) ?? 'nav2-turtlebot3-world'
))
const [worldLayoutVersion, setWorldLayoutVersion] = useState(0)
const availableWorldMaps = useMemo(() => {
const configuredMaps = (curriculumWeek.maps ?? [])
.filter((map): map is WorkspaceMapOption => map.platformKey === 'NAV2')
if (!configuredMaps.length) return NAV2_MAP_OPTIONS
const localCustomMap = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')
return localCustomMap && !configuredMaps.some((map) => map.key === localCustomMap.key)
? [...configuredMaps, localCustomMap]
: configuredMaps
}, [curriculumWeek.maps])
const selectedWorldMap = availableWorldMaps.find((map) => map.key === selectedWorldMapKey) ?? availableWorldMaps[0] ?? NAV2_MAP_OPTIONS[0]
useEffect(() => {
const storedMapKey = window.localStorage.getItem(worldMapStorageKey)
setSelectedWorldMapKey(availableWorldMaps.some((map) => map.key === storedMapKey)
? storedMapKey!
: availableWorldMaps[0]?.key ?? 'nav2-turtlebot3-world')
}, [availableWorldMaps, worldMapStorageKey])
const visualizationProfiles = [
...(platformEnvironmentProfiles?.filter((profile) => profile.week === weekNumber) ?? []),
...(curriculumWeek.environmentProfiles ?? [])
@@ -641,10 +696,10 @@ function LocalPcWorkspaceMockup({
items.findIndex((item) => item.profileType.toLowerCase() === profile.profileType.toLowerCase()) === index
))
const configuredWorkspaceRoot = remoteWorkspace
? defaultStudioSourceSettings.workspaceRoot
? platformSourceSettingsDefaults.workspaceRoot
: sourceSettings.workspaceRoot
const configuredDraftWorkspaceRoot = remoteWorkspace
? defaultStudioSourceSettings.workspaceRoot
? platformSourceSettingsDefaults.workspaceRoot
: sourceSettingsDraft.workspaceRoot
const currentSelectedTreePath = exampleActive ? selectedExampleTreePath : selectedTreePath
const surface = dark ? 'bg-[#202020] text-[#e6e6e6]' : 'bg-[#eef1f6] text-[#151827]'
@@ -655,7 +710,6 @@ function LocalPcWorkspaceMockup({
const textDim = dark ? 'text-white/45' : 'text-[#727b8c]'
const studentSlug = makeStudentSlug(studentEmail)
const clonePathPreview = makePlatformWorkspaceRoot(configuredWorkspaceRoot, studentSlug, courseKey, weekNumber, platformKey)
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platformId}-${studentSlug}-week-${weekNumber}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
const {
visualizationStacks,
visualizationStack,
@@ -663,6 +717,8 @@ function LocalPcWorkspaceMockup({
visualizationRunning,
visualizationBusy,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile,
activeRuntimeImages,
refreshVisualizationStatus,
@@ -672,8 +728,21 @@ function LocalPcWorkspaceMockup({
profiles: visualizationProfiles,
clonePathPreview,
rosDomainId: sourceSettings.rosDomainId,
remoteWorkspace,
mapKey: selectedWorldMap.key,
worldLayoutVersion,
onTitlebarNotice
})
const runtimeModules = useMemo(() => resolveRuntimeModules(
curriculumWeek.modules,
remoteWorkspace
? visualizationProfiles.filter((profile) => ['real_slam', 'real_slam_3d', 'real_navigation'].includes(profile.profileType.toLowerCase()))
: activeEnvironmentProfile,
remoteWorkspace ? 'platform' : 'local'
), [activeEnvironmentProfile, curriculumWeek.modules, remoteWorkspace, visualizationProfiles])
const moduleRuntimeImages = useMemo(() => (
resolveModuleRuntimeImages(visualizationProfiles, remoteWorkspace, activeRuntimeImages)
), [activeRuntimeImages, remoteWorkspace, visualizationProfiles])
const profileSshUser = readNestedString(activeEnvironmentProfile?.config, ['agent', 'sshUser'])
const resolveRemoteWorkspaceTarget = (): RemoteWorkspaceTarget | null => {
const profileAgentUrl = readNestedString(activeEnvironmentProfile?.config, ['agent', 'url'])
@@ -688,7 +757,7 @@ function LocalPcWorkspaceMockup({
host,
user: sshUser ?? undefined,
port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22,
workspaceRoot: remoteRuntimeRoot
workspaceRoot: clonePathPreview
}
}
const getRemoteWorkspaceTarget = (): RemoteWorkspaceTarget => {
@@ -716,7 +785,7 @@ function LocalPcWorkspaceMockup({
}
})
}, [courseKey, weekNumber])
const curriculumModuleKey = (curriculumWeek.modules ?? [])
const curriculumModuleKey = runtimeModules
.map((module) => `${module.id}:${module.name}:${module.folderName}:${module.sortOrder}:${JSON.stringify(module.prebuiltArtifacts ?? [])}`)
.join('|')
const {
@@ -727,7 +796,7 @@ function LocalPcWorkspaceMockup({
loadAvailableModules
} = useWorkspaceModuleList({
clonePathPreview,
curriculumModules: curriculumWeek.modules,
curriculumModules: runtimeModules,
curriculumModuleKey,
remoteWorkspace,
remoteWorkspaceTargetKey,
@@ -807,6 +876,8 @@ function LocalPcWorkspaceMockup({
} = useWorkspaceFileActions({
selectedModule,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
selectedTreePath,
setSelectedTreePath,
setSelectedExampleTreePath,
@@ -971,7 +1042,7 @@ function LocalPcWorkspaceMockup({
let statusMap = gitModuleStatuses
try {
const entries = await Promise.all(installedGitModules.map(async (module) => {
const status = await window.localWorkspace.git.status(module.name, clonePathPreview, module.folderName, null)
const status = await readGitStatus(module, null)
return [module.name, status] as const
}))
statusMap = Object.fromEntries(entries)
@@ -997,15 +1068,25 @@ function LocalPcWorkspaceMockup({
try {
let submittedCommitCount = 0
for (const module of changedModules) {
const result = await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`${weekNumber}주차 ${module.name} 제출`,
undefined,
undefined,
submissionId
)
const result = remoteWorkspace
? await window.remoteWorkspace.git.submit(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
`${weekNumber}주차 ${module.name} 제출`,
undefined,
undefined,
submissionId,
)
: await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`${weekNumber}주차 ${module.name} 제출`,
undefined,
undefined,
submissionId,
)
if (!result.ok) throw new Error(`[${module.name}] ${result.message}`)
if (result.commit) submittedCommitCount += 1
}
@@ -1159,6 +1240,8 @@ function LocalPcWorkspaceMockup({
moduleName: selectedModule?.name,
moduleFolderName: selectedModule?.folderName,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
dark,
terminalActive,
resetKey: `${remoteWorkspace}:${remoteWorkspaceTargetKey}`,
@@ -1293,6 +1376,24 @@ function LocalPcWorkspaceMockup({
const editorLanguage = currentActiveEditorFile ? getEditorLanguage(currentActiveEditorFile) : 'plaintext'
const activeFileIsImage = currentActiveEditorFile ? /\.(png|jpe?g|webp|gif|svg)$/i.test(currentActiveEditorFile) : false
const activeImagePreviewSource = currentActiveEditorFile ? imagePreviewSources[currentActiveEditorFile] : undefined
useEffect(() => {
const file = currentActiveEditorFile
if (!file || !activeFileIsImage || file.startsWith('examples/example-code/') || !selectedModule || imagePreviewSources[file]) return
let disposed = false
const loadImagePreview = async (): Promise<void> => {
try {
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.image(getRemoteWorkspaceTarget(), selectedModule.name, file, selectedModule.folderName)
: await window.localWorkspace.moduleFiles.image(selectedModule.name, file, clonePathPreview, selectedModule.folderName)
if (!disposed) setImagePreviewSources((sources) => ({ ...sources, [file]: result.dataUrl }))
} catch (error) {
console.warn('[module-files] image preview failed', error)
}
}
void loadImagePreview()
return () => { disposed = true }
}, [activeFileIsImage, clonePathPreview, currentActiveEditorFile, imagePreviewSources, remoteWorkspace, remoteWorkspaceTargetKey, selectedModule])
const openEditorFile = (file: EditorFilePath): void => {
if (file.startsWith('examples/example-code/')) {
setSelectedExampleTreePath(file)
@@ -1378,10 +1479,15 @@ function LocalPcWorkspaceMockup({
setInstallingModuleName('__source_settings__')
setModuleInstallError(null)
try {
const result = await window.localWorkspace.modules.stopMany(
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
clonePathPreview
)
const result = remoteWorkspace
? await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
: await window.localWorkspace.modules.stopMany(
runningModules.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
clonePathPreview,
)
if (!result.ok) throw new Error(result.message)
} catch (error) {
console.error('[modules] stop for source settings failed', error)
@@ -1437,7 +1543,17 @@ function LocalPcWorkspaceMockup({
if (!dirtyFiles.length) return
await Promise.all(dirtyFiles.map(async (file) => {
await window.localWorkspace.moduleFiles.write(module.name, file, editorFiles[file] ?? '', clonePathPreview, module.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(
getRemoteWorkspaceTarget(),
module.name,
file,
editorFiles[file] ?? '',
module.folderName,
)
} else {
await window.localWorkspace.moduleFiles.write(module.name, file, editorFiles[file] ?? '', clonePathPreview, module.folderName)
}
setSavedEditorFiles((files) => ({ ...files, [file]: editorFiles[file] ?? '' }))
}))
setDirtyEditorFiles((dirtyFilesState) => {
@@ -1448,13 +1564,13 @@ function LocalPcWorkspaceMockup({
}
const { prepareRemoteRuntimeModule } = useRemoteWorkspaceRuntime({
availableModules,
curriculumModules: curriculumWeek.modules,
curriculumModules: runtimeModules,
courseKey,
weekNumber,
clonePathPreview,
remoteWorkspace,
remoteWorkspaceTargetKey,
activeRuntimeImages,
activeRuntimeImages: moduleRuntimeImages,
getFreshAccessToken,
getRemoteWorkspaceTarget,
saveDirtyFilesForModule,
@@ -1481,16 +1597,29 @@ function LocalPcWorkspaceMockup({
clonePathPreview,
remoteWorkspace,
sourceSettings,
activeRuntimeImages,
activeRuntimeImages: moduleRuntimeImages,
getFreshAccessToken,
getRemoteWorkspaceTarget,
prepareRemoteRuntimeModule,
onTitlebarNotice
})
const deleteModule = async (module: ModuleRow, options: { deleteRepository?: boolean } = {}): Promise<void> => {
if (module.state !== 'stopped') {
onTitlebarNotice(`[${module.name}] 정지된 모듈만 삭제할 수 있습니다.`, 'warning')
return
}
setModuleInstallError(null)
try {
await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.delete(
getRemoteWorkspaceTarget(),
module.name,
`modules/${module.name}`,
module.folderName,
)
} else {
await window.localWorkspace.moduleFiles.delete(module.name, `modules/${module.name}`, clonePathPreview, module.folderName)
}
if (options.deleteRepository) {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/studio/repositories`, {
method: 'DELETE',
@@ -2094,6 +2223,12 @@ function LocalPcWorkspaceMockup({
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null
const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60
const applyWorldMapSelection = (map: WorkspaceMapOption): void => {
window.localStorage.setItem(worldMapStorageKey, map.key)
setSelectedWorldMapKey(map.key)
setWorldMapSelectorOpen(false)
onTitlebarNotice(`[${map.name}] 맵이 선택되었습니다.`, 'info')
}
return (
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
@@ -2111,6 +2246,7 @@ function LocalPcWorkspaceMockup({
previewOpen={previewOpen}
showControlCountdown={showControlCountdown}
controlCountdownDanger={Boolean(controlCountdownDanger)}
canExtendControl={canExtendControl}
controlEndsAt={controlEndsAt ?? null}
submitVisible={curriculumWeek.state !== 'done'}
submitDisabled={gitSubmitting || ((curriculumWeek.detail.requiresSourceSubmission ?? true) && installedGitModules.length === 0)}
@@ -2129,6 +2265,7 @@ function LocalPcWorkspaceMockup({
onTogglePreview={() => setPreviewOpen((value) => !value)}
onSubmit={() => setSubmitConfirmOpen(true)}
onOpenPowerActions={() => setPowerActionModalOpen(true)}
onRequestControlExtension={onRequestControlExtension}
onClose={requestWorkspaceClose}
/>
@@ -2146,16 +2283,44 @@ function LocalPcWorkspaceMockup({
running={visualizationRunning}
busy={visualizationBusy}
viewerUrl={visualizationViewerUrl}
nav2Mode={nav2VisualizationMode}
onDragStart={startPreviewDrag}
onToggleLarge={() => setPreviewLarge((value) => !value)}
onClose={() => setPreviewOpen(false)}
onStackChange={setVisualizationStack}
onStart={() => void startVisualizationBridge()}
onStartNav2={(mode) => {
setNav2VisualizationMode(mode)
void startVisualizationBridge()
}}
onStop={() => void stopVisualizationBridge()}
onResizeStart={startPreviewResize}
/>
)}
{worldMapSelectorOpen && (
<WorldMapSelectorModal
platformKey={activeEnvironmentProfile?.profileType ?? 'NAV2'}
weekNumber={weekNumber}
maps={availableWorldMaps}
selectedMapKey={selectedWorldMap.key}
onClose={() => setWorldMapSelectorOpen(false)}
onCreate={() => {
setWorldMapSelectorOpen(false)
setWorldEditorOpen(true)
}}
onSelect={applyWorldMapSelection}
/>
)}
{worldEditorOpen && (
<WorldEditorModal
workspaceRoot={clonePathPreview}
onSaved={() => setWorldLayoutVersion((version) => version + 1)}
onClose={() => setWorldEditorOpen(false)}
/>
)}
{treeDialog && (
<TreeTargetDialog
dark={dark}
@@ -2616,6 +2781,7 @@ function LocalPcWorkspaceMockup({
controllingModuleNames={controllingModuleNames}
listError={moduleListError}
installError={moduleInstallError}
allowRepositoryDeletion={!remoteWorkspace}
onAddModuleTab={addModuleTabOnly}
onOpenSource={openModuleSource}
onInstallModule={(module) => void installModule(module)}
@@ -2628,6 +2794,13 @@ function LocalPcWorkspaceMockup({
onInstallAll={() => void installAllModules()}
onStartAll={() => void startAllModules()}
onStopAll={() => void stopAllModules()}
onOpenWorldEditor={() => {
if (remoteWorkspace) {
onTitlebarNotice('맵 설정은 현재 로컬 플랫폼에서만 지원합니다.', 'warning')
return
}
setWorldMapSelectorOpen(true)
}}
/>
</div>
)}
@@ -2721,6 +2894,7 @@ export default function App(): React.JSX.Element {
const [authSession, setAuthSession] = useState<LmsAuthSession | null>(null)
const authSessionRef = useRef<LmsAuthSession | null>(null)
const [authReady, setAuthReady] = useState(false)
const [launcherSessionEnded, setLauncherSessionEnded] = useState(false)
const [studentCoursesLoading, setStudentCoursesLoading] = useState(false)
const [curriculumLoading, setCurriculumLoading] = useState(false)
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
@@ -2754,9 +2928,12 @@ export default function App(): React.JSX.Element {
const [lastWorkspacePlatform, setLastWorkspacePlatform] = useState<PlatformOption | null>(null)
const connectedPlatformRef = useRef<PlatformOption | null>(null)
const runningPlatformsRef = useRef<PlatformOption[]>([])
const platformStatusRequestSequenceRef = useRef(0)
const forcedPlatformTerminationHandlerRef = useRef<(payload: PlatformControlTerminatedPayload) => void>(() => undefined)
const [workspaceWeekState, setWorkspaceWeekState] = useState<CurriculumWeek | null>(null)
const [sessionConfirmAction, setSessionConfirmAction] = useState<'disconnect' | 'release' | null>(null)
const [controlExtensionConfirmOpen, setControlExtensionConfirmOpen] = useState(false)
const [controlExtensionSubmitting, setControlExtensionSubmitting] = useState(false)
const [runningPlatformCloseTarget, setRunningPlatformCloseTarget] = useState<PlatformOption | null>(null)
const [titlebarNotices, setTitlebarNotices] = useState<Array<{ id: number; title: string; tone?: 'info' | 'warning' | 'error'; addToList?: boolean }>>([])
const instructorAccount = isInstructorAccount(authSession?.user)
@@ -2828,6 +3005,10 @@ export default function App(): React.JSX.Element {
clearStoredAuthSession()
setAuthSession(null)
}, [])
useEffect(() => window.launcherAuth?.onSessionExpired?.(() => {
clearAuthSession()
setLauncherSessionEnded(true)
}), [clearAuthSession])
useEffect(() => {
authSessionRef.current = authSession
}, [authSession])
@@ -2856,7 +3037,6 @@ export default function App(): React.JSX.Element {
?? null
const profileSshUser = readNestedString(profile?.config, ['agent', 'sshUser'])
const studentSlug = makeStudentSlug(authSession?.user.email)
const remoteRuntimeRoot = `/tmp/autonomy-studio/sessions/${`${platform.id}-${studentSlug}-week-${workspaceWeek.week}`.replace(/[^a-zA-Z0-9_.-]+/g, '-')}`
const profileAgentUrl = readNestedString(profile?.config, ['agent', 'url'])
const host = parseHostFromUrl(profileAgentUrl)
?? parseHostFromUrl(readNestedString(profile?.config, ['bridge', 'env', 'BRIDGE_HTTP_URL']))
@@ -2868,7 +3048,13 @@ export default function App(): React.JSX.Element {
host,
user: profileSshUser ?? platformSettings.sshUser ?? platformDefaults.sshUser ?? undefined,
port: Number.isFinite(sshPort) && sshPort > 0 ? sshPort : 22,
workspaceRoot: remoteRuntimeRoot
workspaceRoot: makePlatformWorkspaceRoot(
platformSettings.workspaceRoot,
studentSlug,
courseKey,
workspaceWeek.week,
platform.platformKey,
)
}
}
const stopRemotePlatformModules = async (platform: PlatformOption): Promise<void> => {
@@ -2877,7 +3063,7 @@ export default function App(): React.JSX.Element {
const modules = (workspaceWeek.modules ?? [])
.slice()
.sort((left, right) => (right.sortOrder ?? 0) - (left.sortOrder ?? 0))
.map((module) => ({ moduleName: module.name, folderName: module.folderName }))
.map((module) => ({ moduleName: module.name, folderName: module.folderName, templateRepositoryUrl: module.templateRepositoryUrl }))
if (!modules.length) return
await window.remoteWorkspace.modules.resetSession(target, modules).catch(() => ({ ok: false, message: '플랫폼 초기화 실패' }))
}
@@ -3060,6 +3246,7 @@ export default function App(): React.JSX.Element {
const loadCurriculum = useCallback(async (): Promise<void> => {
if (!authReady || !studentCoursesLoaded || courseSelectionVisible || !selectedCourseId) return
const curriculumLoadingStartedAt = Date.now()
setCurriculumLoading(true)
try {
const payload = await fetchCurriculum(fetchWithFreshAuth, selectedCourseId)
@@ -3103,6 +3290,8 @@ export default function App(): React.JSX.Element {
setPlatformOptions([])
setSelectedWeekState(null)
} finally {
const remainingMs = Math.max(0, 500 - (Date.now() - curriculumLoadingStartedAt))
if (remainingMs) await new Promise<void>((resolve) => window.setTimeout(resolve, remainingMs))
setCurriculumLoading(false)
}
}, [authReady, authSession?.accessToken, courseSelectionVisible, fetchWithFreshAuth, selectedCourseId, selectedWeekState?.week, studentCoursesLoaded])
@@ -3130,23 +3319,58 @@ export default function App(): React.JSX.Element {
let cancelled = false
const refreshPlatformStatuses = async () => {
const requestSequence = ++platformStatusRequestSequenceRef.current
try {
const platforms = await fetchPlatformStatuses(fetchWithFreshAuth, remotePlatformIds)
if (cancelled || !platforms.length) return
if (cancelled || requestSequence !== platformStatusRequestSequenceRef.current || !platforms.length) return
const statusById = new Map(platforms.map((platform) => [platform.id, platform]))
const mergePlatformStatus = (platform: PlatformOption): PlatformOption => {
const status = statusById.get(platform.id)
return status ? { ...platform, ...status } : platform
}
setPlatformOptions((current) => current.map(mergePlatformStatus))
setRunningPlatforms((current) => current
const lostControlPlatformIds = new Set(
runningPlatformsRef.current
.filter((platform) => {
if (platform.platformKey === 'futuredrive-local-pc') return false
const nextControl = statusById.get(platform.id)?.control
if (!nextControl) return false
if (nextControl.status !== 'ACTIVE') return true
const currentSessionId = platform.control?.session?.id
return Boolean(
currentSessionId
&& nextControl.session?.id
&& nextControl.session.id !== currentSessionId,
)
})
.map((platform) => platform.id),
)
const remainingRunningPlatforms = runningPlatformsRef.current
.filter((platform) => !lostControlPlatformIds.has(platform.id))
.map(mergePlatformStatus)
.filter((platform) => hasCurrentUserPlatformControl(platform)))
setConnectedPlatform((current) => {
if (!current) return current
const nextPlatform = mergePlatformStatus(current)
return hasCurrentUserPlatformControl(nextPlatform) ? nextPlatform : null
})
if (lostControlPlatformIds.size) {
setRunningPlatforms(remainingRunningPlatforms)
setConnectedPlatform((current) => {
if (current && !lostControlPlatformIds.has(current.id)) return mergePlatformStatus(current)
return remainingRunningPlatforms[0] ?? null
})
setLastWorkspacePlatform(remainingRunningPlatforms[0] ?? null)
if (!remainingRunningPlatforms.length) {
setLocalPcLayoutOpen(false)
setStudioEntered(false)
setSessionStatus('ended')
setSessionEndedAt(new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit', hour12: false }))
setWorkspaceWeekState(null)
}
setCurriculumRefreshSignal((current) => current + 1)
} else {
setRunningPlatforms((current) => current.map(mergePlatformStatus))
setConnectedPlatform((current) => {
if (!current) return current
return mergePlatformStatus(current)
})
}
} catch {
// Status polling is best-effort; keep the last known platform state.
}
@@ -3164,10 +3388,15 @@ export default function App(): React.JSX.Element {
const restored = restoredStudioSessionRef.current
if (!restored || !platformOptions.length) return
const restoredPlatformById = new Map(restored.runningPlatforms.map((platform) => [platform.id, platform]))
const nextRunningPlatforms = restored.runningPlatformIds
.map((id) => platformOptions.find((platform) => platform.id === id))
.filter((platform): platform is PlatformOption => Boolean(platform))
.filter((platform) => hasCurrentUserPlatformControl(platform))
.map((id) => {
const restoredPlatform = restoredPlatformById.get(id)
const currentPlatform = platformOptions.find((platform) => platform.id === id)
if (restoredPlatform && currentPlatform) return { ...restoredPlatform, ...currentPlatform }
return currentPlatform ?? restoredPlatform ?? null
})
.filter((platform): platform is PlatformOption => Boolean(platform) && hasCurrentUserPlatformControl(platform))
const nextConnectedPlatform = restored.connectedPlatformId
? nextRunningPlatforms.find((platform) => platform.id === restored.connectedPlatformId) ?? nextRunningPlatforms[0] ?? null
: nextRunningPlatforms[0] ?? null
@@ -3201,6 +3430,7 @@ export default function App(): React.JSX.Element {
sessionEndedAt,
connectedPlatformId: connectedPlatform?.id ?? null,
runningPlatformIds: runningPlatforms.map((platform) => platform.id),
runningPlatforms,
workspaceWeekNumber: workspaceWeekState?.week ?? null
})
}, [connectedPlatform?.id, localPcLayoutOpen, runningPlatforms, sessionEndedAt, sessionStartedAt, sessionStatus, studioEntered, workspaceWeekState?.week])
@@ -3390,6 +3620,15 @@ export default function App(): React.JSX.Element {
].join(' ')
const fullAppModalOverlayClass = 'fixed bottom-0 left-0 right-0 top-[38px] z-[80] grid place-items-center bg-black/36'
const canReleaseConnectedPlatform = connectedPlatform?.control?.status === 'ACTIVE'
const controlWarningMatchesConnectedPlatform = Boolean(
controlWarningNotice
&& controlWarningPlatform?.id === connectedPlatform?.id,
)
const canExtendConnectedPlatform = Boolean(
canReleaseConnectedPlatform
&& (connectedPlatform?.control?.warning || controlWarningMatchesConnectedPlatform)
&& (connectedPlatform.control.session?.extensionCount ?? 0) < 1,
)
const connectedPlatformControlEndsAt = canReleaseConnectedPlatform
? connectedPlatform?.control?.session?.endsAt ?? null
: null
@@ -3499,12 +3738,16 @@ export default function App(): React.JSX.Element {
window.sessionStorage.removeItem(workspaceKey)
}
const connectedPlatformId = connectedPlatformRef.current?.id ?? null
const closesConnectedWorkspace = connectedPlatformId === platformToRemove.id
setRunningPlatforms((platforms) => {
const nextPlatforms = platforms.filter((platform) => platform.id !== platformToRemove.id)
const nextConnectedPlatform = nextPlatforms[0] ?? null
const nextConnectedPlatform = !closesConnectedWorkspace && connectedPlatformId
? nextPlatforms.find((platform) => platform.id === connectedPlatformId) ?? nextPlatforms[0] ?? null
: nextPlatforms[0] ?? null
setConnectedPlatform(nextConnectedPlatform)
setLastWorkspacePlatform(nextConnectedPlatform)
setLocalPcLayoutOpen(false)
if (closesConnectedWorkspace || !nextPlatforms.length) setLocalPcLayoutOpen(false)
setSessionStatus(nextPlatforms.length ? 'running' : 'ended')
if (!nextPlatforms.length) {
setLastWorkspacePlatform(null)
@@ -3549,7 +3792,9 @@ export default function App(): React.JSX.Element {
} else {
await stopRemotePlatformModules(platformToClose)
}
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
if (closeConnectedWorkspace) {
await window.localWorkspace.visualization.stop(connectedEnvironmentProfile?.config).catch(() => ({ ok: false, message: '환경 종료 실패' }))
}
} finally {
const remainingMs = closeConnectedWorkspace ? Math.max(0, 2000 - (Date.now() - loadingStartedAt)) : 0
window.setTimeout(() => {
@@ -3664,6 +3909,38 @@ export default function App(): React.JSX.Element {
}, remainingMs)
}
}
const handleExtendControl = async (): Promise<void> => {
if (!connectedPlatform || !canExtendConnectedPlatform || controlExtensionSubmitting) return
setControlExtensionSubmitting(true)
try {
const result = await extendPlatformControl(fetchWithFreshAuth, connectedPlatform.id)
if (!result.session) throw new Error('연장된 제어 시간을 확인하지 못했습니다.')
const updatePlatform = (platform: PlatformOption): PlatformOption => {
if (platform.id !== connectedPlatform.id || !platform.control) return platform
return {
...platform,
control: {
...platform.control,
warning: false,
session: result.session,
},
}
}
setPlatformOptions((platforms) => platforms.map(updatePlatform))
setRunningPlatforms((platforms) => platforms.map(updatePlatform))
setConnectedPlatform((platform) => platform ? updatePlatform(platform) : platform)
setControlWarningNotice(null)
setControlExtensionConfirmOpen(false)
showTitlebarNotice(`${connectedPlatform.title} 제어 시간이 연장되었습니다.`, 'info', true)
} catch (error) {
showTitlebarNotice(error instanceof Error ? error.message : '제어 시간 연장에 실패했습니다.', 'warning', true)
} finally {
setControlExtensionSubmitting(false)
}
}
const expireBackgroundPlatformControl = async (platform: PlatformOption): Promise<void> => {
await stopRemotePlatformModules(platform)
if (authSession?.accessToken) {
@@ -3836,6 +4113,8 @@ export default function App(): React.JSX.Element {
}
const applyPlatformControlResponse = (platformId: string, payload: PlatformControlActionResponse): void => {
// Do not let an older status poll overwrite this control action result.
platformStatusRequestSequenceRef.current += 1
const updatePlatform = (platform: PlatformOption): PlatformOption => {
if (platform.id !== platformId) return platform
if (payload.result === 'GRANTED' || payload.result === 'ALREADY_GRANTED') {
@@ -3876,7 +4155,7 @@ export default function App(): React.JSX.Element {
state: 'reserved',
control: {
status: payload.session ? 'IN_USE' : 'AVAILABLE',
action: payload.session ? '대기 예약' : '제어권 요청',
action: payload.session ? '대기 예약' : '제어요청',
warning: false,
session: payload.session ?? null,
queue: null,
@@ -3980,9 +4259,8 @@ export default function App(): React.JSX.Element {
const position = payload.queue?.position
showTitlebarNotice(`${platform.title} 대기 예약되었습니다${position ? ` (${position}번째)` : ''}`, 'info', true)
}
setCurriculumRefreshSignal((current) => current + 1)
} catch {
setCurriculumRefreshSignal((current) => current + 1)
// Keep the existing curriculum visible when a control request fails.
} finally {
await waitForMinimumLoading(loadingStartedAt)
if (shouldWaitForControlPaint) await waitForNextPaint()
@@ -4066,7 +4344,12 @@ export default function App(): React.JSX.Element {
: runningPlatforms[0]
if (platform) openRunningPlatformWorkspace(platform)
}}
onDisconnectSession={() => setSessionConfirmAction('disconnect')}
onDisconnectSession={(sessionId) => {
const platform = sessionId
? runningPlatforms.find((current) => current.id === sessionId)
: connectedPlatform
if (platform) setRunningPlatformCloseTarget(platform)
}}
onReleaseControl={() => setSessionConfirmAction('release')}
/>
<main
@@ -4089,18 +4372,23 @@ export default function App(): React.JSX.Element {
<div className={localPcLayoutOpen ? 'h-full' : 'hidden h-full'}>
<LocalPcWorkspaceMockup
key={workspacePlatform?.id ?? 'local-pc-workspace'}
onClose={closeLocalPcWorkspace}
onClose={() => {
closeLocalPcWorkspace()
setOuterView('curriculum')
}}
onDisconnectSession={() => setSessionConfirmAction('disconnect')}
onReleaseControl={() => setSessionConfirmAction('release')}
onRequestControlExtension={() => setControlExtensionConfirmOpen(true)}
onTitlebarNotice={showTitlebarNotice}
onRegisterStopRunningModules={(handler) => {
stopRunningWorkspaceModulesRef.current = handler
}}
canReleaseControl={canReleaseConnectedPlatform}
canExtendControl={canExtendConnectedPlatform}
controlEndsAt={connectedPlatformControlEndsAt}
theme={ideTheme}
title={workspaceWeek.title}
platformName={activePlatformName}
platformName={workspacePlatform?.title ?? activePlatformName}
platformId={workspacePlatform?.id ?? 'local-pc'}
platformKey={workspacePlatform?.platformKey ?? 'futuredrive-local-pc'}
platformEnvironmentProfiles={workspacePlatform?.environmentProfiles}
@@ -4285,6 +4573,11 @@ export default function App(): React.JSX.Element {
title="제어 시간 안내"
message="제어 시간이 곧 종료됩니다."
detail={`남은 시간 ${formatControlRemainingTime(controlWarningNotice.endsAt)}${formatSessionClock(controlWarningNotice.endsAt) ? ` · 종료 ${formatSessionClock(controlWarningNotice.endsAt)}` : ''}`}
actionLabel={canExtendConnectedPlatform ? '연장' : undefined}
onAction={canExtendConnectedPlatform ? () => {
setControlWarningNotice(null)
setControlExtensionConfirmOpen(true)
} : undefined}
onConfirm={() => setControlWarningNotice(null)}
>
{(controlWarningPlatform || controlWarningWeekLabel) && (
@@ -4342,6 +4635,30 @@ export default function App(): React.JSX.Element {
}}
/>
)}
{controlExtensionConfirmOpen && (
<ControlExtensionConfirmModal
overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
platformName={activePlatformName}
submitting={controlExtensionSubmitting}
onCancel={() => setControlExtensionConfirmOpen(false)}
onConfirm={() => void handleExtendControl()}
/>
)}
{launcherSessionEnded && (
<StatusNoticeModal
overlayClassName={fullAppModalOverlayClass}
dark={appModalDark}
tone="warning"
title="LMS 세션 종료"
message="런처에서 로그아웃되어 LMS 세션이 종료되었습니다."
detail="런처에 다시 로그인한 뒤 LMS를 실행해주세요."
onConfirm={() => {
window.close()
window.setTimeout(() => window.location.replace('about:blank'), 100)
}}
/>
)}
{platformLoading && !platformModalOpen && !recentSessionModalOpen && (
<PlatformLoadingOverlay
title={platformLoadingCopy.title}
@@ -60,6 +60,8 @@ export function StatusNoticeModal({
message,
detail,
children,
actionLabel,
onAction,
onConfirm
}: AppModalTheme & {
tone: ModalTone
@@ -67,6 +69,8 @@ export function StatusNoticeModal({
message: string
detail?: string
children?: React.ReactNode
actionLabel?: string
onAction?: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
@@ -84,7 +88,12 @@ export function StatusNoticeModal({
{detail}
</p>
)}
<div className="mt-4 flex justify-end">
<div className="mt-4 flex justify-end gap-2">
{actionLabel && onAction && (
<button type="button" onClick={onAction} className={cancelButtonClass(dark)}>
{actionLabel}
</button>
)}
<button type="button" onClick={onConfirm} className={confirmButtonClass('primary')}>
확인
</button>
@@ -195,6 +204,43 @@ export function SessionConfirmModal({
)
}
export function ControlExtensionConfirmModal({
overlayClassName,
dark,
platformName,
submitting = false,
onCancel,
onConfirm,
}: AppModalTheme & {
platformName: string
submitting?: boolean
onCancel: () => void
onConfirm: () => void
}): React.JSX.Element {
return (
<div className={overlayClassName}>
<section className={modalClass(dark)}>
<div className={['flex items-center gap-2 border-b px-4 py-3', borderClass(dark)].join(' ')}>
<Clock3 size={15} className="text-[#f59e0b]" />
<strong className="text-[13px] font-bold">제어 시간 연장</strong>
</div>
<div className="p-4">
<p className={['text-[13px] font-semibold', strongClass(dark)].join(' ')}>{platformName} 제어 시간을 연장할까요?</p>
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dimClass(dark)].join(' ')}>
대기 중인 예약이 없을 때 현재 설정된 제어 시간만큼 한 번만 연장됩니다.
</p>
<div className="mt-4 flex justify-end gap-2">
<button type="button" disabled={submitting} onClick={onCancel} className={cancelButtonClass(dark)}>취소</button>
<button type="button" disabled={submitting} onClick={onConfirm} className={confirmButtonClass('primary')}>
{submitting ? '연장 중' : '연장'}
</button>
</div>
</div>
</section>
</div>
)
}
function ConfirmStatusModal({
overlayClassName,
dark,
+7 -7
View File
@@ -33,7 +33,7 @@ export default function Sidebar({
runningSessions?: Array<{ id: string; title: string; startedAt?: string | null; endsAt?: string | null; dotClassName?: string }>
canReleaseControl?: boolean
onOpenSession?: (sessionId?: string) => void
onDisconnectSession?: () => void
onDisconnectSession?: (sessionId?: string) => void
onReleaseControl?: () => void
}): React.JSX.Element {
const [sessionPopoverOpen, setSessionPopoverOpen] = useState(false)
@@ -133,7 +133,7 @@ export default function Sidebar({
>
{!collapsed && (
<div className="session-scrollbar grid max-h-[286px] gap-2 overflow-y-auto pr-1">
{visibleRunningSessions.map((session, index) => (
{visibleRunningSessions.map((session) => (
<div key={session.id} className="relative">
<button
type="button"
@@ -152,13 +152,13 @@ export default function Sidebar({
)}
</span>
</button>
{sessionStatus === 'running' && index === 0 && (
{sessionStatus === 'running' && (
<button
type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => {
event.stopPropagation()
onDisconnectSession?.()
onDisconnectSession?.(session.id)
}}
aria-label="세션 연결종료"
title="연결종료"
@@ -183,7 +183,7 @@ export default function Sidebar({
{collapsed && sessionPopoverOpen && (
<div className="absolute bottom-0 left-[62px] z-20 w-[240px] rounded-md border border-white/10 bg-[#111520] py-1.5 shadow-[18px_0_42px_rgba(0,0,0,0.22)]">
<div className="session-scrollbar max-h-[286px] overflow-y-auto pr-1">
{visibleRunningSessions.map((session, index) => (
{visibleRunningSessions.map((session) => (
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
<button
type="button"
@@ -205,14 +205,14 @@ export default function Sidebar({
)}
</span>
</button>
{sessionStatus === 'running' && index === 0 && (
{sessionStatus === 'running' && (
<button
type="button"
className="absolute right-2.5 top-1/2 grid h-8 w-8 -translate-y-1/2 shrink-0 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/[0.08] hover:text-white"
onClick={(event) => {
event.stopPropagation()
setSessionPopoverOpen(false)
onDisconnectSession?.()
onDisconnectSession?.(session.id)
}}
aria-label="세션 연결종료"
title="연결종료"
+33 -8
View File
@@ -36,6 +36,7 @@ interface Window {
appUrl: string | null
handoffToken: string | null
audienceApp: string
onSessionExpired?: (listener: () => void) => () => void
}
lmsBootSplash?: {
status: (message: string) => void
@@ -56,6 +57,7 @@ interface Window {
files: Record<string, string>
folders: string[]
}>
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => Promise<{ ok: boolean }>
@@ -208,6 +210,15 @@ interface Window {
localWorkspace: {
moduleFiles: Window['moduleFiles']
modules: Window['modules']
nav2: {
setSimulatorPose: (pose: { x: number; y: number; yaw: number }) => Promise<{ ok: boolean; message: string }>
addRuntimeObstacle: (obstacle: { id: string; x: number; y: number; yaw: number; length: number; depth: number; height: number }) => Promise<{ ok: boolean; message: string }>
clearRuntimeObstacles: () => Promise<{ ok: boolean; message: string }>
}
worldLayout: {
read: (workspaceRoot?: string) => Promise<WorldLayout>
write: (workspaceRoot: string | undefined, layout: WorldLayout) => Promise<WorldLayout>
}
visualization: Window['visualization']
git: Window['git']
terminal: Window['terminal']
@@ -215,6 +226,7 @@ interface Window {
remoteWorkspace: {
moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ReturnType<Window['moduleFiles']['image']>
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ReturnType<Window['moduleFiles']['create']>
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ReturnType<Window['moduleFiles']['write']>
copy: (target: RemoteWorkspaceTarget, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => ReturnType<Window['moduleFiles']['copy']>
@@ -240,12 +252,12 @@ interface Window {
runtime: string
}
}>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
start: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
build: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
stop: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; message: string }>
stopMany: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null) => Promise<{ ok: boolean; message: string }>
resetSession: (target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => Promise<{ ok: boolean; message: string }>
restart: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: RuntimeImageArtifact[] | null, dockerImage?: string | null) => Promise<{ ok: boolean; message: string }>
logs: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => Promise<{ ok: boolean; logs: string; message?: string }>
}
git: {
@@ -259,14 +271,27 @@ interface Window {
start: (target: RemoteWorkspaceTarget, moduleName?: string, cols?: number, rows?: number, sessionId?: string, folderName?: string) => Promise<boolean>
resize: (target: RemoteWorkspaceTarget, cols?: number, rows?: number, sessionId?: string) => Promise<boolean>
changeDirectory: (target: RemoteWorkspaceTarget, moduleName: string, sessionId?: string, folderName?: string) => Promise<boolean>
write: (data: string, sessionId?: string) => void
stop: (sessionId?: string) => void
onData: (callback: (data: string) => void, sessionId?: string) => () => void
onSuppressData: (callback: (data: string) => void, sessionId?: string) => () => void
write: (target: RemoteWorkspaceTarget, data: string, sessionId?: string) => void
stop: (target: RemoteWorkspaceTarget, sessionId?: string) => void
onData: (target: RemoteWorkspaceTarget, callback: (data: string) => void, sessionId?: string) => () => void
onSuppressData: (target: RemoteWorkspaceTarget, callback: (data: string) => void, sessionId?: string) => () => void
}
}
}
type WorldLayoutObject = {
id: string
type: 'pillar' | 'wall' | 'box'
position: [number, number, number]
rotation: [number, number, number]
size: [number, number, number]
}
type WorldLayout = {
version: 1
objects: WorldLayoutObject[]
}
type RemoteWorkspaceTarget = {
host: string
user?: string | null
@@ -65,9 +65,20 @@ export type CurriculumWeek = {
templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
defaultParameters?: string[] | null
dockerImage?: string | null
}>
environmentProfiles?: EnvironmentProfile[]
maps?: Array<{
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
layout?: unknown
}>
}
export type CurriculumResponse = {
@@ -25,7 +25,7 @@ export default function PlatformSelectModal({
}: PlatformSelectModalProps): React.JSX.Element {
return (
<div className="fixed bottom-0 left-0 right-0 top-[38px] z-50 grid place-items-center bg-[#080a10]/35 p-6 backdrop-blur-[2px]">
<section className="flex h-[660px] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
<section className="flex h-[min(660px,calc(100vh-86px))] w-full max-w-[560px] flex-col overflow-hidden rounded-xl border border-[#dfe3ed] bg-white shadow-[0_28px_80px_rgba(8,10,16,0.28)]">
<header className="flex items-start justify-between gap-4 border-b border-[#e5e8ef] px-6 pb-4 pt-5">
<div>
<span className="text-[12px] font-medium text-[#6f4df6]">{weekLabel}</span>
@@ -45,7 +45,7 @@ export default function PlatformSelectionPanel({
onOpenSettings
}: PlatformSelectionPanelProps): React.JSX.Element {
return (
<div className={['min-h-0 w-1/2 shrink-0 px-6 pb-5 pt-3 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
<div className={['h-full min-h-0 w-1/2 shrink-0 overflow-y-auto px-6 pb-5 pt-3 pr-5 transition-opacity duration-300', mode === 'settings' ? 'opacity-0' : 'opacity-100'].join(' ')}>
{runningPlatforms.length > 0 && (
<div className="mb-4 pr-1">
<p className="mb-2 text-[12px] font-black text-[#2563eb]">실행중인 플랫폼</p>
@@ -76,12 +76,7 @@ export default function PlatformSelectionPanel({
counts={counts}
onChange={onFilterChange}
/>
<div data-help="platform-list" className={[
'grid content-start gap-3 overflow-y-auto pr-1',
runningPlatforms.length > 0
? 'min-h-[340px] max-h-[340px]'
: 'min-h-[470px] max-h-[470px]'
].join(' ')}>
<div data-help="platform-list" className="grid content-start gap-3">
{filteredPlatforms.length ? filteredPlatforms.map((platform) => {
const { id, status, action, state, iconKey } = platform
const Icon = PLATFORM_ICONS[iconKey] ?? Monitor
@@ -75,6 +75,20 @@ export async function releasePlatformControl(fetchWithFreshAuth: AuthFetch, plat
if (!response.ok) throw new Error('platform-release failed')
}
export async function extendPlatformControl(
fetchWithFreshAuth: AuthFetch,
platformId: string,
): Promise<PlatformControlActionResponse> {
const response = await fetchWithFreshAuth(`${LMS_API_BASE_URL}/platform-control/platforms/${encodeURIComponent(platformId)}/extend`, {
method: 'POST',
})
const payload = await response.json().catch(() => null) as PlatformControlActionResponse | { message?: string } | null
if (!response.ok) {
throw new Error(payload && 'message' in payload && payload.message ? payload.message : '제어 시간 연장에 실패했습니다.')
}
return payload as PlatformControlActionResponse
}
export async function requestOrCancelPlatformControl(
fetchWithFreshAuth: AuthFetch,
platform: PlatformOption
+2 -1
View File
@@ -51,6 +51,7 @@ export type PlatformOption = {
startedAt: string
endsAt: string
warningAt: string
extensionCount?: number
} | null
queue: {
id: string
@@ -63,7 +64,7 @@ export type PlatformOption = {
export type PlatformControlState = NonNullable<PlatformOption['control']>
export type PlatformControlActionResponse = {
result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED'
result?: 'GRANTED' | 'ALREADY_GRANTED' | 'QUEUED' | 'CANCELED' | 'EXTENDED'
platformId?: string
session?: PlatformControlState['session']
queue?: PlatformControlState['queue']
@@ -9,6 +9,7 @@ type DeletableModule = {
type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
dark: boolean
module: Module
allowRepositoryDeletion?: boolean
onCancel: () => void
onConfirm: (module: Module, options: { deleteRepository: boolean }) => void
}
@@ -16,6 +17,7 @@ type ModuleDeleteConfirmProps<Module extends DeletableModule> = {
export default function ModuleDeleteConfirm<Module extends DeletableModule>({
dark,
module,
allowRepositoryDeletion = true,
onCancel,
onConfirm
}: ModuleDeleteConfirmProps<Module>): React.JSX.Element {
@@ -40,7 +42,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
<p className={['mt-2 text-[12px] font-medium leading-relaxed', dark ? 'text-white/45' : 'text-[#727b8c]'].join(' ')}>
워크스페이스에 설치된 모듈 폴더가 삭제됩니다.
</p>
<label
{allowRepositoryDeletion && <label
className={[
'mt-4 flex cursor-pointer items-start gap-2 rounded-md border p-3',
dark ? 'border-white/10 bg-white/[0.03]' : 'border-[#d8dee9] bg-[#f8fafc]'
@@ -60,7 +62,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
제출 커밋과 작업 이력이 삭제되고, 다음 설치 시 템플릿 저장소 기준으로 새로 생성됩니다.
</span>
</span>
</label>
</label>}
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
@@ -74,7 +76,7 @@ export default function ModuleDeleteConfirm<Module extends DeletableModule>({
</button>
<button
type="button"
onClick={() => onConfirm(module, { deleteRepository })}
onClick={() => onConfirm(module, { deleteRepository: allowRepositoryDeletion && deleteRepository })}
className="h-8 cursor-pointer rounded-md bg-[#d14b4b] px-3 text-[12px] font-bold text-white transition hover:bg-[#bd3838]"
>
삭제
@@ -6,12 +6,10 @@ type ModuleListHeaderProps = {
export default function ModuleListHeader({ dark }: ModuleListHeaderProps): React.JSX.Element {
return (
<div className={['grid h-9 grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
<div className={['grid h-9 grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center border-b px-3 text-[11px] font-bold', dark ? 'border-white/10 bg-[#2b2b2b] text-white/45' : 'border-[#e1e6ef] bg-[#f3f5f9] text-[#727b8c]'].join(' ')}>
<span className="text-center">상태</span>
<span className="text-center">분류</span>
<span>모듈명</span>
<span className="text-center">CPU</span>
<span className="text-center">메모리</span>
<span className="text-center">가동시간</span>
<span className="col-span-2 text-center">제어</span>
</div>
@@ -19,6 +19,7 @@ type ModuleListRowProps<Module extends ModuleListRowItem> = {
module: Module
installing: boolean
controlling: boolean
bulkControlling: boolean
onAddModuleTab: (module: Module) => void
onOpenSource: (module: Module) => void
onOpenParameters: (module: Module) => void
@@ -80,6 +81,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
module,
installing,
controlling,
bulkControlling,
onAddModuleTab,
onOpenSource,
onOpenParameters,
@@ -92,6 +94,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
}: ModuleListRowProps<Module>): React.JSX.Element {
const selectable = module.state !== 'uninstalled'
const busy = installing || controlling
const parameters = Array.isArray(module.parameters) ? module.parameters : []
const stateStyle: Record<ModuleListRowItem['state'], string> = {
running: dark ? 'bg-[#12372d] text-[#72e2b5]' : 'bg-[#dff8ed] text-[#177553]',
stopped: dark ? 'bg-white/8 text-white/45' : 'bg-[#edf0f5] text-[#687284]',
@@ -121,6 +124,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
const installAction = module.state === 'uninstalled'
? { label: '설치', Icon: Download, tone: 'install' as ModuleActionTone }
: { label: '삭제', Icon: Trash2, tone: 'delete' as ModuleActionTone }
const installActionDisabled = busy || (module.state !== 'uninstalled' && module.state !== 'stopped')
return (
<div
@@ -128,7 +132,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
if (selectable) onOpenSource(module)
}}
className={[
'grid h-[58px] grid-cols-[78px_100px_minmax(150px,1fr)_minmax(104px,0.45fr)_minmax(104px,0.45fr)_84px_104px_62px] items-center px-3 text-[12px] transition',
'grid h-[58px] grid-cols-[88px_115px_minmax(220px,1fr)_105px_132px_70px] items-center px-3 text-[12px] transition',
'cursor-default',
selectable
? dark ? 'text-white/70 hover:bg-white/[0.045]' : 'text-[#4f596b] hover:bg-[#f5f7fb]'
@@ -158,37 +162,9 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
dark ? 'text-white/32 hover:text-white/55' : 'text-[#8a94a5] hover:text-[#4f596b]'
].join(' ')}
>
{module.parameters.length ? module.parameters.join(' ') : '파라미터 설정'}
{parameters.length ? parameters.join(' ') : '파라미터 설정'}
</button>
</span>
<span className="flex items-center justify-center gap-1.5">
{module.cpu === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
) : (
<>
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
{module.cpu.toFixed(module.cpu >= 10 ? 0 : 1)}%
</span>
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
<span className="block h-full rounded-full bg-[#2f8dff]" style={{ width: `${Math.min(100, module.cpu)}%` }} />
</span>
</>
)}
</span>
<span className="flex items-center justify-center gap-1.5">
{module.memory === null ? (
<span className={dark ? 'text-white/28' : 'text-[#a0a7b4]'}>-</span>
) : (
<>
<span className={['w-10 text-right font-mono text-[10px]', dark ? 'text-white/58' : 'text-[#687284]'].join(' ')}>
{module.memory.toFixed(module.memory >= 10 ? 0 : 1)}%
</span>
<span className={['h-1.5 w-[42px] overflow-hidden rounded-full', dark ? 'bg-white/10' : 'bg-[#e2e7f0]'].join(' ')}>
<span className="block h-full rounded-full bg-[#2fbda0]" style={{ width: `${Math.min(100, module.memory)}%` }} />
</span>
</>
)}
</span>
<span className={['text-center', module.runtime === '-' ? (dark ? 'text-white/28' : 'text-[#a0a7b4]') : (dark ? 'font-mono text-white/58' : 'font-mono text-[#687284]')].join(' ')}>
{module.runtime}
</span>
@@ -215,7 +191,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
{controlActions.map(({ label, Icon, tone, action }) => (
(() => {
const canCancelBuild = module.state === 'stopped' && controlling && label === '중지'
const disabled = busy && !canCancelBuild
const disabled = bulkControlling || (busy && !canCancelBuild)
return (
<button
key={`${module.name}-${label}`}
@@ -238,7 +214,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
<span className="flex justify-center">
<button
type="button"
disabled={busy}
disabled={installActionDisabled}
onClick={(event) => {
event.stopPropagation()
if (module.state === 'uninstalled') {
@@ -248,7 +224,7 @@ export default function ModuleListRow<Module extends ModuleListRowItem>({
onDeleteModule(module)
}}
aria-label={installAction.label}
className={actionButtonClass(dark, installAction.tone, busy)}
className={actionButtonClass(dark, installAction.tone, installActionDisabled)}
>
{busy ? <RefreshCw size={14} className="animate-spin" /> : <installAction.Icon size={14} />}
<TooltipLabel dark={dark}>{installAction.label}</TooltipLabel>
@@ -15,6 +15,7 @@ type ModuleListToolbarProps = {
onInstallAll: () => void
onStartAll: () => void
onStopAll: () => void
onOpenWorldEditor: () => void
}
const FILTER_TABS: Array<{ id: ModuleFilter; label: string }> = [
@@ -35,14 +36,15 @@ export default function ModuleListToolbar({
onFilterChange,
onInstallAll,
onStartAll,
onStopAll
onStopAll,
onOpenWorldEditor
}: ModuleListToolbarProps): React.JSX.Element {
const installingAll = installingModuleName === '__all__'
const startingAll = installingModuleName === '__start_all__'
const stoppingAll = installingModuleName === '__stop_all__'
const bulkActions = [
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: undefined },
{ label: '맵설정', Icon: MapPinned, busy: false, disabled: false, onClick: onOpenWorldEditor },
{
label: '전체 설치',
Icon: Download,
@@ -17,6 +17,7 @@ type ModuleListViewProps = {
controllingModuleNames: string[]
listError: string | null
installError: string | null
allowRepositoryDeletion?: boolean
onAddModuleTab: (module: ModuleRow) => void
onOpenSource: (module: ModuleRow) => void
onInstallModule: (module: ModuleRow) => void
@@ -29,6 +30,7 @@ type ModuleListViewProps = {
onInstallAll: () => void
onStartAll: () => void
onStopAll: () => void
onOpenWorldEditor: () => void
}
export default function ModuleListView({
@@ -39,6 +41,7 @@ export default function ModuleListView({
controllingModuleNames,
listError,
installError,
allowRepositoryDeletion = true,
onAddModuleTab,
onOpenSource,
onInstallModule,
@@ -50,7 +53,8 @@ export default function ModuleListView({
onRestartModule,
onInstallAll,
onStartAll,
onStopAll
onStopAll,
onOpenWorldEditor
}: ModuleListViewProps): React.JSX.Element {
const [moduleFilter, setModuleFilter] = useState<ModuleFilter>('all')
const [deleteConfirmModule, setDeleteConfirmModule] = useState<ModuleRow | null>(null)
@@ -60,6 +64,7 @@ export default function ModuleListView({
const runningCount = modules.filter((row) => row.state === 'running').length
const stoppedCount = modules.filter((row) => row.state === 'stopped').length
const installingAll = installingModuleName === '__all__'
const bulkControlling = installingModuleName === '__start_all__' || installingModuleName === '__stop_all__'
useEffect(() => {
const handleEscape = (event: KeyboardEvent): void => {
@@ -102,6 +107,7 @@ export default function ModuleListView({
onInstallAll={onInstallAll}
onStartAll={onStartAll}
onStopAll={onStopAll}
onOpenWorldEditor={onOpenWorldEditor}
/>
<ModuleListHeader dark={dark} />
{(listError || installError) && (
@@ -122,6 +128,7 @@ export default function ModuleListView({
module={row}
installing={installingModuleName === row.name || (installingAll && row.state === 'uninstalled')}
controlling={controllingModuleNames.includes(row.name)}
bulkControlling={bulkControlling}
onAddModuleTab={onAddModuleTab}
onOpenSource={onOpenSource}
onOpenParameters={(module) => {
@@ -148,6 +155,7 @@ export default function ModuleListView({
<ModuleDeleteConfirm
dark={dark}
module={deleteConfirmModule}
allowRepositoryDeletion={allowRepositoryDeletion}
onCancel={() => setDeleteConfirmModule(null)}
onConfirm={(module, options) => {
onDeleteModule(module, options)
@@ -196,7 +196,7 @@ export default function WorkspaceFilePanel({
}: WorkspaceFilePanelProps): React.JSX.Element {
return (
<aside
className={['min-h-0 border-r transition-opacity', searchActive ? 'flex flex-col' : '', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
className={['flex min-h-0 flex-col border-r transition-opacity', workspaceActive ? '' : 'pointer-events-none overflow-hidden border-r-0 opacity-0', dark ? 'border-white/10 bg-[#242424]' : 'border-[#cfd6e4] bg-[#edf1f7]'].join(' ')}
style={workspaceActive ? { gridColumn: 4, gridRow: 2 } : undefined}
>
<div className={['flex h-11 items-center justify-between border-b px-3', borderSoft].join(' ')}>
@@ -396,7 +396,7 @@ function ExplorerPanel(props: Omit<WorkspaceFilePanelProps,
} = props
return (
<div className={['px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<div className={['thin-y-scrollbar min-h-0 flex-1 overflow-y-auto px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
<button
type="button"
onClick={onSelectRoot}
@@ -16,6 +16,7 @@ type WorkspaceTopBarProps = {
previewOpen: boolean
showControlCountdown: boolean
controlCountdownDanger: boolean
canExtendControl: boolean
controlEndsAt: string | null
submitVisible: boolean
submitDisabled: boolean
@@ -28,6 +29,7 @@ type WorkspaceTopBarProps = {
onTogglePreview: () => void
onSubmit: () => void
onOpenPowerActions: () => void
onRequestControlExtension: () => void
onClose: () => void
}
@@ -45,6 +47,7 @@ export default function WorkspaceTopBar({
previewOpen,
showControlCountdown,
controlCountdownDanger,
canExtendControl,
controlEndsAt,
submitVisible,
submitDisabled,
@@ -57,6 +60,7 @@ export default function WorkspaceTopBar({
onTogglePreview,
onSubmit,
onOpenPowerActions,
onRequestControlExtension,
onClose
}: WorkspaceTopBarProps): React.JSX.Element {
return (
@@ -101,6 +105,15 @@ export default function WorkspaceTopBar({
<Clock3 size={13} />
{formatControlRemainingTime(controlEndsAt)}
</span>
{controlCountdownDanger && canExtendControl && (
<button
type="button"
onClick={onRequestControlExtension}
className="h-6 cursor-pointer rounded border border-[#ef4444]/45 px-2 text-[11px] font-bold text-[#ff8b8b] transition hover:bg-[#ef4444]/12"
>
연장
</button>
)}
</>
)}
<span className={['ml-1 mr-1 h-4 w-px', dark ? 'bg-white/16' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -2,6 +2,7 @@ import { Maximize2, Minimize2, PanelLeft, X } from 'lucide-react'
import type React from 'react'
export type VisualizationStack = 'autoware' | 'nav2' | 'slam'
export type Nav2VisualizationMode = 'slam' | 'navigation'
type WorkspaceVisualizationPanelProps = {
dark: boolean
@@ -16,11 +17,13 @@ type WorkspaceVisualizationPanelProps = {
running: boolean
busy: boolean
viewerUrl: string
nav2Mode: Nav2VisualizationMode
onDragStart: (event: React.MouseEvent<HTMLDivElement>) => void
onToggleLarge: () => void
onClose: () => void
onStackChange: (stack: VisualizationStack) => void
onStart: () => void
onStartNav2: (mode: Nav2VisualizationMode) => void
onStop: () => void
onResizeStart: (event: React.MouseEvent<HTMLDivElement>, direction: 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw') => void
}
@@ -38,14 +41,17 @@ export default function WorkspaceVisualizationPanel({
running,
busy,
viewerUrl,
nav2Mode,
onDragStart,
onToggleLarge,
onClose,
onStackChange,
onStart,
onStartNav2,
onStop,
onResizeStart
}: WorkspaceVisualizationPanelProps): React.JSX.Element {
const isNav2Visualization = activeStack === 'nav2' || activeStack === 'slam'
return (
<section
className={[
@@ -127,21 +133,58 @@ export default function WorkspaceVisualizationPanel({
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => {
if (running) onStop()
else onStart()
}}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
running ? 'bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]' : 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : running ? '종료' : '시작'}
</button>
{running ? (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStop}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#7f1d1d] text-[#fecaca] hover:bg-[#991b1b]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '종료'}
</button>
) : isNav2Visualization ? (
<>
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => onStartNav2('slam')}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
nav2Mode === 'slam' ? 'bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy && nav2Mode === 'slam' ? '처리 중' : 'SLAM 시작'}
</button>
<button
type="button"
disabled={busy || !stacks.length}
onClick={() => onStartNav2('navigation')}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition',
nav2Mode === 'navigation' ? 'bg-[#2563eb] text-white hover:bg-[#3b82f6]' : 'bg-white/8 text-white/66 hover:bg-white/12 hover:text-white',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy && nav2Mode === 'navigation' ? '처리 중' : '주행 시작'}
</button>
</>
) : (
<button
type="button"
disabled={busy || !stacks.length}
onClick={onStart}
className={[
'h-7 cursor-pointer rounded-md px-3 text-[11px] font-black transition bg-[#14532d] text-[#bbf7d0] hover:bg-[#166534]',
busy || !stacks.length ? 'cursor-default opacity-60' : ''
].join(' ')}
>
{busy ? '처리 중' : '시작'}
</button>
)}
</div>
</div>
<div className="relative min-h-0 flex-1">
@@ -0,0 +1,509 @@
import { Box, Cylinder, LoaderCircle, Maximize2, Move3D, PanelsTopLeft, Rotate3D, RotateCcw, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import type React from 'react'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { ColladaLoader } from 'three/examples/jsm/loaders/ColladaLoader.js'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader.js'
type WorldObjectType = WorldLayoutObject['type']
type WorldEditorModalProps = {
workspaceRoot: string
onClose: () => void
onSaved?: () => void
}
type TransformMode = 'translate' | 'rotate' | 'scale'
const objectLabels: Record<WorldObjectType, string> = {
pillar: '기둥',
wall: '벽',
box: '박스'
}
const defaultsByType: Record<WorldObjectType, Omit<WorldLayoutObject, 'id' | 'type'>> = {
pillar: { position: [0, 0, 1], rotation: [0, 0, 0], size: [0.5, 0.5, 2] },
wall: { position: [0, 0, 1], rotation: [0, 0, 0], size: [4, 0.2, 2] },
box: { position: [0, 0, 0.5], rotation: [0, 0, 0], size: [1, 1, 1] }
}
function createObject(type: WorldObjectType, index: number): WorldLayoutObject {
const template = defaultsByType[type]
return {
id: `${type}-${Date.now()}-${index}`,
type,
position: [template.position[0] + index * 0.7, template.position[1] + index * 0.45, template.position[2]],
rotation: [...template.rotation],
size: [...template.size]
}
}
function objectMaterial(type: WorldObjectType): THREE.MeshStandardMaterial {
const color = type === 'pillar' ? '#fb923c' : type === 'wall' ? '#60a5fa' : '#a78bfa'
return new THREE.MeshStandardMaterial({ color, roughness: 0.68, metalness: 0.05 })
}
function createMesh(item: WorldLayoutObject): THREE.Mesh {
const [width, depth, height] = item.size
const geometry = item.type === 'pillar'
? new THREE.CylinderGeometry(Math.max(width, depth) / 2, Math.max(width, depth) / 2, height, 32)
: new THREE.BoxGeometry(width, depth, height)
const mesh = new THREE.Mesh(geometry, objectMaterial(item.type))
mesh.position.set(item.position[0], item.position[1], item.position[2])
mesh.rotation.set(item.rotation[0], item.rotation[1], item.rotation[2])
if (item.type === 'pillar') mesh.rotateX(Math.PI / 2)
mesh.castShadow = true
mesh.receiveShadow = true
mesh.userData.objectId = item.id
return mesh
}
const modelRoot = '/slam-viewer/turtlebot3_world_model/turtlebot3_world'
const modelRoots: Record<string, string> = {
turtlebot3_world: modelRoot,
turtlebot3_house: '/slam-viewer/turtlebot3_world_model/turtlebot3_house',
turtlebot3_common: '/slam-viewer/turtlebot3_world_model/turtlebot3_common'
}
function parseNumbers(value: string | null | undefined, count = 0): number[] {
const numbers = (value ?? '').trim().split(/\s+/).filter(Boolean).map(Number).filter(Number.isFinite)
while (numbers.length < count) numbers.push(0)
return numbers
}
function directChild(element: Element | null | undefined, name: string): Element | null {
return Array.from(element?.children ?? []).find((child) => child.tagName === name) ?? null
}
function childText(element: Element | null | undefined, name: string, fallback = ''): string {
return directChild(element, name)?.textContent?.trim() || fallback
}
function applySdfPose(target: THREE.Object3D, value: string): void {
const [x, y, z, roll, pitch, yaw] = parseNumbers(value, 6)
target.position.set(x, y, z)
target.rotation.set(roll, pitch, yaw, 'XYZ')
}
function worldMaterial(visual: Element): THREE.MeshLambertMaterial {
const text = `${visual.getAttribute('name') ?? ''} ${Array.from(visual.querySelectorAll('uri, script name')).map((item) => item.textContent ?? '').join(' ')}`.toLowerCase()
const ambient = visual.querySelector('ambient')?.textContent
const [red, green, blue] = parseNumbers(ambient, 3)
const color = ambient ? new THREE.Color(red, green, blue) : text.includes('wall') ? '#171717' : text.includes('green') ? '#16a34a' : '#e5e7eb'
// TurtleBot3 기본 월드는 외곽 벽 메시의 안쪽에서 보게 된다.
// 내부 면도 표시해야 다각형 전체 벽 구조가 보인다.
return new THREE.MeshLambertMaterial({ color, side: THREE.DoubleSide })
}
function gazeboAssetPath(uri: string): string {
return uri.replace(/^model:\/\/(\w+)\//, (_match, name: string) => `${modelRoots[name] ?? `/slam-viewer/turtlebot3_world_model/${name}`}/`)
}
async function loadWorldSdf(parent: THREE.Group): Promise<void> {
const response = await fetch(`${modelRoot}/model.sdf`)
if (!response.ok) throw new Error(`기본 월드 모델을 불러오지 못했습니다. (${response.status})`)
const documentNode = new DOMParser().parseFromString(await response.text(), 'application/xml')
const model = documentNode.querySelector('model')
if (!model) throw new Error('기본 월드 모델 형식이 올바르지 않습니다.')
const colladaLoader = new ColladaLoader()
const stlLoader = new STLLoader()
const modelGroup = new THREE.Group()
applySdfPose(modelGroup, childText(model, 'pose', '0 0 0 0 0 0'))
parent.add(modelGroup)
const loadMesh = async (target: THREE.Group, visual: Element, geometry: Element): Promise<void> => {
const meshNode = directChild(geometry, 'mesh')
const uri = childText(meshNode, 'uri')
if (!uri) return
const scale = parseNumbers(childText(meshNode, 'scale', '1 1 1'), 3)
const url = gazeboAssetPath(uri)
const material = worldMaterial(visual)
if (url.endsWith('.stl')) {
const geometryValue = await stlLoader.loadAsync(url)
geometryValue.computeVertexNormals()
const mesh = new THREE.Mesh(geometryValue, material)
mesh.scale.set(scale[0], scale[1], scale[2])
mesh.castShadow = true
mesh.receiveShadow = true
target.add(mesh)
return
}
const collada = await colladaLoader.loadAsync(url)
collada.scene.scale.set(scale[0], scale[1], scale[2])
collada.scene.traverse((child) => {
if (!(child instanceof THREE.Mesh)) return
child.material = material.clone()
child.castShadow = true
child.receiveShadow = true
})
target.add(collada.scene)
}
for (const link of Array.from(model.querySelectorAll(':scope > link'))) {
const linkGroup = new THREE.Group()
applySdfPose(linkGroup, childText(link, 'pose', '0 0 0 0 0 0'))
modelGroup.add(linkGroup)
for (const visual of Array.from(link.querySelectorAll(':scope > visual'))) {
const visualGroup = new THREE.Group()
applySdfPose(visualGroup, childText(visual, 'pose', '0 0 0 0 0 0'))
linkGroup.add(visualGroup)
const geometry = directChild(visual, 'geometry')
if (!geometry) continue
const material = worldMaterial(visual)
if (directChild(geometry, 'mesh')) {
await loadMesh(visualGroup, visual, geometry).catch(() => undefined)
continue
}
const box = directChild(geometry, 'box')
const cylinder = directChild(geometry, 'cylinder')
if (box) {
const [width, depth, height] = parseNumbers(childText(box, 'size', '1 1 1'), 3)
visualGroup.add(new THREE.Mesh(new THREE.BoxGeometry(width, depth, height), material))
} else if (cylinder) {
const radius = Number(childText(cylinder, 'radius', '0.1')) || 0.1
const height = Number(childText(cylinder, 'length', '1')) || 1
const mesh = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, height, 32), material)
mesh.rotateX(Math.PI / 2)
visualGroup.add(mesh)
}
}
}
}
export default function WorldEditorModal({ workspaceRoot, onClose, onSaved }: WorldEditorModalProps): React.JSX.Element {
const canvasHostRef = useRef<HTMLDivElement>(null)
const viewerRef = useRef<HTMLIFrameElement>(null)
const worldRef = useRef<WorldLayout>({ version: 1, objects: [] })
const selectedIdRef = useRef<string | null>(null)
const renderSceneRef = useRef<(() => void) | null>(null)
const [layout, setLayout] = useState<WorldLayout>({ version: 1, objects: [] })
const [selectedId, setSelectedId] = useState<string | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [transformMode, setTransformMode] = useState<TransformMode>('translate')
useEffect(() => {
let active = true
void window.localWorkspace.worldLayout.read(workspaceRoot)
.then((nextLayout) => {
if (!active) return
worldRef.current = nextLayout
setLayout(nextLayout)
setSelectedId(nextLayout.objects[0]?.id ?? null)
})
.catch((cause: unknown) => {
if (!active) return
setError(cause instanceof Error ? cause.message : '월드 정보를 불러오지 못했습니다.')
})
.finally(() => active && setLoading(false))
return () => { active = false }
}, [workspaceRoot])
useEffect(() => {
worldRef.current = layout
}, [layout])
useEffect(() => {
selectedIdRef.current = selectedId
renderSceneRef.current?.()
sendLayoutToViewer(worldRef.current)
}, [selectedId])
const sendLayoutToViewer = (nextLayout: WorldLayout): void => {
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-layout',
layout: nextLayout,
selectedId: selectedIdRef.current,
}, '*')
}
useEffect(() => {
sendLayoutToViewer(layout)
}, [layout])
useEffect(() => {
const handleMessage = (event: MessageEvent): void => {
if (event.data?.source !== 'autonomy-world-editor') return
if (event.data.type === 'ready') {
sendLayoutToViewer(worldRef.current)
return
}
if (event.data.type === 'select-object') setSelectedId(typeof event.data.id === 'string' ? event.data.id : null)
if (event.data.type === 'transform-object' && typeof event.data.id === 'string' && event.data.patch) {
const patch = event.data.patch as Partial<WorldLayoutObject>
setLayout((current) => {
const next = {
...current,
objects: current.objects.map((item) => item.id === event.data.id ? { ...item, ...patch } : item),
}
worldRef.current = next
return next
})
}
}
window.addEventListener('message', handleMessage)
return () => window.removeEventListener('message', handleMessage)
}, [])
useEffect(() => {
const host = canvasHostRef.current
// The editor iframe owns the actual TurtleBot3 world rendering. Keep the
// previous canvas path dormant while the shared viewer is mounted.
if (!host || loading || viewerRef.current) return
const scene = new THREE.Scene()
scene.background = new THREE.Color('#0b1220')
const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 100)
camera.position.set(4, 6, 5)
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
host.replaceChildren(renderer.domElement)
const controls = new OrbitControls(camera, renderer.domElement)
controls.target.set(0, 0, 0)
controls.enableDamping = true
controls.maxPolarAngle = Math.PI / 2.08
controls.update()
scene.add(new THREE.HemisphereLight('#dbeafe', '#0b1220', 1.7))
const keyLight = new THREE.DirectionalLight('#ffffff', 1.8)
keyLight.position.set(6, -5, 11)
keyLight.castShadow = true
scene.add(keyLight)
const ground = new THREE.Mesh(
new THREE.PlaneGeometry(20, 20),
new THREE.MeshStandardMaterial({ color: '#17233a', roughness: 0.95, metalness: 0 })
)
ground.rotation.x = -Math.PI / 2
ground.receiveShadow = true
scene.add(ground)
scene.add(new THREE.GridHelper(20, 20, '#3b82f6', '#24324c'))
// Gazebo SDF uses Z-up, while Three.js uses Y-up.
const sdfRoot = new THREE.Group()
sdfRoot.rotation.x = -Math.PI / 2
scene.add(sdfRoot)
const objectGroup = new THREE.Group()
const selectionGroup = new THREE.Group()
sdfRoot.add(objectGroup)
scene.add(selectionGroup)
const raycaster = new THREE.Raycaster()
const pointer = new THREE.Vector2()
const renderScene = (): void => {
objectGroup.clear()
selectionGroup.clear()
for (const item of worldRef.current.objects) {
const mesh = createMesh(item)
objectGroup.add(mesh)
if (item.id === selectedIdRef.current) {
const outline = new THREE.Box3().setFromObject(mesh)
const size = outline.getSize(new THREE.Vector3())
const center = outline.getCenter(new THREE.Vector3())
const helper = new THREE.Box3Helper(new THREE.Box3().setFromCenterAndSize(center, size.multiplyScalar(1.08)), '#93c5fd')
selectionGroup.add(helper)
}
}
}
renderSceneRef.current = renderScene
renderScene()
let disposed = false
void loadWorldSdf(sdfRoot).catch((cause: unknown) => {
if (!disposed) setError(cause instanceof Error ? cause.message : '기본 TurtleBot3 월드 모델을 불러오지 못했습니다.')
})
const resize = (): void => {
const { width, height } = host.getBoundingClientRect()
if (!width || !height) return
camera.aspect = width / height
camera.updateProjectionMatrix()
renderer.setSize(width, height, false)
}
const observer = new ResizeObserver(resize)
observer.observe(host)
resize()
const handlePointerDown = (event: PointerEvent): void => {
const rect = renderer.domElement.getBoundingClientRect()
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
raycaster.setFromCamera(pointer, camera)
const hit = raycaster.intersectObjects(objectGroup.children, false)[0]
setSelectedId(typeof hit?.object.userData.objectId === 'string' ? hit.object.userData.objectId : null)
}
renderer.domElement.addEventListener('pointerdown', handlePointerDown)
let frameId = 0
const animate = (): void => {
controls.update()
renderer.render(scene, camera)
frameId = window.requestAnimationFrame(animate)
}
animate()
return () => {
disposed = true
renderSceneRef.current = null
window.cancelAnimationFrame(frameId)
observer.disconnect()
renderer.domElement.removeEventListener('pointerdown', handlePointerDown)
controls.dispose()
renderer.dispose()
host.replaceChildren()
}
}, [loading])
const selectTransformMode = (mode: TransformMode): void => {
setTransformMode(mode)
viewerRef.current?.contentWindow?.postMessage({
source: 'autonomy-world-editor',
type: 'set-transform-mode',
mode,
}, '*')
}
const selected = layout.objects.find((item) => item.id === selectedId) ?? null
const updateObject = (id: string, patch: Partial<WorldLayoutObject>): void => {
setLayout((current) => {
const next = { ...current, objects: current.objects.map((item) => item.id === id ? { ...item, ...patch } : item) }
worldRef.current = next
return next
})
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const updateTuple = (key: 'position' | 'rotation' | 'size', index: number, value: string): void => {
if (!selected) return
const numeric = Number(value)
if (!Number.isFinite(numeric)) return
const next = [...selected[key]] as [number, number, number]
next[index] = key === 'size' ? Math.max(0.1, numeric) : numeric
updateObject(selected.id, { [key]: next })
}
const addObject = (type: WorldObjectType): void => {
const object = createObject(type, layout.objects.length)
setLayout((current) => {
const next = { ...current, objects: [...current.objects, object] }
worldRef.current = next
return next
})
setSelectedId(object.id)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const removeSelected = (): void => {
if (!selected) return
const remaining = layout.objects.filter((item) => item.id !== selected.id)
const next = { ...layout, objects: remaining }
worldRef.current = next
setLayout(next)
setSelectedId(remaining[0]?.id ?? null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const resetLayout = (): void => {
const next: WorldLayout = { version: 1, objects: [] }
worldRef.current = next
setLayout(next)
setSelectedId(null)
window.requestAnimationFrame(() => renderSceneRef.current?.())
}
const save = async (): Promise<void> => {
setSaving(true)
setError(null)
try {
const saved = await window.localWorkspace.worldLayout.write(workspaceRoot, layout)
setLayout(saved)
onSaved?.()
onClose()
} catch (cause) {
setError(cause instanceof Error ? cause.message : '월드 정보를 저장하지 못했습니다.')
} finally {
setSaving(false)
}
}
return (
<div className="absolute inset-0 z-[70] flex items-center justify-center bg-[#050914]/78 p-6 backdrop-blur-sm">
<section className="flex h-[min(820px,calc(100vh-64px))] w-[min(1280px,calc(100vw-64px))] flex-col overflow-hidden rounded-lg border border-white/12 bg-[#111a2a] shadow-[0_24px_72px_rgba(0,0,0,0.5)]">
<header className="flex h-12 shrink-0 items-center justify-between border-b border-white/10 bg-[#162238] px-4">
<div>
<h2 className="text-[13px] font-bold text-white">맵 설정</h2>
<p className="mt-0.5 text-[10px] font-medium text-white/42">주차 워크스페이스에 월드 배치를 저장합니다.</p>
</div>
<button type="button" onClick={onClose} className="grid size-7 cursor-pointer place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white" aria-label="닫기"><X size={16} /></button>
</header>
<div className="flex min-h-0 flex-1">
<aside className="flex w-[214px] shrink-0 flex-col border-r border-white/10 bg-[#101a2a]">
<div className="border-b border-white/10 px-3 py-3">
<p className="text-[10px] font-bold tracking-[0.1em] text-white/38">SCENE</p>
<p className="mt-1 text-[11px] font-semibold text-white/66">월드 오브젝트</p>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-2">
{layout.objects.length ? layout.objects.map((item) => {
const Icon = item.type === 'pillar' ? Cylinder : item.type === 'wall' ? PanelsTopLeft : Box
const selected = item.id === selectedId
return (
<button key={item.id} type="button" onClick={() => setSelectedId(item.id)} className={`flex h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left transition ${selected ? 'bg-[#3b7ffa]/20 text-[#d9e7ff]' : 'text-white/60 hover:bg-white/[0.06] hover:text-white'}`}>
<Icon size={13} className={selected ? 'text-[#75a4ff]' : 'text-white/38'} />
<span className="min-w-0 flex-1 truncate text-[11px] font-semibold">{objectLabels[item.type]}</span>
<span className="text-[10px] text-white/30">{item.id.split('-').at(-1)}</span>
</button>
)
}) : <div className="px-3 py-8 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 추가해<br />맵을 구성하세요.</div>}
</div>
<div className="border-t border-white/10 p-2">
<p className="mb-2 px-1 text-[10px] font-bold tracking-[0.08em] text-white/35">ADD OBJECT</p>
<div className="grid grid-cols-3 gap-1">
{([
['pillar', Cylinder, '기둥'],
['wall', PanelsTopLeft, '벽'],
['box', Box, '박스'],
] as const).map(([type, Icon, label]) => (
<button key={type} type="button" onClick={() => addObject(type)} title={`${label} 추가`} className="flex h-12 cursor-pointer flex-col items-center justify-center gap-1 rounded-md border border-white/10 text-[10px] font-semibold text-white/60 transition hover:border-[#60a5fa]/55 hover:bg-[#1a2c49] hover:text-white"><Icon size={14} />{label}</button>
))}
</div>
</div>
</aside>
<main className="relative min-w-0 flex-1 bg-[#0b1220]">
<div ref={canvasHostRef} className="pointer-events-none absolute inset-0 opacity-0" />
<iframe ref={viewerRef} title="TurtleBot3 World Editor" src="/slam-viewer/index.html?editor=1&mapKey=nav2-custom" className="absolute inset-0 z-10 size-full border-0" />
{loading && <div className="absolute inset-0 z-20 grid place-items-center bg-[#0b1220] text-[12px] font-semibold text-white/58"><span className="flex items-center gap-2"><LoaderCircle size={16} className="animate-spin" />월드 불러오는 중</span></div>}
<div className="absolute left-3 top-3 z-20 flex overflow-hidden rounded-md border border-white/15 bg-[#15223a]/95 shadow">
{([
['translate', Move3D, '이동'],
['rotate', Rotate3D, '회전'],
['scale', Maximize2, '크기'],
] as const).map(([mode, Icon, label]) => (
<button key={mode} type="button" onClick={() => selectTransformMode(mode)} title={label} aria-label={label} className={`flex h-8 cursor-pointer items-center gap-1.5 px-2.5 text-[11px] font-bold transition ${transformMode === mode ? 'bg-[#3b7ffa] text-white' : 'text-white/60 hover:bg-white/10 hover:text-white'}`}><Icon size={14} />{label}</button>
))}
</div>
</main>
<aside className="flex w-[270px] shrink-0 flex-col border-l border-white/10 bg-[#131e31]">
<div className="border-b border-white/10 px-3 py-3"><span className="text-[11px] font-bold text-white/70">선택한 오브젝트</span></div>
{selected ? (
<div className="min-h-0 flex-1 overflow-y-auto p-3">
<div className="mb-4 flex items-center justify-between"><span className="rounded bg-[#3b7ffa]/18 px-2 py-1 text-[11px] font-bold text-[#a8c7ff]">{objectLabels[selected.type]}</span><button type="button" onClick={removeSelected} className="grid size-7 cursor-pointer place-items-center rounded border border-red-400/25 text-red-300/80 transition hover:bg-red-500/12 hover:text-red-200" title="삭제"><Trash2 size={14} /></button></div>
{(['position', 'rotation', 'size'] as const).map((key) => {
const indexes = key === 'position' ? [0, 1] : key === 'rotation' ? [2] : [0, 1, 2]
return <div key={key} className="mb-4"><p className="mb-1.5 text-[10px] font-bold tracking-[0.08em] text-white/38">{key === 'position' ? '위치' : key === 'rotation' ? '회전' : '크기'}</p><div className={`grid gap-1.5 ${indexes.length === 1 ? 'grid-cols-1' : indexes.length === 2 ? 'grid-cols-2' : 'grid-cols-3'}`}>{indexes.map((index) => <label key={index} className="min-w-0"><span className="mb-1 block text-[10px] font-bold text-white/38">{key === 'rotation' ? '각도 (Z)' : ['X', 'Y', '높이'][index]}</span><input type="number" step="0.1" value={Number(selected[key][index].toFixed(2))} onChange={(event) => updateTuple(key, index, event.target.value)} className="h-8 w-full rounded border border-white/12 bg-[#0d1728] px-2 text-[11px] font-medium text-white outline-none transition focus:border-[#60a5fa]" /></label>)}</div></div>
})}
</div>
) : <div className="grid min-h-0 flex-1 place-items-center px-5 text-center text-[11px] leading-relaxed text-white/35">오브젝트를 선택하거나 추가하세요.</div>}
<div className="border-t border-white/10 p-3"><button type="button" onClick={resetLayout} className="flex h-8 w-full cursor-pointer items-center justify-center gap-1.5 rounded border border-white/12 text-[11px] font-semibold text-white/54 transition hover:bg-white/8 hover:text-white"><RotateCcw size={13} />모두 비우기</button></div>
</aside>
</div>
<footer className="flex h-12 shrink-0 items-center justify-between border-t border-white/10 bg-[#162238] px-4">
<span className="text-[10px] font-medium text-white/38">저장 위치: world-layout.json</span>
<div className="flex items-center gap-2"><button type="button" onClick={onClose} className="h-8 cursor-pointer rounded-md px-3 text-[11px] font-semibold text-white/58 transition hover:bg-white/8 hover:text-white">취소</button><button type="button" disabled={saving || loading} onClick={() => void save()} className="flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#3b7ffa] px-3 text-[11px] font-bold text-white transition hover:bg-[#4a89ff] disabled:cursor-default disabled:opacity-55">{saving ? <LoaderCircle size={14} className="animate-spin" /> : <Save size={14} />}저장</button></div>
</footer>
{error && <div className="absolute bottom-14 left-4 rounded border border-red-400/25 bg-[#3a1f29] px-3 py-2 text-[11px] font-semibold text-red-200">{error}</div>}
</section>
</div>
)
}
@@ -0,0 +1,274 @@
import { AlertTriangle, Check, House, LoaderCircle, Map, PencilRuler, Save, Trash2, X } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
export type WorkspaceMapOption = {
key: string
platformKey: 'NAV2'
name: string
description: string
worldFile: string
viewerModel: 'turtlebot3_world' | 'turtlebot3_house'
custom?: boolean
saved?: boolean
savedMapId?: string
createdAt?: string
sourceMapKey?: string
yamlPath?: string
pgmPath?: string
previewPgm?: string
layout?: unknown
}
export const NAV2_MAP_OPTIONS: WorkspaceMapOption[] = [
{
key: 'nav2-turtlebot3-world',
platformKey: 'NAV2',
name: '기본 월드',
description: 'TurtleBot3 기본 장애물 환경',
worldFile: 'turtlebot3_world.world',
viewerModel: 'turtlebot3_world'
},
{
key: 'nav2-turtlebot3-house',
platformKey: 'NAV2',
name: '실내 환경',
description: '방과 복도로 구성된 TurtleBot3 House',
worldFile: 'turtlebot3_house.world',
viewerModel: 'turtlebot3_house'
},
{
key: 'nav2-custom',
platformKey: 'NAV2',
name: '커스텀 맵',
description: '장애물을 직접 배치해 학습 환경 구성',
worldFile: 'empty_world.world',
viewerModel: 'turtlebot3_world',
custom: true
}
]
const LOCAL_CUSTOM_MAP = NAV2_MAP_OPTIONS.find((map) => map.key === 'nav2-custom')!
function MapPreview({ map, selected }: { map: WorkspaceMapOption; selected: boolean }): React.JSX.Element {
const Icon = map.saved ? Save : map.custom ? PencilRuler : map.viewerModel === 'turtlebot3_house' ? House : Map
const canvasRef = useRef<HTMLCanvasElement>(null)
useEffect(() => {
if (!map.previewPgm || !canvasRef.current) return
const canvas = canvasRef.current
const context = canvas.getContext('2d')
if (!context) return
try {
const bytes = Uint8Array.from(atob(map.previewPgm), (char) => char.charCodeAt(0))
let cursor = 0
const nextToken = (): string => {
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (bytes[cursor] === 35) {
while (cursor < bytes.length && bytes[cursor] !== 10) cursor += 1
return nextToken()
}
const start = cursor
while (cursor < bytes.length && !/\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
return String.fromCharCode(...bytes.slice(start, cursor))
}
if (nextToken() !== 'P5') throw new Error('unsupported_pgm')
const width = Number(nextToken())
const height = Number(nextToken())
const maxValue = Number(nextToken())
while (cursor < bytes.length && /\s/.test(String.fromCharCode(bytes[cursor]))) cursor += 1
if (!Number.isFinite(width) || !Number.isFinite(height) || maxValue !== 255 || bytes.length - cursor < width * height) throw new Error('invalid_pgm')
const targetWidth = 192
const targetHeight = 128
canvas.width = targetWidth
canvas.height = targetHeight
context.fillStyle = '#d9dce0'
context.fillRect(0, 0, targetWidth, targetHeight)
const scale = Math.min(targetWidth / width, targetHeight / height)
const renderedWidth = Math.max(1, Math.floor(width * scale))
const renderedHeight = Math.max(1, Math.floor(height * scale))
const offsetX = Math.floor((targetWidth - renderedWidth) / 2)
const offsetY = Math.floor((targetHeight - renderedHeight) / 2)
const image = context.createImageData(renderedWidth, renderedHeight)
for (let y = 0; y < renderedHeight; y += 1) {
const sourceY = Math.min(height - 1, Math.floor((y / renderedHeight) * height))
for (let x = 0; x < renderedWidth; x += 1) {
const sourceX = Math.min(width - 1, Math.floor((x / renderedWidth) * width))
const value = bytes[cursor + sourceY * width + sourceX]
const index = (y * renderedWidth + x) * 4
image.data[index] = value
image.data[index + 1] = value
image.data[index + 2] = value
image.data[index + 3] = 255
}
}
context.putImageData(image, offsetX, offsetY)
} catch {
// Keep the fallback map schematic visible when a legacy preview is invalid.
}
}, [map.previewPgm])
return (
<span
aria-hidden="true"
className={[
'relative block h-16 w-24 shrink-0 overflow-hidden rounded border bg-[#d9dce0] shadow-inner',
selected ? 'border-[#6f9cff]' : 'border-white/15'
].join(' ')}
>
{map.previewPgm && <canvas ref={canvasRef} className="absolute inset-0 size-full" />}
{!map.previewPgm && <span className="absolute inset-0 bg-[linear-gradient(rgba(20,30,42,0.08)_1px,transparent_1px),linear-gradient(90deg,rgba(20,30,42,0.08)_1px,transparent_1px)] bg-[size:8px_8px]" />}
{!map.previewPgm && (map.custom ? (
<>
<span className="absolute left-[18%] top-[28%] h-[12%] w-[42%] bg-[#536273]" />
<span className="absolute bottom-[20%] right-[16%] h-[34%] w-[12%] bg-[#536273]" />
<span className="absolute bottom-[17%] left-[15%] size-[10%] rounded-full bg-[#536273]" />
</>
) : map.viewerModel === 'turtlebot3_house' ? (
<>
<span className="absolute inset-[12%] border-[3px] border-[#455363]" />
<span className="absolute left-[42%] top-[12%] h-[43%] w-[3px] bg-[#455363]" />
<span className="absolute bottom-[28%] left-[12%] h-[3px] w-[52%] bg-[#455363]" />
<span className="absolute bottom-[12%] right-[25%] h-[30%] w-[3px] bg-[#455363]" />
</>
) : (
<>
<span className="absolute inset-[11%] border-[3px] border-[#455363]" />
<span className="absolute left-[28%] top-[30%] size-[16%] rounded-full bg-[#455363]" />
<span className="absolute right-[22%] top-[22%] h-[16%] w-[24%] bg-[#455363]" />
<span className="absolute bottom-[22%] right-[35%] h-[14%] w-[14%] bg-[#455363]" />
</>
))}
{map.saved && <span className="absolute inset-x-0 bottom-0 bg-[#0f172a]/75 py-0.5 text-center text-[8px] font-extrabold tracking-[0.08em] text-white">SLAM</span>}
<span className={['absolute left-1.5 top-1.5 grid size-5 place-items-center rounded border border-white/35', selected ? 'bg-[#2563eb] text-white' : 'bg-[#111827]/75 text-white/85'].join(' ')}>
<Icon className="size-3" />
</span>
</span>
)
}
type WorldMapSelectorModalProps = {
platformKey: string
weekNumber: number
maps?: WorkspaceMapOption[]
selectedMapKey: string
onSelect: (map: WorkspaceMapOption) => void
onCreate: () => void
onDelete?: (map: WorkspaceMapOption) => void
onClose: () => void
}
export default function WorldMapSelectorModal({ platformKey, weekNumber, maps: configuredMaps, selectedMapKey, onSelect, onCreate, onDelete, onClose }: WorldMapSelectorModalProps): React.JSX.Element {
const maps = platformKey.toUpperCase() === 'NAV2'
? (configuredMaps?.length
? [...configuredMaps, ...(configuredMaps.some((map) => map.key === LOCAL_CUSTOM_MAP.key) ? [] : [LOCAL_CUSTOM_MAP])]
: NAV2_MAP_OPTIONS)
: []
const environmentMaps = maps.filter((map) => !map.saved)
const savedMaps = maps.filter((map) => map.saved)
const selectedMap = maps.find((map) => map.key === selectedMapKey) ?? null
const [tab, setTab] = useState<'environment' | 'saved'>(() => selectedMap?.saved ? 'saved' : 'environment')
const visibleMaps = tab === 'environment' ? environmentMaps : savedMaps
const [pendingMap, setPendingMap] = useState<WorkspaceMapOption | null>(() => selectedMap)
useEffect(() => {
setTab(selectedMap?.saved ? 'saved' : 'environment')
setPendingMap(selectedMap)
}, [selectedMapKey])
return (
<div className="fixed inset-0 z-[150] flex items-center justify-center bg-black/60 p-6" role="dialog" aria-modal="true" aria-label="맵 선택">
<section className="flex w-full max-w-[560px] flex-col overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center justify-between border-b border-white/10 px-6 py-4">
<div>
<h2 className="text-[16px] font-bold">맵 설정</h2>
<p className="mt-1 text-[12px] text-white/50">{weekNumber}주차에서 사용할 {platformKey.toUpperCase()} 학습 환경</p>
</div>
<button type="button" aria-label="닫기" title="닫기" onClick={onClose} className="grid size-8 place-items-center rounded-md text-white/55 transition hover:bg-white/10 hover:text-white">
<X className="size-4" />
</button>
</header>
<div className="flex gap-1 border-b border-white/10 px-5 pt-3">
<button type="button" onClick={() => setTab('environment')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'environment' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>학습 환경</button>
<button type="button" onClick={() => setTab('saved')} className={['h-8 cursor-pointer border-b-2 px-2 text-[12px] font-bold transition', tab === 'saved' ? 'border-[#3b7ffa] text-[#78a4ff]' : 'border-transparent text-white/45 hover:text-white/75'].join(' ')}>저장된 맵</button>
</div>
<div className="thin-y-scrollbar min-h-[270px] max-h-[270px] space-y-2 overflow-y-auto p-5">
{visibleMaps.map((map) => {
const pending = map.key === pendingMap?.key
const selected = map.key === selectedMapKey
return (
<button
key={map.key}
type="button"
onClick={() => setPendingMap(map)}
className={[
'group relative flex w-full cursor-pointer items-start gap-3 rounded-md border px-3 py-3 text-left transition',
pending ? 'border-[#3b7ffa] bg-[#3b7ffa]/12' : 'border-white/10 bg-[#111520] hover:border-white/25 hover:bg-white/[0.045]'
].join(' ')}
>
<MapPreview map={map} selected={pending} />
<span className="min-w-0 flex-1">
<span className="block text-[14px] font-bold">{map.name}</span>
<span className="mt-1 block text-[12px] leading-5 text-white/50">{map.description}</span>
</span>
{map.saved && map.createdAt && <span className="absolute bottom-3 right-4 text-[11px] font-medium text-white/35">{new Date(map.createdAt).toLocaleString('ko-KR', { dateStyle: 'short', timeStyle: 'short' })}</span>}
{selected && <Check className="absolute right-4 top-3 size-4 text-[#7eabff]" />}
{map.saved && map.key !== selectedMapKey && onDelete && (
<span role="button" tabIndex={0} title="삭제" aria-label={`${map.name} 삭제`} onClick={(event) => { event.stopPropagation(); onDelete(map) }} onKeyDown={(event) => { if (event.key === 'Enter') { event.stopPropagation(); onDelete(map) } }} className="absolute right-3 top-2 grid size-7 cursor-pointer place-items-center rounded-md text-white/42 opacity-0 transition hover:bg-red-500/15 hover:text-red-300 group-hover:opacity-100">
<Trash2 className="size-3.5" />
</span>
)}
</button>
)
})}
{!visibleMaps.length && <div className="grid min-h-[160px] place-items-center text-[13px] font-medium text-white/40">저장된 SLAM 맵이 없습니다.</div>}
</div>
<footer className="flex items-center justify-between border-t border-white/10 px-5 py-3">
{tab === 'environment' && (
<button type="button" onClick={onCreate} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/70 transition hover:bg-white/8 hover:text-white">
<PencilRuler className="size-3.5" />
맵 편집
</button>
)}
<button type="button" disabled={!pendingMap || pendingMap.key === selectedMapKey} onClick={() => pendingMap && onSelect(pendingMap)} className="ml-auto h-8 cursor-pointer rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-45">
확인
</button>
</footer>
</section>
</div>
)
}
type WorldMapRestartConfirmModalProps = {
map: WorkspaceMapOption
restarting: boolean
onCancel: () => void
onConfirm: () => void
}
export function WorldMapRestartConfirmModal({ map, restarting, onCancel, onConfirm }: WorldMapRestartConfirmModalProps): React.JSX.Element {
return (
<div className="fixed inset-0 z-[160] grid place-items-center bg-black/65 p-6" role="dialog" aria-modal="true" aria-label="맵 변경 확인">
<section className="w-full max-w-[400px] overflow-hidden rounded-lg border border-white/10 bg-[#171b23] text-white shadow-2xl">
<header className="flex items-center gap-2 border-b border-white/10 px-5 py-4">
<AlertTriangle className="size-4 text-amber-300" />
<h2 className="text-[15px] font-bold">맵 변경</h2>
</header>
<div className="px-5 py-4">
<p className="text-[13px] font-semibold">{map.name}으로 변경하시겠습니까?</p>
<p className="mt-2 text-[12px] leading-5 text-white/55">실행 중인 모듈을 전체 재시작해야 새 맵이 적용됩니다.</p>
<div className="mt-5 flex justify-end gap-2">
<button type="button" disabled={restarting} onClick={onCancel} className="h-8 cursor-pointer rounded-md border border-white/12 px-3 text-[12px] font-bold text-white/65 transition hover:bg-white/8 hover:text-white disabled:cursor-default disabled:opacity-45">
취소
</button>
<button type="button" disabled={restarting} onClick={onConfirm} className="inline-flex h-8 cursor-pointer items-center gap-1.5 rounded-md bg-[#2563eb] px-3 text-[12px] font-bold text-white transition hover:bg-[#1d5ed8] disabled:cursor-default disabled:opacity-60">
{restarting && <LoaderCircle className="size-3.5 animate-spin" />}
{restarting ? '재시작 중' : '변경 후 재시작'}
</button>
</div>
</div>
</section>
</div>
)
}
@@ -0,0 +1,102 @@
import type { CurriculumWeek } from '../curriculum/types'
import type { EnvironmentProfile } from '../platforms/types'
import type { ModulePrebuiltArtifact } from './types'
type CurriculumModule = NonNullable<CurriculumWeek['modules']>[number]
type RuntimeModuleOverride = {
id?: string
folderName?: string
name?: string
enabled?: boolean
sortOrder?: number
defaultParameters?: string[]
prebuiltArtifacts?: ModulePrebuiltArtifact[]
dockerImage?: string | null
}
type RuntimeModuleSet = {
target?: 'local' | 'platform'
includeUnlisted?: boolean
modules?: RuntimeModuleOverride[]
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null && !Array.isArray(value)
? value as Record<string, unknown>
: null
}
function readModuleSet(profile: EnvironmentProfile | null, runtimeTarget: 'local' | 'platform'): RuntimeModuleSet | null {
const config = asRecord(profile?.config)
const raw = asRecord(config?.moduleSet)
if (!raw) return null
const target = raw.target === 'local' || raw.target === 'platform' ? raw.target : undefined
if (target && target !== runtimeTarget) return null
const modules = Array.isArray(raw.modules)
? raw.modules.map(asRecord).filter((item): item is Record<string, unknown> => item !== null).map((item) => ({
id: typeof item.id === 'string' ? item.id : undefined,
folderName: typeof item.folderName === 'string' ? item.folderName : undefined,
name: typeof item.name === 'string' ? item.name : undefined,
enabled: typeof item.enabled === 'boolean' ? item.enabled : undefined,
sortOrder: typeof item.sortOrder === 'number' ? item.sortOrder : undefined,
defaultParameters: Array.isArray(item.defaultParameters)
? item.defaultParameters.filter((value): value is string => typeof value === 'string')
: undefined,
prebuiltArtifacts: Array.isArray(item.prebuiltArtifacts)
? item.prebuiltArtifacts.filter((value): value is ModulePrebuiltArtifact => typeof value === 'object' && value !== null)
: undefined,
dockerImage: typeof item.dockerImage === 'string' ? item.dockerImage : item.dockerImage === null ? null : undefined,
}))
: []
return {
target,
includeUnlisted: raw.includeUnlisted !== false,
modules,
}
}
function matchesModule(module: CurriculumModule, override: RuntimeModuleOverride): boolean {
return Boolean(
(override.id && override.id === module.id)
|| (override.folderName && override.folderName === module.folderName)
|| (override.name && override.name === module.name)
)
}
/** Environment-specific module selection preserves the shared student source repositories. */
export function resolveRuntimeModules(
modules: CurriculumWeek['modules'],
profile: EnvironmentProfile | EnvironmentProfile[] | null,
runtimeTarget: 'local' | 'platform',
): CurriculumWeek['modules'] {
const baseModules = modules ?? []
const profiles = Array.isArray(profile) ? profile : profile ? [profile] : []
const moduleSets = profiles
.map((item) => readModuleSet(item, runtimeTarget))
.filter((item): item is RuntimeModuleSet => item !== null)
if (!moduleSets.length) return baseModules
const overrides = moduleSets.flatMap((moduleSet) => moduleSet.modules ?? [])
const includeUnlisted = moduleSets.every((moduleSet) => moduleSet.includeUnlisted !== false)
return baseModules
.filter((module) => {
const override = overrides.find((item) => matchesModule(module, item))
return includeUnlisted || Boolean(override?.enabled !== false && override)
})
.filter((module) => overrides.find((item) => matchesModule(module, item))?.enabled !== false)
.map((module) => {
const override = overrides.find((item) => matchesModule(module, item))
if (!override) return module
return {
...module,
sortOrder: override.sortOrder ?? module.sortOrder,
defaultParameters: override.defaultParameters ?? module.defaultParameters,
prebuiltArtifacts: override.prebuiltArtifacts ?? module.prebuiltArtifacts,
dockerImage: override.dockerImage !== undefined ? override.dockerImage : module.dockerImage,
}
})
.sort((left, right) => left.sortOrder - right.sortOrder)
}
@@ -25,11 +25,7 @@ export function makeWeekWorkspaceRoot(workspaceRoot: string, studentSlug: string
export function makePlatformWorkspaceRoot(workspaceRoot: string, studentSlug: string, courseKey: string, weekNumber: number, platformKey: string): string {
const normalizedRoot = (workspaceRoot || defaultStudioSourceSettings.workspaceRoot).replace(/\/+$/, '')
if (platformKey === 'futuredrive-local-pc') return makeWeekWorkspaceRoot(normalizedRoot, studentSlug, courseKey, weekNumber)
const safePlatformKey = platformKey
.replace(/^futuredrive[-_]/i, '')
.replace(/[^a-zA-Z0-9_-]+/g, '-')
.toLowerCase()
const suffix = `${studentSlug}/${safePlatformKey}/week-${weekNumber}`
const suffix = `${normalizeCoursePathSegment(courseKey)}/week-${weekNumber}`
if (normalizedRoot === suffix || normalizedRoot.endsWith(`/${suffix}`)) return normalizedRoot
return `${normalizedRoot}/${suffix}`
}
@@ -1,4 +1,5 @@
import { STUDIO_RUNTIME_SESSION_STORAGE_KEY } from '../../config/appConfig'
import type { PlatformOption } from '../platforms/types'
export type StudioRuntimeSessionState = {
localPcLayoutOpen: boolean
@@ -8,6 +9,7 @@ export type StudioRuntimeSessionState = {
sessionEndedAt: string | null
connectedPlatformId: string | null
runningPlatformIds: string[]
runningPlatforms: PlatformOption[]
workspaceWeekNumber: number | null
}
@@ -27,6 +29,15 @@ export function readStudioRuntimeSessionState(): StudioRuntimeSessionState | nul
sessionEndedAt: typeof parsed.sessionEndedAt === 'string' ? parsed.sessionEndedAt : null,
connectedPlatformId: typeof parsed.connectedPlatformId === 'string' ? parsed.connectedPlatformId : null,
runningPlatformIds: parsed.runningPlatformIds.filter((id): id is string => typeof id === 'string'),
runningPlatforms: Array.isArray(parsed.runningPlatforms)
? parsed.runningPlatforms.filter((platform): platform is PlatformOption => (
Boolean(platform)
&& typeof platform === 'object'
&& typeof (platform as PlatformOption).id === 'string'
&& typeof (platform as PlatformOption).platformKey === 'string'
&& typeof (platform as PlatformOption).title === 'string'
))
: [],
workspaceWeekNumber: typeof parsed.workspaceWeekNumber === 'number' && parsed.workspaceWeekNumber > 0 ? parsed.workspaceWeekNumber : null
}
} catch {
@@ -15,6 +15,7 @@ export type ModuleRow = {
templateRepositoryUrl?: string | null
sourceType?: string | null
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
dockerImage?: string | null
parameters: string[]
cpu: number | null
memory: number | null
@@ -62,19 +62,6 @@ export function useRemoteWorkspaceRuntime({
prebuiltArtifacts?: ModulePrebuiltArtifact[] | null
}
const submitResult = await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(),
@@ -87,23 +74,45 @@ export function useRemoteWorkspaceRuntime({
runtimeImages: activeRuntimeImages
}
)
const submitResult = remoteWorkspace
? await window.remoteWorkspace.git.submit(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
)
: await window.localWorkspace.git.submit(
module.name,
clonePathPreview,
module.folderName,
`Run: ${weekNumber}주차 ${module.name}`,
undefined,
undefined,
undefined,
)
if (!submitResult.ok && !submitResult.message.includes('제출할 변경사항이 없습니다')) {
throw new Error(`[${module.name}] ${submitResult.message}`)
}
}, [
activeRuntimeImages,
clonePathPreview,
courseKey,
getFreshAccessToken,
getRemoteWorkspaceTarget,
remoteWorkspace,
saveDirtyFilesForModule,
weekNumber
])
const stopRunningWorkspaceModules = useCallback(async (): Promise<void> => {
if (remoteWorkspace) {
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; sortOrder?: number | null }>()
const moduleMap = new Map<string, { moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null; sortOrder?: number | null }>()
for (const module of availableModules) {
moduleMap.set(module.name, {
moduleName: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder
})
}
@@ -112,6 +121,7 @@ export function useRemoteWorkspaceRuntime({
moduleMap.set(module.name, {
moduleName: module.name,
folderName: module.folderName,
templateRepositoryUrl: module.templateRepositoryUrl,
sortOrder: module.sortOrder
})
}
@@ -8,6 +8,8 @@ import { getPathName, getPathParent, makeUniquePath, replacePathPrefix } from '.
type UseWorkspaceFileActionsOptions = {
selectedModule: ModuleRow | null
clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
selectedTreePath: string | null
setSelectedTreePath: Dispatch<SetStateAction<string | null>>
setSelectedExampleTreePath: Dispatch<SetStateAction<string | null>>
@@ -75,6 +77,8 @@ type UseWorkspaceFileActionsResult = {
export function useWorkspaceFileActions({
selectedModule,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
selectedTreePath,
setSelectedTreePath,
setSelectedExampleTreePath,
@@ -115,7 +119,9 @@ export function useWorkspaceFileActions({
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
try {
const result = await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const result = remoteWorkspace
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
: await window.localWorkspace.moduleFiles.read(module.name, clonePathPreview, module.folderName)
const nextFiles = result.files
const nextFolders = result.folders.length ? result.folders : []
@@ -168,7 +174,11 @@ export function useWorkspaceFileActions({
if (newFileDraft.kind === 'folder') {
const nextFolder = `${newFileDraft.parent}/${normalizedName}`
try {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFolder, 'folder', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFolder, 'folder', clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] create folder failed', error)
return
@@ -183,7 +193,11 @@ export function useWorkspaceFileActions({
const nextFile = `${newFileDraft.parent}/${normalizedName}`
try {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.create(getRemoteWorkspaceTarget(), selectedModule.name, nextFile, 'file', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.create(selectedModule.name, nextFile, 'file', clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] create file failed', error)
return
@@ -215,7 +229,11 @@ export function useWorkspaceFileActions({
const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
if (!selectedModule || file.startsWith('examples/example-code/')) return
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.write(selectedModule.name, file, editorFiles[file] ?? '', clonePathPreview, selectedModule.folderName)
}
markEditorFileSaved(file, editorFiles[file] ?? '')
if (installedGitModules.length) void refreshGitStatuses(selectedGitTarget)
}
@@ -293,7 +311,11 @@ export function useWorkspaceFileActions({
}
const nextPath = `${getPathParent(currentPath)}/${nextName.replace(/^\/+/, '')}`
try {
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.rename(getRemoteWorkspaceTarget(), selectedModule.name, currentPath, nextPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.rename(selectedModule.name, currentPath, nextPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] rename failed', error)
return
@@ -334,7 +356,11 @@ export function useWorkspaceFileActions({
const performDeleteTreeTarget = async (targetPath: string): Promise<void> => {
if (!selectedModule) return
try {
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.delete(getRemoteWorkspaceTarget(), selectedModule.name, targetPath, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.delete(selectedModule.name, targetPath, clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] delete failed', error)
return
@@ -364,7 +390,11 @@ export function useWorkspaceFileActions({
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
try {
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
} else {
await window.localWorkspace.moduleFiles.copy(selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, clonePathPreview, selectedModule.folderName)
}
} catch (error) {
console.error('[module-files] copy failed', error)
return
@@ -87,11 +87,23 @@ export function useWorkspaceModuleControl({
}
const templateRepositoryUrl = prepared.templateRepositoryUrlWithToken ?? prepared.templateRepositoryUrl ?? module.templateRepositoryUrl
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, {
const installOptions = {
prebuiltArtifacts: prepared.prebuiltArtifacts ?? module.prebuiltArtifacts ?? null,
runtimeImages: activeRuntimeImages
})
}, [activeRuntimeImages, clonePathPreview, courseKey, weekNumber])
}
if (remoteWorkspace) {
await window.remoteWorkspace.modules.install(
getRemoteWorkspaceTarget(),
module.name,
module.folderName,
templateRepositoryUrl,
prepared.repositoryUrlWithToken,
installOptions,
)
return
}
await window.localWorkspace.modules.install(module.name, clonePathPreview, module.folderName, templateRepositoryUrl, prepared.repositoryUrlWithToken, installOptions)
}, [activeRuntimeImages, clonePathPreview, courseKey, getRemoteWorkspaceTarget, remoteWorkspace, weekNumber])
const remoteModuleStart = useCallback(async (module: ModuleRow): Promise<ModuleActionResult> => {
await prepareRemoteRuntimeModule(module)
@@ -102,7 +114,8 @@ export function useWorkspaceModuleControl({
getModuleRosParams(module),
sourceSettings.gpuEnabled,
sourceSettings.rosDomainId,
activeRuntimeImages
activeRuntimeImages,
module.dockerImage,
)
}, [activeRuntimeImages, getModuleRosParams, getRemoteWorkspaceTarget, prepareRemoteRuntimeModule, sourceSettings.gpuEnabled, sourceSettings.rosDomainId])
@@ -206,10 +219,11 @@ export function useWorkspaceModuleControl({
setModuleInstallError(null)
try {
if (remoteWorkspace) {
for (const module of targets) {
const result = await remoteModuleStop(module)
if (!result.ok) throw new Error(result.message)
}
const result = await window.remoteWorkspace.modules.stopMany(
getRemoteWorkspaceTarget(),
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
)
if (!result.ok) throw new Error(result.message)
} else {
const result = await window.localWorkspace.modules.stopMany(
targets.map((module) => ({ moduleName: module.name, folderName: module.folderName })),
@@ -227,7 +241,7 @@ export function useWorkspaceModuleControl({
} finally {
setInstallingModuleName(null)
}
}, [availableModules, clonePathPreview, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteModuleStop, remoteWorkspace])
}, [availableModules, clonePathPreview, getRemoteWorkspaceTarget, installingModuleName, loadAvailableModules, onTitlebarNotice, remoteWorkspace])
const runModuleControl = useCallback(async (module: ModuleRow, action: 'start' | 'stop' | 'restart' | 'build'): Promise<void> => {
if (installingModuleName || controllingModuleNames.includes(module.name)) return
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Dispatch, SetStateAction } from 'react'
import type { CurriculumWeek } from '../curriculum/types'
import type { ModuleRow } from './types'
@@ -21,6 +21,7 @@ type UseWorkspaceModuleListOptions = {
type UseWorkspaceModuleListResult = {
availableModules: ModuleRow[]
setAvailableModules: Dispatch<SetStateAction<ModuleRow[]>>
markModuleState: (module: ModuleRow, state: ModuleRow['state']) => void
modulesLoading: boolean
moduleListError: string | null
loadAvailableModules: (options?: LoadAvailableModulesOptions) => Promise<void>
@@ -49,29 +50,79 @@ export function useWorkspaceModuleList({
const [modulesLoading, setModulesLoading] = useState(false)
const [moduleListError, setModuleListError] = useState<string | null>(null)
const remoteTarget = useMemo(() => parseRemoteWorkspaceTarget(remoteWorkspaceTargetKey), [remoteWorkspaceTargetKey])
const loadSequenceRef = useRef(0)
const pendingModuleStatesRef = useRef(new Map<string, { state: ModuleRow['state']; expiresAt: number }>())
const moduleStateKey = (module: ModuleRow): string => module.folderName ?? module.name
const applyPendingModuleStates = useCallback((modules: ModuleRow[]): ModuleRow[] => {
const now = Date.now()
return modules.map((module) => {
const key = moduleStateKey(module)
const pending = pendingModuleStatesRef.current.get(key)
if (!pending) return module
if (module.state === pending.state || pending.expiresAt <= now) {
pendingModuleStatesRef.current.delete(key)
return module
}
return {
...module,
state: pending.state,
runtime: pending.state === 'running' ? '시작 중' : '-',
cpu: pending.state === 'running' ? module.cpu : null,
memory: pending.state === 'running' ? module.memory : null,
}
})
}, [])
const markModuleState = useCallback((module: ModuleRow, state: ModuleRow['state']): void => {
const key = moduleStateKey(module)
pendingModuleStatesRef.current.set(key, { state, expiresAt: Date.now() + 10_000 })
setAvailableModules((current) => current.map((item) => {
if (moduleStateKey(item) !== key) return item
return {
...item,
state,
runtime: state === 'running' ? '시작 중' : '-',
cpu: state === 'running' ? item.cpu : null,
memory: state === 'running' ? item.memory : null,
}
}))
}, [])
const loadRemoteModules = useCallback(async (): Promise<{ modules: ModuleRow[] }> => {
if (!remoteTarget) return { modules: [] }
return window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
const result = await window.remoteWorkspace.modules.list(remoteTarget, curriculumModules ?? [])
return {
modules: result.modules.map((module) => {
const rawModule = module as ModuleRow & { defaultParameters?: unknown }
const parameters = Array.isArray(rawModule.parameters)
? rawModule.parameters
: Array.isArray(rawModule.defaultParameters)
? rawModule.defaultParameters.filter((value): value is string => typeof value === 'string')
: []
return { ...module, parameters }
}),
}
}, [curriculumModuleKey, remoteTarget])
const loadAvailableModules = useCallback(async ({ silent = false }: LoadAvailableModulesOptions = {}): Promise<void> => {
const requestSequence = ++loadSequenceRef.current
if (!silent) setModulesLoading(true)
try {
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
if (remoteWorkspace) {
const remoteResult = await loadRemoteModules().catch(() => ({ modules: [] as ModuleRow[] }))
const remoteStateByName = new Map(remoteResult.modules.map((module) => [module.name, module]))
result.modules = result.modules.map((module) => {
const remoteModule = remoteStateByName.get(module.name)
return remoteModule?.state === 'running'
? { ...module, state: 'running', cpu: remoteModule.cpu, memory: remoteModule.memory, runtime: remoteModule.runtime }
: module
})
const result = await loadRemoteModules()
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
return
}
const result = await window.localWorkspace.modules.list(clonePathPreview, curriculumModules ?? [])
if (requestSequence !== loadSequenceRef.current) return
setModuleListError(null)
setAvailableModules(applyModuleParameterOverrides(result.modules).sort(compareModulePriority))
setAvailableModules(applyPendingModuleStates(applyModuleParameterOverrides(result.modules).sort(compareModulePriority)))
} catch (error) {
if (requestSequence !== loadSequenceRef.current) return
console.error('[modules] list failed', error)
const message = error instanceof Error ? error.message : '모듈 목록을 불러오지 못했습니다.'
if (!silent) {
@@ -79,10 +130,11 @@ export function useWorkspaceModuleList({
setAvailableModules(makeUninstalledModuleRows(curriculumModules))
}
} finally {
if (!silent) setModulesLoading(false)
if (!silent && requestSequence === loadSequenceRef.current) setModulesLoading(false)
}
}, [
applyModuleParameterOverrides,
applyPendingModuleStates,
clonePathPreview,
curriculumModuleKey,
curriculumModules,
@@ -107,6 +159,7 @@ export function useWorkspaceModuleList({
return {
availableModules,
setAvailableModules,
markModuleState,
modulesLoading,
moduleListError,
loadAvailableModules,
@@ -14,6 +14,8 @@ type UseWorkspaceTerminalOptions = {
moduleName?: string | null
moduleFolderName?: string | null
clonePathPreview: string
remoteWorkspace: boolean
getRemoteWorkspaceTarget: () => RemoteWorkspaceTarget
dark: boolean
terminalActive: boolean
resetKey: string
@@ -36,6 +38,8 @@ export function useWorkspaceTerminal({
moduleName,
moduleFolderName,
clonePathPreview,
remoteWorkspace,
getRemoteWorkspaceTarget,
dark,
terminalActive,
resetKey,
@@ -47,11 +51,14 @@ export function useWorkspaceTerminal({
const terminalRef = useRef<XTerm | null>(null)
const terminalFitRef = useRef<FitAddon | null>(null)
const terminalSessionIdRef = useRef<string | null>(null)
const remoteTargetResolverRef = useRef(getRemoteWorkspaceTarget)
const terminalSuppressQueueRef = useRef<string[]>([])
const terminalOutputBufferRef = useRef<Record<string, string>>({})
const terminalFallbackPromptShownRef = useRef<Set<string>>(new Set())
const [terminalPanelHeight, setTerminalPanelHeight] = useState(235)
remoteTargetResolverRef.current = getRemoteWorkspaceTarget
const fitTerminal = (): void => {
terminalFitRef.current?.fit()
}
@@ -71,7 +78,12 @@ export function useWorkspaceTerminal({
void navigator.clipboard.readText()
.then((text) => {
if (text) window.localWorkspace.terminal.write(text, terminalSessionId)
if (!text) return
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), text, terminalSessionId)
return
}
window.localWorkspace.terminal.write(text, terminalSessionId)
})
.catch((error) => console.error('[terminal] paste failed', error))
.finally(() => {
@@ -153,8 +165,18 @@ export function useWorkspaceTerminal({
terminalRef.current = term
terminalFitRef.current = fitAddon
const terminalApi = window.localWorkspace.terminal
const removeDataListener = terminalApi.onData((data) => {
const terminalApi = remoteWorkspace ? window.remoteWorkspace.terminal : window.localWorkspace.terminal
const removeDataListener = remoteWorkspace
? terminalApi.onData(remoteTargetResolverRef.current(), (data) => {
const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) {
suppressQueue.shift()
return
}
terminalOutputBufferRef.current[terminalSessionId] = `${terminalOutputBufferRef.current[terminalSessionId] ?? ''}${data}`.slice(-100000)
term.write(data)
}, terminalSessionId)
: terminalApi.onData((data) => {
const suppressQueue = terminalSuppressQueueRef.current
if (suppressQueue.length && suppressQueue[0] === data) {
suppressQueue.shift()
@@ -168,10 +190,20 @@ export function useWorkspaceTerminal({
term.write(nextData)
}
}, terminalSessionId)
const removeSuppressListener = terminalApi.onSuppressData((data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
const inputDisposable = term.onData((data) => terminalApi.write(data, terminalSessionId))
const removeSuppressListener = remoteWorkspace
? window.remoteWorkspace.terminal.onSuppressData(remoteTargetResolverRef.current(), (data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
: window.localWorkspace.terminal.onSuppressData((data) => {
terminalSuppressQueueRef.current.push(data)
}, terminalSessionId)
const inputDisposable = term.onData((data) => {
if (remoteWorkspace) {
window.remoteWorkspace.terminal.write(remoteTargetResolverRef.current(), data, terminalSessionId)
return
}
window.localWorkspace.terminal.write(data, terminalSessionId)
})
let fallbackPromptTimer: number | null = null
let sizeSyncTimer: number | null = null
@@ -183,8 +215,24 @@ export function useWorkspaceTerminal({
startNestedFrame = window.requestAnimationFrame(() => {
fitAddon.fit()
if (disposed) return resolvePromise(false)
void window.localWorkspace.terminal
.start(moduleName, term.cols, term.rows, terminalSessionId, clonePathPreview, moduleFolderName ?? undefined)
const start = remoteWorkspace
? window.remoteWorkspace.terminal.start(
remoteTargetResolverRef.current(),
moduleName,
term.cols,
term.rows,
terminalSessionId,
moduleFolderName ?? undefined,
)
: window.localWorkspace.terminal.start(
moduleName,
term.cols,
term.rows,
terminalSessionId,
clonePathPreview,
moduleFolderName ?? undefined,
)
void start
.then(resolvePromise)
.catch(() => resolvePromise(false))
})
@@ -195,7 +243,11 @@ export function useWorkspaceTerminal({
if (!started) return
sizeSyncTimer = window.setTimeout(() => {
fitAddon.fit()
void terminalApi.resize(term.cols, term.rows, terminalSessionId)
if (remoteWorkspace) {
void window.remoteWorkspace.terminal.resize(remoteTargetResolverRef.current(), term.cols, term.rows, terminalSessionId)
return
}
void window.localWorkspace.terminal.resize(term.cols, term.rows, terminalSessionId)
}, 150)
fallbackPromptTimer = window.setTimeout(() => {
if (terminalFallbackPromptShownRef.current.has(terminalSessionId) && terminalOutputBufferRef.current[terminalSessionId]) return
@@ -228,14 +280,15 @@ export function useWorkspaceTerminal({
// Full-screen programs such as vi cannot be restored from a partial raw output buffer.
delete terminalOutputBufferRef.current[terminalSessionId]
terminalFallbackPromptShownRef.current.delete(terminalSessionId)
terminalApi.stop(terminalSessionId)
if (remoteWorkspace) window.remoteWorkspace.terminal.stop(remoteTargetResolverRef.current(), terminalSessionId)
else window.localWorkspace.terminal.stop(terminalSessionId)
term.dispose()
terminalRef.current = null
terminalFitRef.current = null
terminalSessionIdRef.current = null
terminalSuppressQueueRef.current = []
}
}, [clonePathPreview, dark, moduleFolderName, moduleName, resetKey, terminalActive])
}, [clonePathPreview, dark, moduleFolderName, moduleName, remoteWorkspace, resetKey, terminalActive])
return {
terminalContextMenu,
@@ -3,12 +3,40 @@ import type { EnvironmentProfile } from '../platforms/types'
import type { RuntimeImageArtifact } from './types'
import type { VisualizationStack } from './WorkspaceVisualizationPanel'
export type Nav2VisualizationMode = 'slam' | 'navigation'
function resolveVisualizationStack(profileType: string): VisualizationStack | null {
const type = profileType.toLowerCase()
if (type === 'real_slam') return 'slam'
if (type === 'real_navigation') return 'nav2'
return type === 'autoware' || type === 'nav2' || type === 'slam' ? type : null
}
function resolveNav2Mode(profile: EnvironmentProfile | null): Nav2VisualizationMode {
return profile?.profileType.toLowerCase() === 'real_navigation' ? 'navigation' : 'slam'
}
function isRealPlatformProfile(profile: EnvironmentProfile | null): boolean {
const type = profile?.profileType.toLowerCase()
return type === 'real_slam' || type === 'real_navigation'
}
function selectRuntimeProfiles(profiles: EnvironmentProfile[], remoteWorkspace: boolean): EnvironmentProfile[] {
const matchingProfiles = profiles.filter((profile) => (
remoteWorkspace ? isRealPlatformProfile(profile) : !isRealPlatformProfile(profile)
))
return matchingProfiles.length ? matchingProfiles : profiles
}
type NoticeTone = 'info' | 'warning' | 'error'
type UseWorkspaceVisualizationOptions = {
profiles: EnvironmentProfile[]
clonePathPreview: string
rosDomainId: string
remoteWorkspace: boolean
mapKey?: string | null
worldLayoutVersion?: number
onTitlebarNotice: (title: string, tone?: NoticeTone, addToList?: boolean) => void
}
@@ -19,6 +47,8 @@ type UseWorkspaceVisualizationResult = {
visualizationRunning: boolean
visualizationBusy: boolean
visualizationViewerUrl: string
nav2VisualizationMode: Nav2VisualizationMode
setNav2VisualizationMode: (mode: Nav2VisualizationMode) => void
activeEnvironmentProfile: EnvironmentProfile | null
activeRuntimeImages: RuntimeImageArtifact[]
refreshVisualizationStatus: () => Promise<void>
@@ -36,20 +66,25 @@ export function useWorkspaceVisualization({
profiles,
clonePathPreview,
rosDomainId,
remoteWorkspace,
mapKey,
worldLayoutVersion,
onTitlebarNotice
}: UseWorkspaceVisualizationOptions): UseWorkspaceVisualizationResult {
const [visualizationStack, setVisualizationStack] = useState<VisualizationStack>('autoware')
const [nav2VisualizationMode, setNav2VisualizationMode] = useState<Nav2VisualizationMode>('slam')
const [visualizationRunning, setVisualizationRunning] = useState(false)
const [, setVisualizationStatusMessage] = useState('대기')
const [visualizationBusy, setVisualizationBusy] = useState(false)
const runtimeProfiles = useMemo(() => selectRuntimeProfiles(profiles, remoteWorkspace), [profiles, remoteWorkspace])
const visualizationStacks = useMemo(() => (
profiles
.map((profile) => profile.profileType.toLowerCase())
.filter((stack): stack is VisualizationStack => stack === 'autoware' || stack === 'nav2' || stack === 'slam')
), [profiles])
runtimeProfiles
.map((profile) => resolveVisualizationStack(profile.profileType))
.filter((stack): stack is VisualizationStack => stack !== null)
), [runtimeProfiles])
const activeEnvironmentProfile = profiles.find((profile) => profile.profileType.toLowerCase() === visualizationStack) ?? profiles[0] ?? null
const activeEnvironmentProfile = runtimeProfiles.find((profile) => resolveVisualizationStack(profile.profileType) === visualizationStack) ?? runtimeProfiles[0] ?? null
const activeEnvironmentConfig = activeEnvironmentProfile?.config ?? {}
const activeRuntimeImages = Array.isArray(activeEnvironmentConfig.runtimeImages)
? activeEnvironmentConfig.runtimeImages.filter((image): image is RuntimeImageArtifact => (
@@ -78,16 +113,15 @@ export function useWorkspaceVisualization({
? visualizationBridgeHttpUrl.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:')
: 'ws://localhost:8765'
const visualizationBackendUrl = visualizationBridgeHttpUrl
const visualizationViewerUrl = visualizationStack === 'slam'
? `/slam-viewer/index.html?directBridge=1&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}`
const visualizationViewerUrl = visualizationStack === 'slam' || visualizationStack === 'nav2'
? `/slam-viewer/index.html?directBridge=1&mode=${nav2VisualizationMode}&runtime=${isRealPlatformProfile(activeEnvironmentProfile) ? 'real' : 'simulation'}&bridgeWs=${encodeURIComponent(`${visualizationBridgeUrl}/ws/state`)}&bridgeHttp=${encodeURIComponent(visualizationBridgeHttpUrl)}&mapKey=${encodeURIComponent(mapKey ?? 'nav2-turtlebot3-world')}&layoutVersion=${worldLayoutVersion ?? 0}`
: `/autoware-viewer/index.html?source=ros&stack=${visualizationStack}&bridge=${encodeURIComponent(visualizationBridgeUrl)}&backend=${encodeURIComponent(visualizationBackendUrl)}`
useEffect(() => {
const profileType = profiles[0]?.profileType?.toLowerCase()
if (profileType === 'slam' || profileType === 'nav2' || profileType === 'autoware') {
setVisualizationStack(profileType)
}
}, [profiles.map((profile) => profile.id).join('|')])
const stack = runtimeProfiles[0] ? resolveVisualizationStack(runtimeProfiles[0].profileType) : null
if (stack) setVisualizationStack(stack)
setNav2VisualizationMode(resolveNav2Mode(runtimeProfiles[0] ?? null))
}, [runtimeProfiles])
const refreshVisualizationStatus = async (): Promise<void> => {
const status = await window.localWorkspace.visualization.status().catch((error) => ({
@@ -155,6 +189,8 @@ export function useWorkspaceVisualization({
visualizationRunning,
visualizationBusy,
visualizationViewerUrl,
nav2VisualizationMode,
setNav2VisualizationMode,
activeEnvironmentProfile,
activeRuntimeImages,
refreshVisualizationStatus,
@@ -10,7 +10,7 @@ export function getEditorLanguage(file: string): string {
if (file.endsWith('.tsx') || file.endsWith('.ts')) return 'typescript'
if (/\.(c|cc|cpp|cxx|h|hh|hpp|hxx)$/i.test(file)) return 'cpp'
if (file.endsWith('.json')) return 'json'
if (file.endsWith('.xml')) return 'xml'
if (file.endsWith('.xml') || file.endsWith('.urdf') || file.endsWith('.xacro')) return 'xml'
if (file.endsWith('.html')) return 'html'
if (file.endsWith('.css')) return 'css'
if (file.endsWith('.py')) return 'python'
+123 -12
View File
@@ -9,6 +9,8 @@ type ModuleInput = {
folderName?: string | null
category?: string | null
sortOrder?: number | null
defaultParameters?: string[] | null
dockerImage?: string | null
templateRepositoryUrl?: string | null
studentRepositoryUrl?: string | null
}
@@ -61,6 +63,43 @@ function readLaunchState(): { handoffToken: string | null; audienceApp: string;
}
const launchState = readLaunchState()
const launcherSessionExpiredListeners = new Set<() => void>()
function notifyLauncherSessionExpired(): void {
for (const listener of launcherSessionExpiredListeners) listener()
}
function watchLauncherSession(): void {
if (!launchState.bridge) return
const controller = new AbortController()
void fetch(`${launchState.bridge.url}/api/session/events`, {
method: 'POST',
headers: { [BRIDGE_HEADER]: launchState.bridge.token },
signal: controller.signal,
}).then(async (response) => {
if (!response.ok || !response.body) return
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) return
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? ''
if (events.some((event) => event.startsWith('event: session-expired'))) {
notifyLauncherSessionExpired()
controller.abort()
return
}
}
}).catch((error) => {
if (error instanceof DOMException && error.name === 'AbortError') return
console.warn('[lms-auth] launcher session event connection failed', error)
})
}
async function bridgeRequest<T>(path: string, body?: unknown): Promise<T> {
if (!launchState.bridge) throw new Error('런처 로컬 연결 정보가 없습니다. 런처에서 웹 실행을 다시 선택하세요.')
@@ -211,6 +250,7 @@ function fileRequest<T>(path: string, moduleName: string, workspaceRoot: string
const localFileApi = {
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/read', moduleName, workspaceRoot, folderName),
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => fileRequest<{ dataUrl: string }>('/api/files/image', moduleName, workspaceRoot, folderName, { path }),
create: (moduleName: string, path: string, kind: 'file' | 'folder', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/create', moduleName, workspaceRoot, folderName, { path, kind }),
write: (moduleName: string, path: string, content: string, workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/write', moduleName, workspaceRoot, folderName, { path, content }),
copy: (moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', workspaceRoot?: string, folderName?: string) => fileRequest('/api/files/copy', moduleName, workspaceRoot, folderName, { fromPath, toPath, mode }),
@@ -286,6 +326,17 @@ const unavailableVisualizationApi = {
stop: async () => ({ ok: true, running: false, stack: null }),
}
const localWorldLayoutApi = {
read: (workspaceRoot?: string) => bridgeRequest<WorldLayout>('/api/world-layout/read', { workspaceRoot }),
write: (workspaceRoot: string | undefined, layout: WorldLayout) => bridgeRequest<WorldLayout>('/api/world-layout/write', { workspaceRoot, layout }),
}
const unavailableNav2Api = {
setSimulatorPose: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
addRuntimeObstacle: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
clearRuntimeObstacles: async () => ({ ok: false, message: 'NAV2 시각화 제어는 웹 브리지에서 아직 지원하지 않습니다.' }),
}
function remoteUnavailable(feature: string): never {
throw new Error(`원격 플랫폼 ${feature}은 Platform Gateway와 Agent API 연결이 필요합니다.`)
}
@@ -301,6 +352,8 @@ function remoteFileRequest<T>(path: string, target: unknown, moduleName: string,
const localWorkspace = {
moduleFiles: localFileApi,
modules: localModules,
nav2: unavailableNav2Api,
worldLayout: localWorldLayoutApi,
visualization: unavailableVisualizationApi,
git: localGitApi,
terminal: localTerminalApi,
@@ -309,6 +362,7 @@ const localWorkspace = {
const remoteWorkspace = {
moduleFiles: {
read: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/files/read', target, moduleName, folderName),
image: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest<{ dataUrl: string }>('/api/remote/files/image', target, moduleName, folderName, { path }),
create: (target: unknown, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => remoteFileRequest('/api/remote/files/create', target, moduleName, folderName, { path, kind }),
write: (target: unknown, moduleName: string, path: string, content: string, folderName?: string) => remoteFileRequest('/api/remote/files/write', target, moduleName, folderName, { path, content }),
copy: (target: unknown, moduleName: string, fromPath: string, toPath: string, mode: 'copy' | 'cut', folderName?: string) => remoteFileRequest('/api/remote/files/copy', target, moduleName, folderName, { fromPath, toPath, mode }),
@@ -316,26 +370,78 @@ const remoteWorkspace = {
delete: (target: unknown, moduleName: string, path: string, folderName?: string) => remoteFileRequest('/api/remote/files/delete', target, moduleName, folderName, { path }),
},
modules: {
list: (target: unknown, modules: ModuleInput[] = []) => remoteBridgeRequest('/api/remote/modules/list', target, { modules }),
install: async (target: unknown, moduleName: string, folderName?: string, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null) => {
const result = await remoteFileRequest<{ ok: boolean; workspacePath?: string }>('/api/remote/modules/install', target, moduleName, folderName, { templateRepositoryUrl, studentRepositoryUrl })
list: async (target: unknown, modules: ModuleInput[] = []) => {
const result = await remoteBridgeRequest<{ workspaceRoot: string; modules: unknown[] }>('/api/remote/modules/list', target, { modules })
return {
...result,
modules: result.modules.map((value, index) => {
const module = value as Partial<ModuleInput> & {
state?: 'running' | 'stopped' | 'uninstalled'
workspacePath?: string | null
parameters?: string[]
cpu?: number | null
memory?: number | null
runtime?: string
}
return {
...module,
state: module.state ?? 'uninstalled',
sortOrder: module.sortOrder ?? index + 1,
category: module.category ?? '',
name: module.name ?? '',
folderName: module.folderName ?? module.name ?? '',
workspacePath: module.workspacePath ?? null,
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
parameters: Array.isArray(module.parameters)
? module.parameters
: Array.isArray(module.defaultParameters)
? module.defaultParameters
: [],
cpu: module.cpu ?? null,
memory: module.memory ?? null,
runtime: module.runtime ?? '-',
}
}),
}
},
install: async (
target: unknown,
moduleName: string,
folderName?: string,
templateRepositoryUrl?: string | null,
studentRepositoryUrl?: string | null,
options?: { prebuiltArtifacts?: unknown; runtimeImages?: unknown },
) => {
const result = await remoteFileRequest<{ ok: boolean; workspacePath?: string }>('/api/remote/modules/install', target, moduleName, folderName, {
templateRepositoryUrl,
studentRepositoryUrl,
prebuiltArtifacts: options?.prebuiltArtifacts,
runtimeImages: options?.runtimeImages,
})
return { ok: result.ok, module: { state: 'stopped' as const, sortOrder: 0, category: '', name: moduleName, folderName: folderName ?? moduleName, sourcePath: result.workspacePath ?? '', workspacePath: result.workspacePath ?? null, parameters: [], cpu: null, memory: null, runtime: '-' } }
},
start: (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages }),
build: (target: unknown, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => remoteFileRequest('/api/remote/modules/build', target, moduleName, folderName, { gpuEnabled, rosDomainId, runtimeImages }),
start: (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage }),
build: (target: unknown, moduleName: string, folderName?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => remoteFileRequest('/api/remote/modules/build', target, moduleName, folderName, { gpuEnabled, rosDomainId, runtimeImages, dockerImage }),
stop: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName),
stopMany: async (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null }>) => {
const results = await Promise.all(modules.map((module) => remoteFileRequest<{ ok: boolean }>('/api/remote/modules/stop', target, module.moduleName, module.folderName ?? undefined)))
return { ok: results.every((result) => result.ok), message: '모듈 중지 요청을 완료했습니다.' }
},
stopMany: (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null }>) => remoteBridgeRequest<{ ok: boolean; message: string }>('/api/remote/modules/stop-many', target, {
modules: modules.map((module) => moduleInput(module.moduleName, module.folderName ?? undefined)),
}),
stopRunning: () => remoteUnavailable('모듈 일괄 중지'),
restart: async (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown) => {
restart: async (target: unknown, moduleName: string, folderName?: string, rosParams?: string, gpuEnabled?: boolean, rosDomainId?: string, runtimeImages?: unknown, dockerImage?: string | null) => {
await remoteFileRequest('/api/remote/modules/stop', target, moduleName, folderName)
return remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages })
return remoteFileRequest('/api/remote/modules/start', target, moduleName, folderName, { rosParams, gpuEnabled, rosDomainId, runtimeImages, dockerImage })
},
logs: (target: unknown, moduleName: string, folderName?: string) => remoteFileRequest('/api/remote/modules/logs', target, moduleName, folderName),
updateParameters: () => remoteUnavailable('모듈 설정'),
resetSession: async () => ({ ok: true, message: '플랫폼 세션을 종료했습니다.' }),
resetSession: async (target: unknown, modules: Array<{ moduleName: string; folderName?: string | null; templateRepositoryUrl?: string | null }>) => {
return remoteBridgeRequest<{ ok: boolean; message?: string }>('/api/remote/modules/reset-session', target, {
modules: modules.map((module) => ({
name: module.moduleName,
folderName: module.folderName ?? module.moduleName,
templateRepositoryUrl: module.templateRepositoryUrl ?? null,
})),
})
},
},
git: {
status: (target: unknown, moduleName: string, folderName?: string, selectedFilePath?: string | null) => remoteFileRequest('/api/remote/git/status', target, moduleName, folderName, { selectedFilePath }),
@@ -388,7 +494,12 @@ window.launcherAuth = {
appUrl: null,
handoffToken: launchState.handoffToken,
audienceApp: launchState.audienceApp,
onSessionExpired: (listener: () => void) => {
launcherSessionExpiredListeners.add(listener)
return () => launcherSessionExpiredListeners.delete(listener)
},
}
watchLauncherSession()
window.lmsBootSplash = { status: () => undefined, done: () => undefined }
window.titlebar = {
minimize: () => undefined,