patch..
This commit is contained in:
+370
-53
@@ -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,
|
||||
|
||||
@@ -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="연결종료"
|
||||
|
||||
Vendored
+33
-8
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user