feat(lms): add Docker setup guidance and improve remote workspace handling

This commit is contained in:
2026-09-09 16:59:47 +09:00
parent 104c083995
commit 54ef5836b1
10 changed files with 273 additions and 21 deletions
+19
View File
@@ -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')
})
+27
View File
@@ -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
View File
@@ -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)
))
+5
View File
@@ -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: {
+34 -12
View File
@@ -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(
let databaseUploadId: string | null = null
const remoteDatabase = remoteWorkspace && (
selectedWorldMap.runtimeKind === 'PLATFORM_3D'
|| (visualizationBridgeLocation === 'platform' && Boolean(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(),
localVisualizationWorkspaceRoot,
databaseFileName,
upload.uploadUrl,
visualizationBridgeLocation === 'platform' ? savedMapDatabasePath : null,
)
databasePath = copied.databasePath
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,
)
}
+1 -1
View File
@@ -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">
+5
View File
@@ -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 : []