feat: add weekly curriculum cover image upload and display

This commit is contained in:
2026-09-23 16:03:33 +09:00
parent a7c9cdc16e
commit 656b1bf62b
23 changed files with 723 additions and 95 deletions
+11
View File
@@ -0,0 +1,11 @@
const image = 'future-nav2-camera-test:humble-jetson_orin'
export function cameraRuntimeArtifacts<T extends { image?: string | null }>(
folder: string,
configured: T[] | null | undefined,
): T[] | null | undefined {
if (!configured) return configured
const camera = folder.includes('lms-nav2-camera-test')
// The optional camera image must not be downloaded for other Nav2 modules.
return configured.filter((item) => (item.image?.trim() === image) === camera)
}
+43 -41
View File
@@ -1,5 +1,7 @@
import { app, BrowserWindow, dialog, Menu, globalShortcut, ipcMain, screen, shell } from 'electron'
import { checkDockerStatus } from './dockerStatus'
import { cameraRuntimeArtifacts } from './cameraRuntime'
import { remoteModuleFilesScript } from './remoteModuleFiles'
import { resolveContainerTerminal } from './containerTerminal'
import { yahboomDevicePreflight, yahboomModuleCases, yahboomRuntimeImage, yahboomRuntimeArtifacts, yahboomSimulation } from './yahboomRuntime'
import { visualizationProgress, reportVisualizationProgress } from './visualizationProgress'
@@ -142,6 +144,7 @@ function getBundledZstdPath(): string | null {
}
type CachedModuleFiles = {
filePaths?: string[]
root: string | null
absoluteRoot: string | null
files: Record<string, string>
@@ -4598,6 +4601,7 @@ function buildNav2DockerRunScript(): string {
'else',
'case "$MODULE_FOLDER" in',
...yahboomModuleCases(),
' *lms-nav2-camera-test*) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py ;;',
' *lms-nav2-mkmini*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
' *lms-nav2-gazebo-simulator*) PACKAGE=nav2_turtlebot_simulator; LAUNCH=simulator.launch.py ;;',
' *lms-nav2-gazebo-vehicle*) PACKAGE=vehicle_pkg; LAUNCH=vehicle.launch.py ;;',
@@ -4635,6 +4639,12 @@ function buildNav2DockerRunScript(): string {
'',
'test -f "$MODULE_ROOT/$MODULE_FOLDER/install/setup.bash" || { echo "Module is not built: $MODULE_FOLDER" >&2; exit 1; }',
...yahboomDevicePreflight(),
'CAMERA_DEVICE=""',
'if [[ "$MODULE_FOLDER" == *lms-nav2-camera-test* ]]; then',
' CAMERA_DEVICE=/dev/video0',
' for arg in "$@"; do case "$arg" in video_device:=*) CAMERA_DEVICE=${arg#video_device:=} ;; esac; done',
' [[ "$CAMERA_DEVICE" =~ ^/dev/video[0-9]+$ ]] && [ -c "$CAMERA_DEVICE" ] || { echo "Camera device not found: $CAMERA_DEVICE" >&2; exit 1; }',
'fi',
'docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true',
'',
'args=(',
@@ -4652,6 +4662,12 @@ function buildNav2DockerRunScript(): string {
' -v /etc/localtime:/etc/localtime:ro',
')',
'if [ -n "$YAHBOOM_DEVICE" ]; then args+=(--device "$YAHBOOM_DEVICE:$YAHBOOM_DEVICE"); fi',
'if [ -n "$CAMERA_DEVICE" ]; then',
' args+=(--device "$CAMERA_DEVICE:$CAMERA_DEVICE")',
' for capture_device in /dev/capture-vi-channel*; do',
' if [ -c "$capture_device" ]; then args+=(--device "$capture_device:$capture_device"); fi',
' done',
'fi',
'if [[ "$GPU_ENABLED" == "true" || "$GPU_ENABLED" == "1" ]]; then',
' args+=(--gpus all -e NVIDIA_VISIBLE_DEVICES=all -e NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics)',
'fi',
@@ -4695,6 +4711,7 @@ function buildNav2DockerBuildScript(): string {
'else',
'case "$MODULE_FOLDER" in',
...yahboomModuleCases(true),
' *lms-nav2-camera-test*) TARGET=camera_test_pkg ;;',
' *lms-nav2-mkmini*) TARGET=vehicle_pkg ;;',
' *lms-nav2-gazebo-simulator*) TARGET=nav2_turtlebot_simulator ;;',
' *lms-nav2-gazebo-vehicle*) TARGET=vehicle_pkg ;;',
@@ -5051,6 +5068,7 @@ async function resolveLocalModuleRuntime(
rosParams: string,
runtimeImages?: RuntimeImageArtifact[] | null,
): Promise<{ image: string; artifacts: RuntimeImageArtifact[] | null | undefined }> {
runtimeImages = cameraRuntimeArtifacts(folderName, runtimeImages)
if (yahboomSimulation(folderName, rosParams)) {
const artifact = runtimeImages?.find((item) =>
item.image?.trim() === defaultNav2GazeboRuntimeImage.image && item.url?.trim())
@@ -5062,7 +5080,9 @@ async function resolveLocalModuleRuntime(
)
return {
image,
artifacts: yahboomRuntimeImage(folderName)
artifacts: folderName.includes('lms-nav2-camera-test')
? (runtimeImages ?? []).filter((item) => item.image?.trim() === image)
: yahboomRuntimeImage(folderName)
? yahboomRuntimeArtifacts(folderName, image, runtimeImages)
: requiredRuntimeImages(runtimeImages, image),
}
@@ -5309,6 +5329,7 @@ async function startRemoteWorkspaceModule(
if (existsOutput.trim() !== 'yes') throw new Error('설치된 모듈을 찾을 수 없습니다.')
const nav2Module = isNav2ModuleFolder(targetFolderName)
runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages)
const runtimeImage = nav2Module
? await resolveRemoteCustomModuleRuntimeImage(
target,
@@ -5320,7 +5341,9 @@ async function startRemoteWorkspaceModule(
? await ensureRemoteNav2RuntimeAssets(target)
: await ensureRemoteStudioRuntimeAssets(target)
const fastDdsConfigPath = await prepareRemoteRosNetworkConfig(target, runtimeRoot, rosNetwork, rosDomainId)
await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 실행 중...")
const currentStatus = await runSshOutput(target, `timeout 12s docker ps -a --filter ${quoteShellSingle(`name=^/${dockerInfo.containerKey}$`)} --format "{{.Status}}" 2>/dev/null || true`, 20000)
if (/^Up\b/i.test(currentStatus.trim())) return { ok: true, message: '이미 실행 중입니다.' }
@@ -5403,6 +5426,7 @@ async function buildRemoteWorkspaceModule(
): Promise<{ ok: boolean; message: string }> {
const target = normalizeRemoteTarget(targetInput)
const targetFolderName = folderName?.trim() || moduleName
runtimeImages = cameraRuntimeArtifacts(targetFolderName, runtimeImages)
const dockerInfo = parseModuleDockerInfo(targetFolderName)
if (!dockerInfo || !isAllowedContainerName(dockerInfo.containerKey)) throw new Error('모듈 폴더명 형식이 올바르지 않습니다.')
@@ -5413,7 +5437,9 @@ async function buildRemoteWorkspaceModule(
targetFolderName,
yahboomRuntimeImage(targetFolderName) ?? await resolveRemoteNav2RuntimeImage(runtimeImages),
)
await prepareRemoteRuntimeImages(target, yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
await prepareRemoteRuntimeImages(target, targetFolderName.includes('lms-nav2-camera-test')
? (runtimeImages ?? []).filter((item) => item.image?.trim() === runtimeImage)
: yahboomRuntimeArtifacts(targetFolderName, runtimeImage, runtimeImages))
reportModuleProgress("모듈 빌드 중...")
await runSsh(target, [
'set -e',
@@ -5723,44 +5749,11 @@ async function collectModuleFiles(moduleName: string, workspaceRoot?: string | n
async function collectRemoteModuleFiles(targetInput: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null): Promise<CachedModuleFiles> {
const { target, moduleRoot } = await resolveRemoteGitModuleRoot(targetInput, moduleName, folderName)
const skippedNames = Array.from(skippedCacheDirs).map(quoteShellSingle).join(' ')
const script = [
'set +e',
`ROOT=${quoteShellSingle(moduleRoot)}`,
`SKIPPED="${skippedNames}"`,
'find "$ROOT" -mindepth 1 -maxdepth 10 \\( -name .git -o -name build -o -name install -o -name log -o -name node_modules -o -name docker \\) -prune -o -type d -printf "D\\t%P\\n" -o -type f -printf "F\\t%P\\t%s\\n" | while IFS="$(printf \'\\t\')" read -r KIND REL SIZE; do',
' [ -z "$REL" ] && continue',
' NAME="${REL##*/}"',
' case "$KIND" in',
' D) printf "D\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" ;;',
' F)',
' CONTENT="QmluYXJ5IG9yIGxhcmdlIGZpbGUgcHJldmlldyBpcyBub3QgYXZhaWxhYmxlLg=="',
' if [ "${SIZE:-0}" -le 1048576 ]; then',
' case "$NAME" in',
' *.c|*.cc|*.cpp|*.cxx|*.h|*.hpp|*.hh|*.py|*.xml|*.urdf|*.xacro|*.yaml|*.yml|*.json|*.txt|*.md|*.cmake|*.launch|*.sh|*.bash|*.toml|*.ini|*.cfg|*.rviz) CONTENT="$(base64 -w0 "$ROOT/$REL" 2>/dev/null)" ;;',
' esac',
' fi',
' printf "F\\t%s\\t%s\\n" "$(printf %s "$REL" | base64 -w0)" "$CONTENT"',
' ;;',
' esac',
'done'
].join('\n')
// 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[] = []
for (const line of output.split('\n')) {
if (!line.trim()) continue
const [kind, encodedPath = '', encodedContent = ''] = line.split('\t')
const relativePath = decodeRemoteField(encodedPath)
if (!relativePath) continue
const rendererPath = `modules/${moduleName}/${relativePath}`
if (kind === 'D') folders.push(rendererPath)
if (kind === 'F') files[rendererPath] = decodeRemoteField(encodedContent)
}
return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot, files, folders }
const output = await runUnqueuedSshOutput(target, remoteModuleFilesScript(moduleRoot, { action: 'list' }), 30000)
const listing = JSON.parse(output) as { filePaths: string[]; folders: string[] }
const prefix = (path: string): string => `modules/${moduleName}/${path}`
return { root: moduleRoot.split('/').pop() ?? moduleName, absoluteRoot: moduleRoot,
files: {}, filePaths: listing.filePaths.map(prefix), folders: listing.folders.map(prefix) }
}
async function collectRootFiles(rootPath: string, rendererRoot: string): Promise<CachedModuleFiles> {
@@ -6447,6 +6440,15 @@ function registerLocalWorkspaceHandlers(): void {
ipcMain.handle('remote-workspace:git:submissions', (_event, target: RemoteWorkspaceTarget, modules: Array<{ moduleName: string; folderName?: string | null }>) => readRemoteWorkspaceGitSubmissions(target, modules))
ipcMain.handle('remote-workspace:git:commit-diff', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null, commitHash?: string | null) => readRemoteWorkspaceGitCommitDiff(target, moduleName, folderName, commitHash))
ipcMain.handle('remote-workspace:module-files:read', (_event, target: RemoteWorkspaceTarget, moduleName: string, folderName?: string | null) => collectRemoteModuleFiles(target, moduleName, folderName))
ipcMain.handle('remote-workspace:module-files:content', async (_event, target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string | null) => {
const resolved = await resolveRemoteRendererModulePath(target, moduleName, path, folderName)
return JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'content', path: resolved.relativePath }), 30000))
})
ipcMain.handle('remote-workspace:module-files:search', async (_event, target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string | null) => {
const resolved = await resolveRemoteGitModuleRoot(target, moduleName, folderName)
const results = JSON.parse(await runUnqueuedSshOutput(resolved.target, remoteModuleFilesScript(resolved.moduleRoot, { action: 'search', query }), 30000)) as Array<{ file: string; lineNumber: number; lineText: string }>
return results.map((result) => ({ ...result, file: `modules/${moduleName}/${result.file}` }))
})
ipcMain.handle('remote-workspace:module-files:image', (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, folderName?: string | null) => readRemoteWorkspaceModuleImage(target, moduleName, rendererPath, folderName))
ipcMain.handle('remote-workspace:module-files:create', async (_event, target: RemoteWorkspaceTarget, moduleName: string, rendererPath: string, kind: 'file' | 'folder', folderName?: string | null) => {
const resolved = await resolveRemoteRendererModulePath(target, moduleName, rendererPath, folderName)
+69
View File
@@ -0,0 +1,69 @@
type Request = { action: 'list' } | { action: 'content'; path: string } | { action: 'search'; query: string }
export function remoteModuleFilesScript(root: string, request: Request): string {
const input = Buffer.from(JSON.stringify({ root, ...request })).toString('base64')
return `python3 - '${input}' <<'PY'
import base64, json, os, stat, sys
args = json.loads(base64.b64decode(sys.argv[1]))
root = os.path.realpath(args['root'])
skip = {'.git', 'build', 'install', 'log', 'node_modules', 'docker'}
extensions = {'.c', '.cc', '.cpp', '.cxx', '.h', '.hpp', '.hh', '.py', '.xml', '.urdf', '.xacro', '.yaml', '.yml', '.json', '.txt', '.md', '.cmake', '.launch', '.sh', '.bash', '.toml', '.ini', '.cfg', '.rviz'}
placeholder = 'Binary or large file preview is not available.'
def content(relative):
if os.path.isabs(relative) or '..' in relative.split('/'):
raise ValueError('Invalid module path')
path = os.path.realpath(os.path.join(root, relative))
if os.path.commonpath([root, path]) != root:
raise ValueError('Invalid module path')
info = os.stat(path)
if not stat.S_ISREG(info.st_mode):
raise ValueError('Not a regular file')
if os.path.splitext(path)[1].lower() not in extensions or info.st_size > 1048576:
return placeholder
with open(path, 'rb') as fp:
data = fp.read(1048577)
if len(data) > 1048576 or b'\\x00' in data:
return placeholder
return data.decode('utf-8', errors='replace')
def entries():
def fail(error):
raise error
for current, dirs, files in os.walk(root, onerror=fail, followlinks=False):
depth = len(os.path.relpath(current, root).split('/')) if current != root else 0
dirs[:] = sorted(d for d in dirs if d not in skip and not os.path.islink(os.path.join(current, d)))
if depth >= 10:
dirs[:] = []
continue
for name in dirs:
yield 'D', os.path.relpath(os.path.join(current, name), root)
for name in sorted(files):
path = os.path.join(current, name)
if not os.path.islink(path) and os.path.isfile(path):
yield 'F', os.path.relpath(path, root)
if args['action'] == 'content':
print(json.dumps({'content': content(args['path'])}))
elif args['action'] == 'list':
paths, folders = [], []
for kind, path in entries():
(folders if kind == 'D' else paths).append(path)
print(json.dumps({'filePaths': paths, 'folders': folders}))
else:
query = args['query'].strip().lower()
results = []
if len(query) >= 2:
for kind, path in entries():
if kind != 'F':
continue
text = content(path)
if text == placeholder:
continue
for number, line in enumerate(text.split('\\n'), 1):
if query in line.lower():
results.append({'file': path, 'lineNumber': number, 'lineText': (line.strip() or line)[:2000]})
if len(results) >= 200:
break
if len(results) >= 200:
break
print(json.dumps(results))
PY`
}
+2
View File
@@ -289,6 +289,8 @@ contextBridge.exposeInMainWorld('localWorkspace', {
contextBridge.exposeInMainWorld('remoteWorkspace', {
moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:read', target, moduleName, folderName),
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:content', target, moduleName, path, folderName),
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:search', target, moduleName, query, folderName),
image: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:image', target, moduleName, path, folderName),
create: (target: RemoteWorkspaceTarget, moduleName: string, path: string, kind: 'file' | 'folder', folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:create', target, moduleName, path, kind, folderName),
write: (target: RemoteWorkspaceTarget, moduleName: string, path: string, content: string, folderName?: string) => ipcRenderer.invoke('remote-workspace:module-files:write', target, moduleName, path, content, folderName),
+69 -20
View File
@@ -49,7 +49,7 @@
#cameraPreviewWrap {
position:absolute;
left:16px;
top:48px;
top:16px;
z-index:10;
display:none;
width:min(260px, calc(100vw - 32px));
@@ -69,7 +69,7 @@
#cameraPreviewWrap.is-collapsed #cameraPreview { display:none; }
#cameraPreviewToggle {
position:absolute;
right:0;
left:0;
top:100%;
display:grid;
width:30px;
@@ -110,6 +110,8 @@
bottom:16px;
z-index:10;
padding:7px 9px;
max-width:calc(100% - 32px);
box-sizing:border-box;
border:1px solid rgba(255,255,255,.24);
background:rgba(17,24,39,.34);
color:rgba(255,255,255,.82);
@@ -369,8 +371,8 @@
.nav2-navigation #rosTopicStatusWrap { bottom:16px; }
.real-runtime #topLeftControls { display:none; }
.real-runtime #keyGuide { display:none; }
.real-yahboom-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-yahboom-teleop #rosTopicStatusWrap { bottom:72px; }
.real-teleop:not(.editor-mode) #keyGuide { display:block; }
.real-teleop #rosTopicStatusWrap { bottom:50px; }
</style>
</head>
<body>
@@ -444,7 +446,8 @@ const ROBOT_PLATFORM = (params.get('platform') || '').trim().toLowerCase();
// Keep the prior real-platform default when an older LMS URL has no platform.
const USE_MKMINI_MODEL = ROBOT_PLATFORM === 'mkmini' || (!ROBOT_PLATFORM && REAL_RUNTIME);
const USE_YAHBOOM_MODEL = ROBOT_PLATFORM === 'yahboom';
const REAL_YAHBOOM_TELEOP = REAL_RUNTIME && USE_YAHBOOM_MODEL;
const REAL_MKMINI_TELEOP = REAL_RUNTIME && USE_MKMINI_MODEL;
const REAL_TELEOP = REAL_RUNTIME && (USE_YAHBOOM_MODEL || USE_MKMINI_MODEL);
const SHOW_POINT_CLOUD = params.get('pointCloud') === '1';
const SHOW_SENSOR_POINT_CLOUD = REAL_RUNTIME && SHOW_POINT_CLOUD;
const SENSOR_POINT_CLOUD_MAX_AGE_MS = 3_000;
@@ -495,8 +498,11 @@ const rosTopicStatusList = document.getElementById('rosTopicStatusList');
if (EDITOR_MODE) document.body.classList.add('editor-mode');
if (!EDITOR_MODE) document.body.classList.add(`nav2-${NAV2_VIEW_MODE}`);
if (REAL_RUNTIME) document.body.classList.add('real-runtime');
if (REAL_YAHBOOM_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-yahboom-teleop');
if (REAL_TELEOP && !EDITOR_MODE) {
document.body.classList.add('real-teleop');
if (REAL_MKMINI_TELEOP) {
document.querySelector('#keyGuide div').textContent = 'i 전진 · , 후진 · j/l 주행 중 조향 · k 정지';
}
}
if (EDITOR_MODE) responseWaitOverlay?.classList.add('is-hidden');
@@ -895,6 +901,7 @@ let savedMapPointCloudLoaded = false;
let sensorPointCloudRequestInFlight = false;
let sensorPointCloudRefreshTimer = null;
let cameraFrameRequestInFlight = false;
let cameraFrameRetryAt = 0;
let cameraFrameRefreshTimer = null;
let cameraFrameObjectUrl = null;
let pathVisible = false;
@@ -2051,28 +2058,47 @@ function setCameraPreviewAvailable(available) {
if (cameraPreview) cameraPreview.removeAttribute('src');
}
function hasLiveCameraState() {
return latestSlamState?.topic_status?.camera_image?.available === true
&& Date.now() - latestSlamStateReceivedAt < 3000;
}
async function refreshCameraFrame() {
if (!ENABLE_CAMERA_PREVIEW || cameraFrameRequestInFlight || document.hidden) return;
// Reuse websocket topic status instead of probing an absent camera with HTTP.
if (!hasLiveCameraState()) {
setCameraPreviewAvailable(false);
return;
}
if (Date.now() < cameraFrameRetryAt) return;
cameraFrameRequestInFlight = true;
try {
const response = await fetch(`${BACKEND_URL}/camera-frame`, { cache: 'no-store', signal: AbortSignal.timeout(2000) });
if (!response.ok) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
return;
}
const nextUrl = URL.createObjectURL(await response.blob());
const frame = await response.blob();
if (!hasLiveCameraState() || document.hidden) {
setCameraPreviewAvailable(false);
return;
}
cameraFrameRetryAt = 0;
const nextUrl = URL.createObjectURL(frame);
const previousUrl = cameraFrameObjectUrl;
cameraFrameObjectUrl = nextUrl;
if (cameraPreview) cameraPreview.src = nextUrl;
if (previousUrl) URL.revokeObjectURL(previousUrl);
} catch (_) {
cameraFrameRetryAt = Date.now() + 5000;
setCameraPreviewAvailable(false);
} finally {
cameraFrameRequestInFlight = false;
}
}
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(true));
cameraPreview?.addEventListener('load', () => setCameraPreviewAvailable(hasLiveCameraState()));
cameraPreview?.addEventListener('error', () => setCameraPreviewAvailable(false));
function drawMiniMap(state) {
@@ -3217,7 +3243,7 @@ function connectControlSocket() {
if (!CONTROL_WS) return;
controlSocket = new WebSocket(CONTROL_WS);
controlSocket.addEventListener('close', () => {
if (REAL_YAHBOOM_TELEOP) {
if (REAL_TELEOP) {
activeMoveKey = null;
stopRobot();
}
@@ -3228,8 +3254,16 @@ function connectControlSocket() {
});
}
let maxLinear = REAL_YAHBOOM_TELEOP ? 0.1 : 0.22;
let maxAngular = REAL_YAHBOOM_TELEOP ? 0.5 : 2.84;
let maxLinear = REAL_TELEOP ? 0.1 : 0.22;
let maxAngular = REAL_TELEOP ? 0.5 : 2.84;
const mkMiniHeldKeys = new Set();
const MKMINI_REAL_ANGULAR_SPEED = 0.08;
function teleopKey(event) {
const key = event.key.toLowerCase();
return REAL_MKMINI_TELEOP
? ({ arrowup: 'i', arrowdown: ',', arrowleft: 'j', arrowright: 'l', ' ': 'k' }[key] ?? key)
: key;
}
let activeMoveKey = null;
let teleopInterval = null;
let singleMoveStopTimer = null;
@@ -3253,7 +3287,7 @@ const speedBindings = {
};
function canUseTeleop(event) {
if (REAL_RUNTIME && (!REAL_YAHBOOM_TELEOP || !realTeleopReady())) return false;
if (EDITOR_MODE || (REAL_RUNTIME && (!REAL_TELEOP || !realTeleopReady()))) return false;
if (!document.hasFocus()) return false;
if (document.hidden || event.ctrlKey || event.altKey || event.metaKey || event.target?.isContentEditable) return false;
const tag = event.target?.tagName?.toLowerCase();
@@ -3292,7 +3326,7 @@ function flushQueuedCmdVel() {
function sendCmdVel(linear, angular) {
const payload = { type:'cmd_vel', linear, angular };
if (REAL_YAHBOOM_TELEOP) {
if (REAL_TELEOP) {
// Never queue physical motion for replay after a connection recovers.
if (!controlSocket || controlSocket.readyState !== WebSocket.OPEN) return;
if ((linear || angular) && !realTeleopReady()) return;
@@ -3346,6 +3380,7 @@ function setSimulatorMkMiniSteering(angle) {
}
function stopRobot() {
mkMiniHeldKeys.clear();
if (teleopInterval) {
window.clearInterval(teleopInterval);
teleopInterval = null;
@@ -3359,13 +3394,25 @@ function stopRobot() {
function sendActiveMoveCommand() {
if (!activeMoveKey) return;
if (REAL_YAHBOOM_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
if (REAL_TELEOP && (!realTeleopReady() || document.hidden || !document.hasFocus())) {
activeMoveKey = null;
stopRobot();
return;
}
const [linearSign, angularSign] = moveBindings[activeMoveKey];
if (linearSign === 0 && angularSign === 0) return;
if (REAL_MKMINI_TELEOP) {
let drive = 0;
let steering = 0;
for (const key of mkMiniHeldKeys) {
drive += moveBindings[key][0];
steering += moveBindings[key][1];
}
// The TOD adapter uses abs(linear) for steering: keep wheel direction
// consistent in reverse, and never turn a steering-only key into motion.
sendCmdVel(Math.sign(drive) * maxLinear, drive ? Math.sign(steering) * MKMINI_REAL_ANGULAR_SPEED : 0);
return;
}
if (USE_MKMINI_MODEL && !REAL_RUNTIME) {
if (activeMoveKey === 'j' || activeMoveKey === 'l') {
@@ -3389,6 +3436,7 @@ function sendActiveMoveCommand() {
function startActiveMoveCommand() {
sendActiveMoveCommand();
if (!activeMoveKey) return;
if (teleopInterval) return;
teleopInterval = window.setInterval(sendActiveMoveCommand, TELEOP_COMMAND_INTERVAL_MS);
}
@@ -3406,7 +3454,7 @@ function sendSingleMoveCommand(key) {
window.addEventListener('keydown', (event) => {
if (!canUseTeleop(event)) return;
const key = event.key.toLowerCase();
const key = teleopKey(event);
if (event.repeat) {
if (moveBindings[key]) event.preventDefault();
@@ -3415,7 +3463,7 @@ window.addEventListener('keydown', (event) => {
if (speedBindings[key]) {
event.preventDefault();
if (REAL_YAHBOOM_TELEOP) return;
if (REAL_TELEOP) return;
const [linearScale, angularScale] = speedBindings[key];
maxLinear = Math.max(0.01, maxLinear * linearScale);
maxAngular = Math.max(0.1, maxAngular * angularScale);
@@ -3431,7 +3479,8 @@ window.addEventListener('keydown', (event) => {
stopRobot();
return;
}
if (REAL_YAHBOOM_TELEOP) {
if (REAL_TELEOP) {
if (REAL_MKMINI_TELEOP) mkMiniHeldKeys.add(key);
activeMoveKey = key;
startActiveMoveCommand();
return;
@@ -3451,7 +3500,7 @@ window.addEventListener('keydown', (event) => {
});
window.addEventListener('keyup', (event) => {
if (REAL_YAHBOOM_TELEOP && event.key.toLowerCase() === activeMoveKey) {
if (REAL_TELEOP && (teleopKey(event) === activeMoveKey || (REAL_MKMINI_TELEOP && mkMiniHeldKeys.has(teleopKey(event))))) {
activeMoveKey = null;
stopRobot();
return;
@@ -3468,7 +3517,7 @@ window.addEventListener('blur', () => {
});
function releaseRealTeleop() {
if (!REAL_YAHBOOM_TELEOP || !activeMoveKey) return;
if (!REAL_TELEOP || !activeMoveKey) return;
activeMoveKey = null;
stopRobot();
}
+29 -3
View File
@@ -82,6 +82,7 @@ import WorkspaceModuleTabs from './features/workspace/WorkspaceModuleTabs'
import WorkspaceNav, { type WorkspaceActivity } from './features/workspace/WorkspaceNav'
import WorkspaceTopBar from './features/workspace/WorkspaceTopBar'
import WorkspaceCameraPanel from './features/workspace/WorkspaceCameraPanel'
import { useRemoteEditorFile } from './features/workspace/useRemoteEditorFile'
import WorkspaceVisualizationPanel from './features/workspace/WorkspaceVisualizationPanel'
import { GitFileContextMenu, PowerActionModal, SourceSettingsSaveConfirmModal, SubmitConfirmModal, TreeTargetDialog, UnsavedEditorCloseModal } from './features/workspace/WorkspaceModals'
@@ -1679,6 +1680,7 @@ function LocalPcWorkspaceMockup({
setOpenExampleEditorTabs,
loadedModuleFolders,
setLoadedModuleFolders,
loadedModuleFilePaths,
setLoadedModuleFilePaths,
setModuleFileLoadingByName,
loadedModuleRoots,
@@ -1712,7 +1714,7 @@ function LocalPcWorkspaceMockup({
return nextDirtyFiles
})
setSelectedGitTarget(null)
if (module) await loadModuleFiles(module)
if (module) await loadModuleFiles(module, true)
await refreshGitStatuses(null)
onTitlebarNotice('변경사항을 되돌렸습니다', 'info')
} catch (error) {
@@ -1786,7 +1788,7 @@ function LocalPcWorkspaceMockup({
: await window.localWorkspace.git.teamPull(module.name, clonePathPreview, module.folderName)
if (!result.ok) throw new Error(`[${module.name}] ${result.message}`)
}
await Promise.all(installedGitModules.map((module) => loadModuleFiles(module)))
await Promise.all(installedGitModules.map((module) => loadModuleFiles(module, true)))
await refreshGitStatuses(null)
onTitlebarNotice('팀 변경사항을 받았습니다', 'info')
} catch (error) {
@@ -2321,6 +2323,17 @@ 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
const remoteEditorFile = useRemoteEditorFile({
enabled: remoteWorkspace,
targetKey: remoteWorkspaceTargetKey,
getTarget: getRemoteWorkspaceTarget,
module: selectedModule,
path: currentActiveEditorFile,
files: editorFiles,
dirty: dirtyEditorFiles,
setFiles: setEditorFiles,
setSaved: setSavedEditorFiles
})
useEffect(() => {
if (!currentActiveEditorFile || !activeFileIsImage || currentActiveEditorFile.startsWith('examples/example-code/') || !selectedModule) return
if (imagePreviewSources[currentActiveEditorFile]) return
@@ -3043,9 +3056,11 @@ function LocalPcWorkspaceMockup({
onClose()
}
const revealSearchMatch = (lineNumber: number, query: string): boolean => {
if (remoteEditorFile.loading || remoteEditorFile.error) return false
if (!editorRef.current) return false
const editor = editorRef.current
const model = editor.getModel()
if (!model || model.isDisposed()) return false
const lineText = model?.getLineContent(lineNumber) ?? ''
const matchIndex = query ? lineText.toLowerCase().indexOf(query.toLowerCase()) : -1
const startColumn = matchIndex >= 0 ? matchIndex + 1 : 1
@@ -3227,9 +3242,14 @@ function LocalPcWorkspaceMockup({
setModuleSearchQuery,
activeModuleSearchQuery,
moduleSearchRunning,
moduleSearchError,
moduleSearchResults,
openSearchResult
} = useWorkspaceSearch({
remoteScopeKey: remoteWorkspace ? remoteWorkspaceTargetKey : undefined,
remoteSearch: remoteWorkspace && selectedModule
? (query) => window.remoteWorkspace.moduleFiles.search(getRemoteWorkspaceTarget(), selectedModule.name, query, selectedModule.folderName)
: undefined,
searchActive,
selectedModule,
editorFiles,
@@ -3420,10 +3440,11 @@ function LocalPcWorkspaceMockup({
const controlRemainingSeconds = controlRemainingMs === null ? null : Math.ceil(controlRemainingMs / 1000)
const showControlCountdown = canReleaseControl && controlRemainingSeconds !== null
const controlCountdownDanger = showControlCountdown && controlRemainingSeconds < 5 * 60
const cameraAvailable = Boolean(curriculumWeek.detail.camera?.url?.trim())
return (
<div ref={workspaceRootRef} className={['relative h-full min-h-0 overflow-hidden', surface].join(' ')}>
{cameraOpen && workspaceVisible && <WorkspaceCameraPanel
{cameraAvailable && cameraOpen && workspaceVisible && <WorkspaceCameraPanel
key={`${courseKey}:${weekNumber}`}
containerRef={visualizationContainerRef}
camera={curriculumWeek.detail.camera}
@@ -3431,6 +3452,7 @@ function LocalPcWorkspaceMockup({
onClose={() => setCameraOpen(false)}
/>}
<WorkspaceTopBar
cameraAvailable={cameraAvailable}
cameraOpen={cameraOpen}
onToggleCamera={() => setCameraOpen((open) => !open)}
dark={dark}
@@ -3675,6 +3697,7 @@ function LocalPcWorkspaceMockup({
moduleSearchQuery={moduleSearchQuery}
activeModuleSearchQuery={activeModuleSearchQuery}
moduleSearchRunning={moduleSearchRunning}
moduleSearchError={moduleSearchError}
moduleSearchResults={moduleSearchResults}
onStartCreateFile={startCreateFile}
onRefreshModuleFiles={refreshModuleFiles}
@@ -3917,6 +3940,9 @@ function LocalPcWorkspaceMockup({
/>
<WorkspaceEditorPane
fileLoading={remoteEditorFile.loading}
fileError={remoteEditorFile.error}
onRetryFile={remoteEditorFile.retry}
dark={dark}
textDim={textDim}
editorPaneRef={editorPaneRef}
+3
View File
@@ -81,6 +81,7 @@ interface Window {
root: string | null
absoluteRoot: string | null
files: Record<string, string>
filePaths?: string[]
folders: string[]
}>
image: (moduleName: string, path: string, workspaceRoot?: string, folderName?: string) => Promise<{ dataUrl: string }>
@@ -298,6 +299,8 @@ interface Window {
remoteWorkspace: {
moduleFiles: {
read: (target: RemoteWorkspaceTarget, moduleName: string, folderName?: string) => ReturnType<Window['moduleFiles']['read']>
content: (target: RemoteWorkspaceTarget, moduleName: string, path: string, folderName?: string) => Promise<{ content: string }>
search: (target: RemoteWorkspaceTarget, moduleName: string, query: string, folderName?: string) => Promise<Array<{ file: string; lineNumber: number; lineText: string }>>
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']>
@@ -1,11 +1,13 @@
import { CheckCircle2, FileText } from 'lucide-react'
import type React from 'react'
import { useState } from 'react'
type CurriculumDetailWeek = {
week: number
title: string
topics: string[]
detail: {
coverImage?: { downloadUrl: string } | null
overview: string
topicDetails: string[]
practiceContents?: Array<{
@@ -31,16 +33,34 @@ export default function CurriculumDetailPanel({
allowLearningStepSelection = false,
onSelectLearningStep
}: CurriculumDetailPanelProps): React.JSX.Element {
const [failedImageUrl, setFailedImageUrl] = useState<string | null>(null)
const imageUrl = week.detail.coverImage?.downloadUrl
const hasCover = Boolean(imageUrl && failedImageUrl !== imageUrl)
return (
<div data-help="curriculum-detail" className="min-w-0 px-7 pb-7 pt-5">
<h2 className="text-[18px] font-bold leading-tight">상세정보</h2>
<div className="mt-5 min-h-[548px] pb-6">
{selected ? (
<>
<section className="rounded-lg border border-[#dde2ec] bg-[#fbfcff] p-5 shadow-[0_10px_24px_rgba(20,27,44,0.05)]">
<h3 className="text-[13px] font-semibold text-[#5f6878]">{week.week}주차 개요</h3>
<h3 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{week.title}</h3>
<p className="mt-1 text-[14px] font-medium leading-[1.5] text-[#4f596b]">{week.detail.overview}</p>
<section className={`relative overflow-hidden rounded-lg border border-[#dde2ec] shadow-[0_10px_24px_rgba(20,27,44,0.05)] ${hasCover ? 'bg-white' : 'bg-[#fbfcff] p-5'}`}>
<div>
<div className={hasCover ? 'px-5 pb-3 pt-5' : undefined}>
<h3 className="text-[13px] font-semibold text-[#5f6878]">{week.week}주차 개요</h3>
<h3 className="mt-1 text-[22px] font-bold leading-tight text-[#111520]">{week.title}</h3>
</div>
{hasCover && (
<div className="relative h-[240px] overflow-hidden">
<img key={imageUrl} src={imageUrl} alt={`${week.week}주차 대표이미지`}
className="block h-full w-full object-cover object-center"
onError={() => setFailedImageUrl(imageUrl ?? null)} />
<div aria-hidden="true" className="pointer-events-none absolute inset-0"
style={{ background: 'linear-gradient(to bottom, #fff 0%, transparent 24px, transparent calc(100% - 24px), #fff 100%)' }} />
</div>
)}
<div className={hasCover ? 'px-5 pb-5 pt-3' : undefined}>
<p className="mt-1 text-[14px] font-medium leading-[1.5] text-[#4f596b]">{week.detail.overview}</p>
</div>
</div>
</section>
<div className="mt-4 grid gap-3">
@@ -41,6 +41,7 @@ export type CurriculumWeek = {
duration: string
github: string
detail: {
coverImage?: DownloadResource | null
camera?: { url: string } | null
overview: string
learningSteps?: LearningStep[]
@@ -752,6 +752,7 @@ function ModuleAddDialog({
<option value="">선택</option>
<option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option>
<option value="Camera">Camera</option>
<option value="Map">Map</option>
<option value="Perception">Perception</option>
<option value="Localization">Localization</option>
@@ -933,6 +934,7 @@ function ModuleImportDialog({
<option value="">선택</option>
<option value="Vehicle">Vehicle</option>
<option value="Sensing">Sensing</option>
<option value="Camera">Camera</option>
<option value="Map">Map</option>
<option value="Perception">Perception</option>
<option value="Localization">Localization</option>
@@ -1,7 +1,10 @@
import Editor, { type BeforeMount, type OnMount } from '@monaco-editor/react'
import { FileText } from 'lucide-react'
import { FileText, Loader2, RotateCw } from 'lucide-react'
type Props = {
fileLoading?: boolean
fileError?: string
onRetryFile?: () => void
dark: boolean
textDim: string
editorPaneRef: React.RefObject<HTMLDivElement | null>
@@ -21,6 +24,9 @@ type Props = {
}
function WorkspaceEditorPane({
fileLoading,
fileError,
onRetryFile,
dark,
textDim,
editorPaneRef,
@@ -70,6 +76,13 @@ function WorkspaceEditorPane({
)}
</div>
</div>
) : fileLoading || fileError ? (
<div className={['flex h-full min-w-0 flex-col items-center justify-center gap-3 p-5 text-[13px]', textDim].join(' ')}>
{fileError ? <>
<p role="alert" className="max-w-full break-all">파일을 불러오지 못했습니다.</p>
<button type="button" title="다시 불러오기" aria-label="다시 불러오기" onClick={onRetryFile} className="cursor-pointer p-2"><RotateCw size={18} /></button>
</> : <><Loader2 size={18} className="animate-spin" /><span role="status">파일 불러오는 중...</span></>}
</div>
) : (
<Editor
height="100%"
@@ -92,7 +105,7 @@ function WorkspaceEditorPane({
scrollBeyondLastLine: false,
tabSize,
wordWrap: wordWrap ? 'on' : 'off',
readOnly: exampleActive
readOnly: exampleActive || editorValue === 'Binary or large file preview is not available.'
}}
/>
)}
@@ -57,6 +57,7 @@ type WorkspaceFilePanelProps = {
moduleSearchQuery: string
activeModuleSearchQuery: string
moduleSearchRunning: boolean
moduleSearchError?: string | null
moduleSearchResults: SearchResult[]
onStartCreateFile: (kind: 'file' | 'folder') => void
onRefreshModuleFiles: () => void
@@ -171,6 +172,7 @@ export default function WorkspaceFilePanel({
moduleSearchQuery,
activeModuleSearchQuery,
moduleSearchRunning,
moduleSearchError,
moduleSearchResults,
onStartCreateFile,
onRefreshModuleFiles,
@@ -232,6 +234,7 @@ export default function WorkspaceFilePanel({
moduleSearchQuery={moduleSearchQuery}
activeModuleSearchQuery={activeModuleSearchQuery}
moduleSearchRunning={moduleSearchRunning}
moduleSearchError={moduleSearchError}
moduleSearchResults={moduleSearchResults}
onSearchChange={onSearchChange}
onOpenSearchResult={onOpenSearchResult}
@@ -284,12 +287,13 @@ function SearchPanel({
moduleSearchQuery,
activeModuleSearchQuery,
moduleSearchRunning,
moduleSearchError,
moduleSearchResults,
onSearchChange,
onOpenSearchResult
}: Pick<WorkspaceFilePanelProps,
'dark' | 'textMid' | 'textDim' | 'selectedModuleName' | 'moduleSearchInputRef' | 'moduleSearchQuery' |
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchResults' | 'onSearchChange' | 'onOpenSearchResult'
'activeModuleSearchQuery' | 'moduleSearchRunning' | 'moduleSearchError' | 'moduleSearchResults' | 'onSearchChange' | 'onOpenSearchResult'
>): React.JSX.Element {
return (
<div className={['flex min-h-0 flex-1 flex-col px-2 py-2 text-[13px] font-medium', textMid].join(' ')}>
@@ -318,6 +322,8 @@ function SearchPanel({
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
) : moduleSearchRunning ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 중...</p>
) : moduleSearchError ? (
<p role="alert" className="px-3 py-2 text-[12px] text-red-500">{moduleSearchError}</p>
) : moduleSearchResults.length === 0 ? (
<p className={['px-1 text-[12px] font-medium', textDim].join(' ')}>검색 결과가 없습니다.</p>
) : (
@@ -3,6 +3,7 @@ import type React from 'react'
import Tooltip from '../../components/Tooltip'
type WorkspaceTopBarProps = {
cameraAvailable: boolean
cameraOpen: boolean
onToggleCamera: () => void
dark: boolean
@@ -36,6 +37,7 @@ type WorkspaceTopBarProps = {
}
export default function WorkspaceTopBar({
cameraAvailable,
cameraOpen,
onToggleCamera,
dark,
@@ -97,9 +99,11 @@ export default function WorkspaceTopBar({
<PanelLeft size={14} />
시각화
</button>
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />오버뷰
</button>
{cameraAvailable && (
<button type="button" onClick={onToggleCamera} className={topToggleButtonClass(cameraOpen)}>
<Camera size={14} />오버뷰
</button>
)}
{showControlCountdown && (
<>
<span className={['ml-2 h-4 w-px', dark ? 'bg-white/20' : 'bg-[#cfd6e4]'].join(' ')} />
@@ -0,0 +1,49 @@
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { ModuleRow } from './types'
export function useRemoteEditorFile(options: {
enabled: boolean
targetKey: string
getTarget: () => RemoteWorkspaceTarget
module: ModuleRow | null
path: string | null
files: Record<string, string>
dirty: Record<string, boolean>
setFiles: Dispatch<SetStateAction<Record<string, string>>>
setSaved: Dispatch<SetStateAction<Record<string, string>>>
}) {
const latest = useRef(options)
latest.current = options
const pending = useRef(new Map<string, Promise<{ content: string }>>())
const [failure, setFailure] = useState<{ key: string; message: string } | null>(null)
const [attempt, setAttempt] = useState(0)
const { enabled, path, module, targetKey, files } = options
const eligible = enabled && !!path && !!module && path.startsWith(`modules/${module.name}/`) && !/\.(png|jpe?g|webp|gif|svg)$/i.test(path)
const key = JSON.stringify([targetKey, module?.name, module?.folderName, path])
const cached = path ? files[path] : undefined
useEffect(() => {
if (!eligible || !path || !module || cached !== undefined) return
let cancelled = false
setFailure(null)
let request = pending.current.get(key)
if (!request) {
request = window.remoteWorkspace.moduleFiles.content(latest.current.getTarget(), module.name, path, module.folderName)
pending.current.set(key, request)
const cleanup = (): void => { pending.current.delete(key) }
void request.then(cleanup, cleanup)
}
void request.then(({ content }) => {
if (cancelled || latest.current.dirty[path] || latest.current.files[path] !== undefined) return
latest.current.setFiles((current) => current[path] === undefined ? { ...current, [path]: content } : current)
latest.current.setSaved((current) => ({ ...current, [path]: content }))
}).catch((error) => {
if (!cancelled) setFailure({ key, message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [eligible, key, cached, attempt])
return {
loading: eligible && cached === undefined && failure?.key !== key,
error: eligible && cached === undefined && failure?.key === key ? failure.message : undefined,
retry: () => { setFailure(null); setAttempt((value) => value + 1) }
}
}
@@ -27,6 +27,7 @@ type UseWorkspaceFileActionsOptions = {
setActiveExampleEditorFile: Dispatch<SetStateAction<EditorFilePath | null>>
setOpenExampleEditorTabs: Dispatch<SetStateAction<EditorFilePath[]>>
loadedModuleFolders: Record<string, string[]>
loadedModuleFilePaths: Record<string, string[]>
setLoadedModuleFolders: Dispatch<SetStateAction<Record<string, string[]>>>
setLoadedModuleFilePaths: Dispatch<SetStateAction<Record<string, string[]>>>
setModuleFileLoadingByName: Dispatch<SetStateAction<Record<string, boolean>>>
@@ -52,7 +53,7 @@ type UseWorkspaceFileActionsOptions = {
}
type UseWorkspaceFileActionsResult = {
loadModuleFiles: (module: ModuleRow) => Promise<void>
loadModuleFiles: (module: ModuleRow, refreshContents?: boolean) => Promise<void>
startCreateFile: (kind: 'file' | 'folder') => void
commitCreateFile: () => Promise<void>
getTreeDisplayPath: (path: string) => string
@@ -95,6 +96,7 @@ export function useWorkspaceFileActions({
setActiveExampleEditorFile,
setOpenExampleEditorTabs,
loadedModuleFolders,
loadedModuleFilePaths,
setLoadedModuleFolders,
setLoadedModuleFilePaths,
setModuleFileLoadingByName,
@@ -115,14 +117,16 @@ export function useWorkspaceFileActions({
refreshGitStatuses
}: UseWorkspaceFileActionsOptions): UseWorkspaceFileActionsResult {
const pendingFileReads = useRef(new Map<string, Promise<void>>())
const dirtyFilesRef = useRef(dirtyEditorFiles)
dirtyFilesRef.current = dirtyEditorFiles
const loadModuleFiles = async (module: ModuleRow): Promise<void> => {
const loadModuleFiles = async (module: ModuleRow, refreshContents = false): Promise<void> => {
const target = remoteWorkspace ? getRemoteWorkspaceTarget() : null
const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName])
const key = JSON.stringify([target, clonePathPreview, module.name, module.folderName, refreshContents])
const pending = pendingFileReads.current.get(key)
if (pending) return pending
const request = readModuleFiles(module, target)
const request = readModuleFiles(module, target, refreshContents)
pendingFileReads.current.set(key, request)
try {
await request
@@ -131,7 +135,7 @@ export function useWorkspaceFileActions({
}
}
const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null): Promise<void> => {
const readModuleFiles = async (module: ModuleRow, target: RemoteWorkspaceTarget | null, refreshContents: boolean): Promise<void> => {
setModuleFileLoadingByName((loading) => ({ ...loading, [module.name]: true }))
try {
const result = target
@@ -141,14 +145,20 @@ export function useWorkspaceFileActions({
const nextFolders = result.folders.length ? result.folders : []
setEditorFiles((files) => {
if (target && result.filePaths && !refreshContents) return files
const merged = { ...files }
if (target && result.filePaths) {
Object.keys(merged).forEach((path) => {
if (path.startsWith(`modules/${module.name}/`) && !dirtyFilesRef.current[path]) delete merged[path]
})
}
Object.entries(nextFiles).forEach(([path, content]) => {
if (!dirtyEditorFiles[path]) merged[path] = content
if (!dirtyFilesRef.current[path]) merged[path] = content
})
return merged
})
setSavedEditorFiles((files) => ({ ...files, ...nextFiles }))
setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: Object.keys(nextFiles) }))
setLoadedModuleFilePaths((paths) => ({ ...paths, [module.name]: result.filePaths ?? Object.keys(nextFiles) }))
setLoadedModuleFolders((folders) => ({ ...folders, [module.name]: nextFolders }))
setLoadedModuleRoots((roots) => ({ ...roots, [module.name]: result.absoluteRoot }))
} catch (error) {
@@ -165,7 +175,7 @@ export function useWorkspaceFileActions({
if (!selectedModule) return
const rootPath = `modules/${selectedModule.name}`
const selectedPathInModule = selectedTreePath?.startsWith(`${rootPath}/`) || selectedTreePath === rootPath ? selectedTreePath : null
const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined : false
const selectedIsFile = selectedPathInModule ? editorFiles[selectedPathInModule] !== undefined || (loadedModuleFilePaths[selectedModule.name] ?? []).includes(selectedPathInModule) : false
const parent = selectedPathInModule
? selectedIsFile
? selectedPathInModule.split('/').slice(0, -1).join('/')
@@ -244,6 +254,7 @@ export function useWorkspaceFileActions({
const saveEditorFile = async (file: EditorFilePath): Promise<void> => {
if (!selectedModule || file.startsWith('examples/example-code/')) return
if (editorFiles[file] === undefined) throw new Error('파일 내용을 먼저 불러와야 합니다.')
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.write(getRemoteWorkspaceTarget(), selectedModule.name, file, editorFiles[file] ?? '', selectedModule.folderName)
} else {
@@ -403,7 +414,8 @@ export function useWorkspaceFileActions({
if (pasteParent === sourcePath || pasteParent.startsWith(`${sourcePath}/`)) return
const rootPath = `modules/${selectedModule.name}`
const allFolders = Array.from(new Set([...(loadedModuleFolders[selectedModule.name] ?? []), ...createdModuleFolders.filter((folder) => folder.startsWith(rootPath))]))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, editorFiles, allFolders)
const knownFiles = Object.fromEntries((loadedModuleFilePaths[selectedModule.name] ?? []).map((path) => [path, '']))
const targetBasePath = makeUniquePath(`${pasteParent}/${getPathName(sourcePath)}`, { ...knownFiles, ...editorFiles }, allFolders)
try {
if (remoteWorkspace) {
await window.remoteWorkspace.moduleFiles.copy(getRemoteWorkspaceTarget(), selectedModule.name, sourcePath, targetBasePath, treeClipboard.mode, selectedModule.folderName)
@@ -447,7 +459,7 @@ export function useWorkspaceFileActions({
const refreshModuleFiles = (): void => {
if (!selectedModule) return
setNewFileDraft(null)
void loadModuleFiles(selectedModule)
void loadModuleFiles(selectedModule, true)
}
return {
@@ -14,6 +14,8 @@ type PendingReveal = {
}
type UseWorkspaceSearchOptions = {
remoteSearch?: (query: string) => Promise<ModuleSearchResult[]>
remoteScopeKey?: string
searchActive: boolean
selectedModule: ModuleRow | null
editorFiles: Record<string, string>
@@ -24,6 +26,7 @@ type UseWorkspaceSearchOptions = {
}
type UseWorkspaceSearchResult = {
moduleSearchError: string | null
moduleSearchInputRef: React.RefObject<HTMLInputElement | null>
moduleSearchQuery: string
setModuleSearchQuery: React.Dispatch<React.SetStateAction<string>>
@@ -35,6 +38,8 @@ type UseWorkspaceSearchResult = {
}
export function useWorkspaceSearch({
remoteSearch,
remoteScopeKey,
searchActive,
selectedModule,
editorFiles,
@@ -47,6 +52,9 @@ export function useWorkspaceSearch({
const [debouncedModuleSearchQuery, setDebouncedModuleSearchQuery] = useState('')
const [moduleSearchResults, setModuleSearchResults] = useState<ModuleSearchResult[]>([])
const [moduleSearchRunning, setModuleSearchRunning] = useState(false)
const [moduleSearchError, setModuleSearchError] = useState<string | null>(null)
const remoteSearchRef = useRef(remoteSearch)
remoteSearchRef.current = remoteSearch
const [pendingReveal, setPendingReveal] = useState<PendingReveal | null>(null)
const moduleSearchInputRef = useRef<HTMLInputElement>(null)
const searchRunIdRef = useRef(0)
@@ -64,8 +72,14 @@ export function useWorkspaceSearch({
useEffect(() => {
if (!pendingReveal) return
const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query)
if (revealed) setPendingReveal(null)
const reveal = (): boolean => {
const revealed = onRevealSearchMatch(pendingReveal.lineNumber, pendingReveal.query)
if (revealed) setPendingReveal(null)
return revealed
}
if (reveal()) return
const timer = window.setInterval(() => { if (reveal()) window.clearInterval(timer) }, 100)
return () => window.clearInterval(timer)
}, [activeEditorFile, onRevealSearchMatch, pendingReveal])
useEffect(() => {
@@ -84,6 +98,7 @@ export function useWorkspaceSearch({
useEffect(() => {
const query = debouncedModuleSearchQuery.trim()
setModuleSearchError(null)
if (!searchActive || !selectedModule || query.length < 2) {
setModuleSearchResults([])
setModuleSearchRunning(false)
@@ -102,7 +117,7 @@ export function useWorkspaceSearch({
if (cancelled || searchRunIdRef.current !== searchRunId) return
const chunkEnd = Math.min(moduleFilePaths.length, fileIndex + 12)
for (; fileIndex < chunkEnd; fileIndex += 1) {
for (; fileIndex < chunkEnd && nextResults.length < 200; fileIndex += 1) {
const file = moduleFilePaths[fileIndex]
const content = editorFiles[file] ?? ''
const lines = content.split('\n')
@@ -127,14 +142,29 @@ export function useWorkspaceSearch({
}
}
const timer = window.setTimeout(searchChunk, 0)
const searchRemote = remoteSearchRef.current
const timer = window.setTimeout(() => {
if (!searchRemote) { searchChunk(); return }
void searchRemote(query).then((results) => {
if (cancelled) return
nextResults.push(...results.filter((result) => editorFiles[result.file] === undefined))
searchChunk()
}).catch((error) => {
if (cancelled) return
console.error('[module-files] search failed', error)
setModuleSearchResults([])
setModuleSearchError('검색하지 못했습니다. 다시 시도해주세요.')
setModuleSearchRunning(false)
})
}, 0)
return () => {
cancelled = true
window.clearTimeout(timer)
}
}, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule])
}, [debouncedModuleSearchQuery, editorFiles, moduleFilePathKey, searchActive, selectedModule, remoteScopeKey])
return {
moduleSearchError,
moduleSearchInputRef,
moduleSearchQuery,
setModuleSearchQuery,