feat(lms): add Docker setup guidance and improve remote workspace handling
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.1.23",
|
||||
"version": "1.1.26",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { test } from 'node:test'
|
||||
import { classifyDockerError } from './dockerStatus'
|
||||
|
||||
test('missing CLI is distinguished from an unavailable daemon', () => {
|
||||
assert.equal(classifyDockerError('ENOENT', 'spawn docker ENOENT'), 'missing')
|
||||
assert.equal(classifyDockerError(1, 'Cannot connect to the Docker daemon'), 'unavailable')
|
||||
assert.equal(classifyDockerError(1, 'dial unix /run/docker.sock: connect: connection refused'), 'unavailable')
|
||||
})
|
||||
|
||||
test('permission errors take precedence over connection errors', () => {
|
||||
assert.equal(classifyDockerError(1, 'Cannot connect: permission denied'), 'permission')
|
||||
assert.equal(classifyDockerError('EACCES', 'spawn docker EACCES'), 'permission')
|
||||
})
|
||||
|
||||
test('timeouts and unknown failures do not imply Docker needs installing', () => {
|
||||
assert.equal(classifyDockerError(null, 'Command failed: docker info'), 'error')
|
||||
assert.equal(classifyDockerError(1, 'context not found'), 'error')
|
||||
})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
export type DockerStatus = 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'
|
||||
|
||||
export function classifyDockerError(code: string | number | null | undefined, message: string): DockerStatus {
|
||||
if (code === 'ENOENT') return 'missing'
|
||||
if (/permission denied|access is denied|EACCES/i.test(message)) return 'permission'
|
||||
if (/cannot connect|connection refused|is the docker daemon running/i.test(message)) return 'unavailable'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export function checkDockerStatus(): Promise<{ status: DockerStatus; platform: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile('docker', ['info', '--format', '{{.ServerVersion}}'], {
|
||||
timeout: 5000,
|
||||
maxBuffer: 256 * 1024,
|
||||
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
||||
}, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
status: error
|
||||
? classifyDockerError(error.code, `${error.message}\n${stderr}`)
|
||||
: stdout.trim() ? 'ready' : 'error',
|
||||
platform: process.platform,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
+52
-4
@@ -1,4 +1,5 @@
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen } from 'electron'
|
||||
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
|
||||
import { checkDockerStatus } from './dockerStatus'
|
||||
import type { WebContents } from 'electron'
|
||||
import { spawn } from 'child_process'
|
||||
import * as pty from 'node-pty'
|
||||
@@ -2140,8 +2141,8 @@ async function readRemoteDockerStatsOutput(target: RemoteWorkspaceTarget): Promi
|
||||
try {
|
||||
const output = await runUnqueuedSshOutput(
|
||||
normalized,
|
||||
'timeout -k 1s 2s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
|
||||
4_000,
|
||||
'timeout -k 1s 8s docker stats --no-stream --format "STATS\\t{{.Name}}\\t{{.CPUPerc}}\\t{{.MemPerc}}" 2>/dev/null || true',
|
||||
15_000,
|
||||
)
|
||||
remoteDockerStatsRetryAfter.delete(key)
|
||||
return output
|
||||
@@ -2218,6 +2219,47 @@ async function readRemoteSavedMapArtifacts(
|
||||
}))
|
||||
}
|
||||
|
||||
async function uploadRemoteRtabmapDatabase(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
uploadUrl: string,
|
||||
databasePath?: string | null,
|
||||
): Promise<{ ok: boolean }> {
|
||||
const target = normalizeRemoteTarget(targetInput)
|
||||
const url = new URL(uploadUrl)
|
||||
if (url.protocol !== 'https:' || !url.hostname.endsWith('.r2.cloudflarestorage.com')
|
||||
|| url.username || url.password || url.port || !url.searchParams.has('X-Amz-Signature')) {
|
||||
throw new Error('R2 업로드 URL이 올바르지 않습니다.')
|
||||
}
|
||||
const fileName = databasePath ? savedMapArtifactFileName('database', databasePath) : 'rtabmap-active.db'
|
||||
const sourcePath = `${target.workspaceRoot}/maps/${fileName}`
|
||||
const snapshotScript = [
|
||||
'import sqlite3, sys',
|
||||
'source = sqlite3.connect("file:" + sys.argv[1] + "?mode=ro", uri=True)',
|
||||
'destination = sqlite3.connect(sys.argv[2])',
|
||||
'source.backup(destination)',
|
||||
'destination.close()',
|
||||
'source.close()',
|
||||
].join('; ')
|
||||
const script = [
|
||||
'set -e',
|
||||
`SOURCE=${quoteShellSingle(sourcePath)}`,
|
||||
'test -f "$SOURCE"',
|
||||
'command -v curl >/dev/null',
|
||||
'SNAPSHOT=$(mktemp --suffix=.db)',
|
||||
'trap \'rm -f "$SNAPSHOT"\' EXIT',
|
||||
`timeout -k 2s 120s python3 -c ${quoteShellSingle(snapshotScript)} "$SOURCE" "$SNAPSHOT"`,
|
||||
`curl --fail --silent --show-error --connect-timeout 15 --max-time 900 --header 'Content-Type: application/octet-stream' --upload-file "$SNAPSHOT" --output /dev/null ${quoteShellSingle(uploadUrl)}`,
|
||||
].join('\n')
|
||||
try {
|
||||
// Only commands cross SSH; the database bytes go from the platform to R2.
|
||||
await runUnqueuedSshOutput(target, script, 1_100_000)
|
||||
} catch {
|
||||
// The command contains a signed URL; do not expose it in renderer errors.
|
||||
throw new Error('플랫폼의 3D DB R2 업로드에 실패했습니다. DB 파일, curl 설치 및 인터넷 연결을 확인해 주세요.')
|
||||
}
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function copyRemoteActiveRtabmapDatabaseToLocal(
|
||||
targetInput: RemoteWorkspaceTarget,
|
||||
localWorkspaceRoot: string | null | undefined,
|
||||
@@ -5612,7 +5654,8 @@ async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, modu
|
||||
' esac',
|
||||
'done'
|
||||
].join('\n')
|
||||
const output = await runSshOutput(target, script, 30000)
|
||||
// Source transfers must not hold up module start/stop on the control queue.
|
||||
const output = await runUnqueuedSshOutput(target, script, 30000)
|
||||
const files: Record<string, string> = {}
|
||||
const folders: string[] = []
|
||||
|
||||
@@ -6198,6 +6241,8 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
})
|
||||
ipcMain.handle('local-workspace:modules:list', (_event, workspaceRoot?: string | null, courseModules?: CourseModuleInput[] | null) => collectAvailableModules(workspaceRoot, courseModules))
|
||||
ipcMain.handle('local-workspace:modules:docker-images', () => listLocalDockerImages())
|
||||
ipcMain.handle('local-workspace:docker:status', () => checkDockerStatus())
|
||||
ipcMain.handle('local-workspace:docker:guide', () => shell.openExternal('https://docs.docker.com/engine/install/'))
|
||||
ipcMain.handle('local-workspace:ros-graph', () => collectLocalRosGraph())
|
||||
ipcMain.handle('local-workspace:modules:install', (_event, moduleName: string, workspaceRoot?: string | null, folderName?: string | null, templateRepositoryUrl?: string | null, studentRepositoryUrl?: string | null, options?: ModuleInstallOptions | null) => installModuleFromCache(moduleName, workspaceRoot, folderName, templateRepositoryUrl, studentRepositoryUrl, options))
|
||||
ipcMain.handle('local-workspace:modules:create-custom', (_event, workspaceRoot?: string | null, input?: CustomModuleScaffoldInput | null) => {
|
||||
@@ -6258,6 +6303,9 @@ function registerLocalWorkspaceHandlers(): void {
|
||||
ipcMain.handle('remote-workspace:maps:copy-active-rtabmap-database', (_event, target: RemoteWorkspaceTarget, localWorkspaceRoot: string | null | undefined, destinationPath: string) => (
|
||||
copyRemoteActiveRtabmapDatabaseToLocal(target, localWorkspaceRoot, destinationPath)
|
||||
))
|
||||
ipcMain.handle('remote-workspace:maps:upload-rtabmap-database', (_event, target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => (
|
||||
uploadRemoteRtabmapDatabase(target, uploadUrl, databasePath)
|
||||
))
|
||||
ipcMain.handle('remote-workspace:maps:sync', (_event, target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => (
|
||||
syncRemoteSavedMapArtifacts(target, artifacts)
|
||||
))
|
||||
|
||||
@@ -182,6 +182,10 @@ contextBridge.exposeInMainWorld('visualization', {
|
||||
stop: (environmentProfile?: unknown) => ipcRenderer.invoke('visualization:stop', environmentProfile)
|
||||
})
|
||||
contextBridge.exposeInMainWorld('localWorkspace', {
|
||||
docker: {
|
||||
status: () => ipcRenderer.invoke('local-workspace:docker:status'),
|
||||
openGuide: () => ipcRenderer.invoke('local-workspace:docker:guide'),
|
||||
},
|
||||
moduleFiles: {
|
||||
read: (moduleName: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:read', moduleName, workspaceRoot, folderName),
|
||||
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => ipcRenderer.invoke('local-workspace:module-files:image', moduleName, path, workspaceRoot, folderName),
|
||||
@@ -275,6 +279,7 @@ contextBridge.exposeInMainWorld('remoteWorkspace', {
|
||||
maps: {
|
||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => ipcRenderer.invoke('remote-workspace:maps:read-artifacts', target, artifacts),
|
||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => ipcRenderer.invoke('remote-workspace:maps:copy-active-rtabmap-database', target, localWorkspaceRoot, destinationPath),
|
||||
uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => ipcRenderer.invoke('remote-workspace:maps:upload-rtabmap-database', target, uploadUrl, databasePath),
|
||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => ipcRenderer.invoke('remote-workspace:maps:sync', target, artifacts)
|
||||
},
|
||||
modules: {
|
||||
|
||||
+35
-13
@@ -6,6 +6,7 @@ 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 DockerSetupNotice from './components/DockerSetupNotice'
|
||||
import PlatformLoadingOverlay from './components/PlatformLoadingOverlay'
|
||||
import { ControlExtensionConfirmModal, ModuleShutdownConfirmModal, RunningPlatformCloseModal, SessionConfirmModal, StatusNoticeModal, WeekSwitchBlockedModal } from './components/AppStatusModals'
|
||||
import TitleBar from './components/TitleBar'
|
||||
@@ -1220,6 +1221,7 @@ function LocalPcWorkspaceMockup({
|
||||
useEffect(() => {
|
||||
void loadSavedSlamMaps()
|
||||
}, [loadSavedSlamMaps])
|
||||
const mapSaveInProgressRef = useRef(false)
|
||||
useEffect(() => {
|
||||
const handleViewerMessage = (event: MessageEvent) => {
|
||||
const payload = event.data as {
|
||||
@@ -1290,25 +1292,41 @@ function LocalPcWorkspaceMockup({
|
||||
const savedMapDatabasePath = payload.map?.databasePath ?? null
|
||||
const savedMapPreviewPgm = payload.map?.previewPgm ?? null
|
||||
if (payload.type !== 'map-saved' || typeof yamlPath !== 'string' || typeof pgmPath !== 'string') return
|
||||
if (mapSaveInProgressRef.current) {
|
||||
onTitlebarNotice('이전 맵을 저장하고 있습니다. 완료 후 다시 저장해 주세요.', 'info')
|
||||
return
|
||||
}
|
||||
mapSaveInProgressRef.current = true
|
||||
void (async () => {
|
||||
try {
|
||||
let databasePath = savedMapDatabasePath
|
||||
const savesThreeDimensionalMap = remoteWorkspace
|
||||
&& visualizationBridgeLocation === 'local'
|
||||
&& selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||
if (savesThreeDimensionalMap && !databasePath) {
|
||||
const databaseFileName = yamlPath.replace(/\.yaml$/i, '.db')
|
||||
const copied = await window.remoteWorkspace.maps.copyActiveRtabmapDatabaseToLocal(
|
||||
getRemoteWorkspaceTarget(),
|
||||
localVisualizationWorkspaceRoot,
|
||||
databaseFileName,
|
||||
let databaseUploadId: string | null = null
|
||||
const remoteDatabase = remoteWorkspace && (
|
||||
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|
||||
|| (visualizationBridgeLocation === 'platform' && Boolean(databasePath))
|
||||
)
|
||||
databasePath = copied.databasePath
|
||||
if (remoteDatabase) {
|
||||
databasePath = databasePath || yamlPath.replace(/\.yaml$/i, '.db')
|
||||
onTitlebarNotice('플랫폼의 3D 맵을 업로드하고 있습니다.', 'info')
|
||||
const prepared = await fetch(`${LMS_API_BASE_URL}/studio/maps/database-upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${await getFreshAccessToken()}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ courseId: courseKey, week: weekNumber, databasePath }),
|
||||
})
|
||||
if (!prepared.ok) throw new Error(`database_upload_prepare_failed_${prepared.status}`)
|
||||
const upload = await prepared.json() as { uploadUrl: string; databaseUploadId: string }
|
||||
if (!upload.uploadUrl || !upload.databaseUploadId) throw new Error('database_upload_response_invalid')
|
||||
await window.remoteWorkspace.maps.uploadRtabmapDatabase(
|
||||
getRemoteWorkspaceTarget(),
|
||||
upload.uploadUrl,
|
||||
visualizationBridgeLocation === 'platform' ? savedMapDatabasePath : null,
|
||||
)
|
||||
databaseUploadId = upload.databaseUploadId
|
||||
}
|
||||
const sourceArtifacts = [
|
||||
{ name: 'yaml' as const, path: yamlPath },
|
||||
{ name: 'pgm' as const, path: pgmPath },
|
||||
...(databasePath ? [{ name: 'database' as const, path: databasePath }] : []),
|
||||
...(databasePath && !databaseUploadId ? [{ name: 'database' as const, path: databasePath }] : []),
|
||||
]
|
||||
// A remote ROS session can still use a bridge running on this PC.
|
||||
// Read saved artifacts from the bridge host, not merely from the
|
||||
@@ -1331,6 +1349,7 @@ function LocalPcWorkspaceMockup({
|
||||
form.set('pgmPath', pgmPath)
|
||||
if (savedMapPreviewPgm) form.set('previewPgm', savedMapPreviewPgm)
|
||||
if (databasePath) form.set('databasePath', databasePath)
|
||||
if (databaseUploadId) form.set('databaseUploadId', databaseUploadId)
|
||||
artifacts.forEach((artifact) => {
|
||||
const binary = atob(artifact.base64)
|
||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0))
|
||||
@@ -1352,6 +1371,8 @@ function LocalPcWorkspaceMockup({
|
||||
} catch (error) {
|
||||
console.warn('[studio] saved SLAM map record failed', error)
|
||||
onTitlebarNotice('맵 저장 정보 등록에 실패했습니다.', 'error')
|
||||
} finally {
|
||||
mapSaveInProgressRef.current = false
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -2365,7 +2386,7 @@ function LocalPcWorkspaceMockup({
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!selectedModule) return
|
||||
if (!selectedModule || (activeIdeActivity !== 'workspace' && activeIdeActivity !== 'search')) return
|
||||
|
||||
const refreshOnFocus = (): void => {
|
||||
void loadModuleFiles(selectedModule)
|
||||
@@ -2373,7 +2394,7 @@ function LocalPcWorkspaceMockup({
|
||||
|
||||
window.addEventListener('focus', refreshOnFocus)
|
||||
return () => window.removeEventListener('focus', refreshOnFocus)
|
||||
}, [selectedModule, clonePathPreview])
|
||||
}, [selectedModule, clonePathPreview, activeIdeActivity, remoteWorkspace])
|
||||
const updateSourceSetting = <Key extends keyof StudioSourceSettings>(key: Key, value: StudioSourceSettings[Key]): void => {
|
||||
setSourceSettingsDraft((settings) => ({ ...settings, [key]: value }))
|
||||
}
|
||||
@@ -5778,6 +5799,7 @@ export default function App(): React.JSX.Element {
|
||||
|
||||
return (
|
||||
<div ref={appRootRef} className="relative h-screen w-screen overflow-hidden bg-[#0a0b0e]">
|
||||
<DockerSetupNotice />
|
||||
<div className="flex h-full w-full min-w-[1180px] flex-col overflow-hidden bg-[#0a0b0e]">
|
||||
<TitleBar
|
||||
user={authSession?.user ?? null}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Check, Copy, ExternalLink, RefreshCw, X } from 'lucide-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type DockerResult = Awaited<ReturnType<Window['localWorkspace']['docker']['status']>>
|
||||
|
||||
const messages = {
|
||||
missing: 'Docker 명령을 찾을 수 없습니다.',
|
||||
permission: '현재 사용자의 Docker 접근 권한을 확인해주세요.',
|
||||
unavailable: 'Docker 데몬에 연결할 수 없습니다.',
|
||||
error: 'Docker 상태를 확인하지 못했습니다.',
|
||||
ready: 'Docker를 사용할 수 있습니다.',
|
||||
}
|
||||
|
||||
export default function DockerSetupNotice(): React.JSX.Element | null {
|
||||
const [result, setResult] = useState<DockerResult | null>(null)
|
||||
const [dismissed, setDismissed] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [actionError, setActionError] = useState('')
|
||||
const dialogRef = useRef<HTMLDialogElement>(null)
|
||||
const retryPending = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
const docker = window.localWorkspace?.docker
|
||||
if (!docker) return
|
||||
let active = true
|
||||
void docker.status().then((value) => {
|
||||
if (active) setResult(value)
|
||||
}).catch(() => {
|
||||
if (active) setResult({ status: 'error', platform: '' })
|
||||
})
|
||||
return () => { active = false }
|
||||
}, [])
|
||||
|
||||
const visible = Boolean(result && result.status !== 'ready' && !dismissed)
|
||||
useEffect(() => {
|
||||
if (visible) dialogRef.current?.showModal()
|
||||
}, [visible])
|
||||
|
||||
useEffect(() => {
|
||||
if (!copied) return
|
||||
const timer = window.setTimeout(() => setCopied(false), 1500)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [copied])
|
||||
|
||||
if (!visible || !result) return null
|
||||
const linux = result.platform === 'linux'
|
||||
const command = linux && result.status === 'permission'
|
||||
? 'sudo groupadd -f docker\nsudo usermod -aG docker "$(id -un)"'
|
||||
: linux && result.status === 'unavailable'
|
||||
? 'sudo systemctl start docker\ndocker info'
|
||||
: 'docker --version\ndocker context show\ndocker info'
|
||||
|
||||
async function retry(): Promise<void> {
|
||||
if (retryPending.current) return
|
||||
retryPending.current = true
|
||||
setBusy(true)
|
||||
setActionError('')
|
||||
setCopied(false)
|
||||
try {
|
||||
setResult(await window.localWorkspace.docker.status())
|
||||
} catch {
|
||||
setResult({ status: 'error', platform: result?.platform ?? '' })
|
||||
} finally {
|
||||
retryPending.current = false
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<dialog ref={dialogRef} aria-labelledby="docker-setup-title" onCancel={() => setDismissed(true)}
|
||||
className="fixed inset-0 m-auto max-h-[calc(100dvh-32px)] w-[520px] max-w-[calc(100vw-32px)] overflow-y-auto rounded-lg border border-white/10 bg-[#252525] p-0 text-white shadow-2xl backdrop:bg-black/60">
|
||||
<header className="flex items-center justify-between border-b border-white/10 px-5 py-3">
|
||||
<h2 id="docker-setup-title" className="text-[14px] font-bold">로컬 Docker 설정</h2>
|
||||
<button type="button" autoFocus title="나중에" aria-label="나중에" onClick={() => setDismissed(true)} className="flex h-7 w-7 cursor-pointer items-center justify-center rounded hover:bg-white/10"><X size={16} /></button>
|
||||
</header>
|
||||
<div className="space-y-4 px-5 py-4 text-[13px] leading-relaxed">
|
||||
<p className="font-semibold">{messages[result.status]}</p>
|
||||
<p className="text-white/65">Docker 없이도 LMS와 원격 플랫폼을 사용할 수 있습니다. 로컬 모듈 빌드와 실행에는 Docker가 필요합니다.</p>
|
||||
{result.status === 'missing' ? (
|
||||
<p>공식 설치 안내에서 운영체제를 선택해 Docker를 설치한 뒤 다시 확인해주세요. Ubuntu에서는 Docker Engine의 apt 저장소 설치 방법을 사용할 수 있습니다.</p>
|
||||
) : <>
|
||||
{linux && result.status === 'permission' && <p>일반 Docker Engine 사용 시 아래 명령으로 권한을 추가한 뒤 OS에서 로그아웃하고 다시 로그인해주세요. Docker 그룹은 관리자 수준의 권한을 부여합니다. Rootless Docker나 별도 Docker 컨텍스트를 사용한다면 연결 설정부터 확인해주세요.</p>}
|
||||
{linux && result.status === 'unavailable' && <p>일반 Docker Engine은 아래 명령으로 서비스를 시작할 수 있습니다. Docker Desktop이나 Rootless Docker 사용 시 해당 서비스를 실행하고 Docker 컨텍스트를 확인해주세요.</p>}
|
||||
<div className="relative rounded-md bg-black/25 p-3 pr-12">
|
||||
<pre className="whitespace-pre-wrap break-all text-[12px]">{command}</pre>
|
||||
<button type="button" title={copied ? '복사됨' : '명령어 복사'} aria-label={copied ? '복사됨' : '명령어 복사'}
|
||||
className="absolute right-2 top-2 flex h-8 w-8 cursor-pointer items-center justify-center rounded hover:bg-white/10"
|
||||
onClick={() => { void navigator.clipboard.writeText(command).then(() => { setCopied(true); setActionError('') }).catch(() => setActionError('복사하지 못했습니다. 명령어를 직접 선택해 복사해주세요.')) }}>
|
||||
{copied ? <Check size={16} /> : <Copy size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</>}
|
||||
<button type="button" className="inline-flex cursor-pointer items-center gap-2 text-[#9dbdff] hover:underline"
|
||||
onClick={() => { void window.localWorkspace.docker.openGuide().catch(() => setActionError('설치 안내: https://docs.docker.com/engine/install/')) }}>
|
||||
<ExternalLink size={14} />Docker 공식 설치 안내
|
||||
</button>
|
||||
{actionError && <p role="status" className="break-words text-[#f2b86b]">{actionError}</p>}
|
||||
</div>
|
||||
<footer className="flex justify-end gap-2 border-t border-white/10 px-5 py-3">
|
||||
<button type="button" onClick={() => setDismissed(true)} className="h-8 cursor-pointer rounded-md border border-white/20 px-3 text-[12px] hover:bg-white/10">나중에</button>
|
||||
<button type="button" disabled={busy} onClick={() => void retry()} className="flex h-8 cursor-pointer items-center gap-2 rounded-md bg-[#2563eb] px-3 text-[12px] disabled:cursor-wait disabled:opacity-50 hover:bg-[#1d4ed8]">
|
||||
<RefreshCw size={14} className={busy ? 'animate-spin' : ''} />{busy ? '확인 중' : '다시 확인'}
|
||||
</button>
|
||||
</footer>
|
||||
</dialog>, document.body,
|
||||
)
|
||||
}
|
||||
@@ -187,7 +187,7 @@ export default function Sidebar({
|
||||
</button>
|
||||
)}
|
||||
{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="absolute bottom-0 left-[62px] z-50 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) => (
|
||||
<div key={session.id} className="relative border-b border-white/10 last:border-b-0">
|
||||
|
||||
Vendored
+5
@@ -257,6 +257,10 @@ interface Window {
|
||||
}>
|
||||
}
|
||||
localWorkspace: {
|
||||
docker: {
|
||||
status: () => Promise<{ status: 'ready' | 'missing' | 'permission' | 'unavailable' | 'error'; platform: string }>
|
||||
openGuide: () => Promise<void>
|
||||
}
|
||||
moduleFiles: Window['moduleFiles']
|
||||
modules: Window['modules']
|
||||
rosGraph: {
|
||||
@@ -302,6 +306,7 @@ interface Window {
|
||||
maps: {
|
||||
readArtifacts: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactPath[]) => Promise<Array<{ name: SavedMapArtifactName; fileName: string; base64: string }>>
|
||||
copyActiveRtabmapDatabaseToLocal: (target: RemoteWorkspaceTarget, localWorkspaceRoot: string | undefined, destinationPath: string) => Promise<{ databasePath: string }>
|
||||
uploadRtabmapDatabase: (target: RemoteWorkspaceTarget, uploadUrl: string, databasePath?: string | null) => Promise<{ ok: boolean }>
|
||||
sync: (target: RemoteWorkspaceTarget, artifacts: SavedMapArtifactDownload[]) => Promise<{ ok: boolean }>
|
||||
}
|
||||
modules: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Dispatch, SetStateAction } from 'react'
|
||||
import { useRef, type Dispatch, type SetStateAction } from 'react'
|
||||
import type { ModuleRow } from './types'
|
||||
import type { EditorFilePath } from './workspaceFiles'
|
||||
import type { NewFileDraftState, TreeClipboardState, TreeContextMenuState } from './useWorkspaceTreeState'
|
||||
@@ -114,11 +114,28 @@ export function useWorkspaceFileActions({
|
||||
selectedGitTarget,
|
||||
refreshGitStatuses
|
||||
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
|
||||
const pendingFileReads = useRef(new Map<string, Promise<void>>())
|
||||
|
||||
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
|
||||
const target = remoteWorkspace ? getRemoteWorkspaceTarget() : null
|
||||
const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName])
|
||||
const pending = pendingFileReads.current.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
const request = readModuleFiles(module, target)
|
||||
pendingFileReads.current.set(key, request)
|
||||
try {
|
||||
await request
|
||||
} finally {
|
||||
pendingFileReads.current.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null): Promise<void> => {
|
||||
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
|
||||
try {
|
||||
const result = remoteWorkspace
|
||||
? await window.remoteWorkspace.moduleFiles.read(getRemoteWorkspaceTarget(), module.name, module.folderName)
|
||||
const result = target
|
||||
? await window.remoteWorkspace.moduleFiles.read(target, 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 : []
|
||||
|
||||
Reference in New Issue
Block a user