feat: add weekly curriculum cover image upload and display
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lms",
|
||||
"version": "1.1.32",
|
||||
"version": "1.2.1",
|
||||
"description": "Autonomy Studio LMS Electron app",
|
||||
"main": "./out/main/index.js",
|
||||
"author": {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
Vendored
+3
@@ -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)]">
|
||||
<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>
|
||||
{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 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,
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
const vm = require('node:vm');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const ts = require('typescript');
|
||||
const root = path.join(__dirname, '..');
|
||||
const source = fs.readFileSync(path.join(root, 'src/main/index.ts'), 'utf8');
|
||||
const ast = ts.createSourceFile('index.ts', source, ts.ScriptTarget.Latest, true);
|
||||
const ctx = { exports: {} };
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/main/yahboomRuntime.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText, ctx);
|
||||
Object.assign(ctx, ctx.exports);
|
||||
vm.runInNewContext(ts.transpileModule(fs.readFileSync(path.join(root, 'src/main/cameraRuntime.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText, ctx);
|
||||
const images = [{ image: 'future-nav2-camera-test:humble-jetson_orin' },
|
||||
{ image: 'future-nav2:humble-jetson_orin' }, { image: 'slam-ros-bridge:jetson_orin' }];
|
||||
assert.deepEqual(ctx.exports.cameraRuntimeArtifacts('[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws', images), images.slice(0, 1));
|
||||
assert.deepEqual(ctx.exports.cameraRuntimeArtifacts('[lms-nav2-mkmini]nav2-mkmini_v1.0.0_ws', images), images.slice(1));
|
||||
assert.equal(ctx.exports.cameraRuntimeArtifacts('anything', undefined), undefined);
|
||||
const names = ['buildNav2DockerRunScript', 'buildNav2DockerBuildScript'];
|
||||
for (const node of ast.statements) {
|
||||
if (ts.isFunctionDeclaration(node) && names.includes(node.name?.text)) {
|
||||
vm.runInNewContext(ts.transpileModule(node.getText(ast), {}).outputText, ctx);
|
||||
}
|
||||
}
|
||||
const run = ctx.buildNav2DockerRunScript();
|
||||
const build = ctx.buildNav2DockerBuildScript();
|
||||
for (const script of [run, build]) {
|
||||
const syntax = spawnSync('bash', ['-n'], { input: script, encoding: 'utf8' });
|
||||
assert.equal(syntax.status, 0, syntax.stderr);
|
||||
}
|
||||
assert.match(run, /lms-nav2-camera-test\*\) PACKAGE=camera_test_pkg; LAUNCH=camera.launch.py/);
|
||||
assert.match(build, /lms-nav2-camera-test\*\) TARGET=camera_test_pkg/);
|
||||
assert.match(run, /--device "\$CAMERA_DEVICE:\$CAMERA_DEVICE"/);
|
||||
assert.match(run, /for capture_device in \/dev\/capture-vi-channel\*/);
|
||||
assert.match(run, /--device "\$capture_device:\$capture_device"/);
|
||||
assert.match(run, /FASTDDS_DEFAULT_PROFILES_FILE/);
|
||||
assert.match(run, /ROS_LOCALHOST_ONLY=\$ROS_LOCALHOST_ONLY_VALUE/);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'camera-runtime-'));
|
||||
const folder = '[lms-nav2-camera-test]nav2-camera-test_v1.0.0_ws';
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, folder, 'install'), { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, folder, 'install/setup.bash'), '');
|
||||
for (const device of ['/dev/null', '/dev/video999999', '/tmp/video0']) {
|
||||
const result = spawnSync('bash', ['-s', '--', 'camera-test', dir, folder, 'camera-image', '77', 'false', `video_device:=${device}`],
|
||||
{ input: run, encoding: 'utf8' });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Camera device not found/);
|
||||
}
|
||||
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
||||
console.log('Camera module routing, shell syntax, DDS settings and device validation passed.');
|
||||
@@ -0,0 +1,70 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
|
||||
const source = html.slice(html.indexOf('function hasLiveCameraState()'), html.indexOf("cameraPreview?.addEventListener('load'"));
|
||||
let now = 10000;
|
||||
let requests = 0;
|
||||
let ok = true;
|
||||
let visible = false;
|
||||
let afterBlob = () => {};
|
||||
const context = vm.createContext({
|
||||
ENABLE_CAMERA_PREVIEW: true,
|
||||
cameraFrameRequestInFlight: false,
|
||||
cameraFrameRetryAt: 0,
|
||||
cameraFrameObjectUrl: null,
|
||||
cameraPreview: {},
|
||||
latestSlamState: null,
|
||||
latestSlamStateReceivedAt: now,
|
||||
document: { hidden: false },
|
||||
Date: { now: () => now },
|
||||
BACKEND_URL: 'http://localhost:8765',
|
||||
AbortSignal,
|
||||
URL: { createObjectURL: () => 'blob:frame', revokeObjectURL() {} },
|
||||
setCameraPreviewAvailable(value) { visible = value; },
|
||||
async fetch() {
|
||||
requests++;
|
||||
return { ok, async blob() { afterBlob(); return {}; } };
|
||||
},
|
||||
});
|
||||
vm.runInContext(source, context);
|
||||
const refresh = () => context.refreshCameraFrame();
|
||||
const available = (value) => {
|
||||
context.latestSlamState = { topic_status: { camera_image: { available: value } } };
|
||||
context.latestSlamStateReceivedAt = now;
|
||||
};
|
||||
|
||||
(async () => {
|
||||
await refresh();
|
||||
available(false);
|
||||
await refresh();
|
||||
assert.equal(requests, 0, 'Absent camera must not fetch frames');
|
||||
available(true);
|
||||
await refresh();
|
||||
assert.equal(requests, 1);
|
||||
assert.equal(context.cameraPreview.src, 'blob:frame');
|
||||
now += 3001;
|
||||
await refresh();
|
||||
assert.equal(requests, 1, 'Stale websocket state must stop polling');
|
||||
assert.equal(visible, false);
|
||||
available(true);
|
||||
ok = false;
|
||||
await refresh();
|
||||
await refresh();
|
||||
assert.equal(requests, 2, 'HTTP errors must back off');
|
||||
now += 5000;
|
||||
available(true);
|
||||
ok = true;
|
||||
afterBlob = () => available(false);
|
||||
context.cameraPreview.src = '';
|
||||
await refresh();
|
||||
assert.equal(context.cameraPreview.src, '', 'Late responses must not revive a disconnected camera');
|
||||
assert.equal(context.cameraFrameRequestInFlight, false);
|
||||
afterBlob = () => {};
|
||||
available(true);
|
||||
await refresh();
|
||||
assert.equal(context.cameraPreview.src, 'blob:frame', 'Camera reconnect must resume frames');
|
||||
console.log('camera preview polling tests passed');
|
||||
})().catch((error) => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,51 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
const moduleRow = { name: 'Test', folderName: 'test' };
|
||||
const clean = 'modules/Test/clean.py';
|
||||
const dirty = 'modules/Test/dirty.py';
|
||||
let reads = 0;
|
||||
const context = {
|
||||
exports: {}, console,
|
||||
require: name => name === 'react' ? { useRef: value => ({ current: value }) } : {},
|
||||
window: { remoteWorkspace: { moduleFiles: { read: async () => {
|
||||
reads++;
|
||||
return { files: {}, filePaths: [clean, dirty], folders: [], absoluteRoot: '/module' };
|
||||
} } } }
|
||||
};
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/renderer/src/features/workspace/useWorkspaceFileActions.ts'), 'utf8');
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
|
||||
let files = { [clean]: 'cached', [dirty]: 'unsaved' };
|
||||
const options = {
|
||||
selectedModule: moduleRow, remoteWorkspace: true, getRemoteWorkspaceTarget: () => ({ host: 'test' }),
|
||||
clonePathPreview: '/workspace', dirtyEditorFiles: { [dirty]: true },
|
||||
setEditorFiles: fn => { files = fn(files); },
|
||||
setNewFileDraft: () => {}
|
||||
};
|
||||
for (const name of ['setModuleFileLoadingByName', 'setSavedEditorFiles', 'setLoadedModuleFilePaths', 'setLoadedModuleFolders', 'setLoadedModuleRoots']) {
|
||||
options[name] = fn => fn({});
|
||||
}
|
||||
(async () => {
|
||||
const actions = context.exports.useWorkspaceFileActions(options);
|
||||
const cached = files;
|
||||
await actions.loadModuleFiles(moduleRow);
|
||||
await actions.loadModuleFiles(moduleRow);
|
||||
assert.equal(reads, 2);
|
||||
assert.equal(files, cached, 'Automatic listing refresh must retain the content cache');
|
||||
await actions.loadModuleFiles(moduleRow, true);
|
||||
assert.equal(files[clean], undefined, 'Explicit refresh invalidates clean content');
|
||||
assert.equal(files[dirty], 'unsaved', 'Explicit refresh preserves unsaved edits');
|
||||
files = { [clean]: 'reloaded', [dirty]: 'unsaved' };
|
||||
actions.refreshModuleFiles();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(files[clean], undefined, 'Refresh button must explicitly invalidate content');
|
||||
assert.equal(files[dirty], 'unsaved');
|
||||
options.remoteWorkspace = false;
|
||||
context.window.localWorkspace = { moduleFiles: { read: async () => ({ files: { [clean]: 'local disk' }, folders: [], absoluteRoot: '/module' }) } };
|
||||
await context.exports.useWorkspaceFileActions(options).loadModuleFiles(moduleRow);
|
||||
assert.equal(files[clean], 'local disk', 'Local workspace retains its existing eager loading');
|
||||
console.log('Automatic cache retention, explicit refresh, dirty protection and local loading tests passed.');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,67 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
// Exercise effect cleanup and asynchronous IPC responses without starting Electron.
|
||||
const slots = [];
|
||||
let cursor = 0;
|
||||
let effects = [];
|
||||
const react = {
|
||||
useRef(value) { const i = cursor++; return slots[i] ??= { current: value }; },
|
||||
useState(value) {
|
||||
const i = cursor++;
|
||||
if (!(i in slots)) slots[i] = value;
|
||||
return [slots[i], next => { slots[i] = typeof next === 'function' ? next(slots[i]) : next; }];
|
||||
},
|
||||
useEffect(fn, deps) {
|
||||
const i = cursor++;
|
||||
const previous = slots[i];
|
||||
if (previous && deps.every((dep, index) => Object.is(dep, previous.deps[index]))) return;
|
||||
effects.push(() => { previous?.cleanup?.(); slots[i] = { deps, cleanup: fn() }; });
|
||||
}
|
||||
};
|
||||
const requests = [];
|
||||
const context = { exports: {}, require: name => { assert.equal(name, 'react'); return react; }, window: {
|
||||
remoteWorkspace: { moduleFiles: { content: (...args) => new Promise((resolve, reject) => requests.push({ args, resolve, reject })) } }
|
||||
} };
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/renderer/src/features/workspace/useRemoteEditorFile.ts'), 'utf8');
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
|
||||
const options = {
|
||||
enabled: true, targetKey: 'platform', getTarget: () => ({}), module: { name: 'Test', folderName: 'test' },
|
||||
path: null, files: {}, dirty: {},
|
||||
setFiles(fn) { options.files = fn(options.files); },
|
||||
setSaved(fn) { options.saved = fn(options.saved ?? {}); }
|
||||
};
|
||||
function render() {
|
||||
cursor = 0;
|
||||
const result = context.exports.useRemoteEditorFile(options);
|
||||
const pending = effects; effects = []; pending.forEach(fn => fn());
|
||||
return result;
|
||||
}
|
||||
const tick = () => new Promise(resolve => setImmediate(resolve));
|
||||
(async () => {
|
||||
render(); assert.equal(requests.length, 0);
|
||||
options.path = 'modules/Test/first.py';
|
||||
assert.equal(render().loading, true); assert.equal(requests.length, 1);
|
||||
render(); assert.equal(requests.length, 1);
|
||||
options.path = 'modules/Test/second.py'; render();
|
||||
requests[0].resolve({ content: 'old response' }); await tick();
|
||||
assert.equal(options.files['modules/Test/first.py'], undefined);
|
||||
requests[1].resolve({ content: 'second' }); await tick();
|
||||
assert.equal(render().loading, false);
|
||||
assert.equal(options.files[options.path], 'second');
|
||||
options.path = null; render(); options.path = 'modules/Test/second.py'; render();
|
||||
assert.equal(requests.length, 2);
|
||||
options.path = 'modules/Test/fail.py'; render();
|
||||
requests[2].reject(new Error('offline')); await tick();
|
||||
assert.ok(render().error);
|
||||
render().retry(); render(); assert.equal(requests.length, 4);
|
||||
options.dirty[options.path] = true; options.files[options.path] = 'unsaved'; render();
|
||||
requests[3].resolve({ content: 'server copy' }); await tick();
|
||||
assert.equal(options.files[options.path], 'unsaved');
|
||||
options.path = 'modules/Test/image.png'; render(); assert.equal(requests.length, 4);
|
||||
options.enabled = false; options.path = 'modules/Test/local.py'; render(); assert.equal(requests.length, 4);
|
||||
console.log('Remote editor click loading, caching, cancellation, retry and dirty-file protection tests passed.');
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -0,0 +1,44 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const ts = require('typescript');
|
||||
const source = fs.readFileSync(path.join(__dirname, '../src/main/remoteModuleFiles.ts'), 'utf8');
|
||||
const context = { exports: {}, Buffer };
|
||||
vm.runInNewContext(ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText, context);
|
||||
const script = context.exports.remoteModuleFilesScript;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'module-files-'));
|
||||
function run(request) {
|
||||
return spawnSync('bash', ['-s'], { input: script(root, request), encoding: 'utf8' });
|
||||
}
|
||||
function read(request) {
|
||||
const result = run(request);
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
return JSON.parse(result.stdout);
|
||||
}
|
||||
try {
|
||||
fs.mkdirSync(path.join(root, 'src'));
|
||||
fs.mkdirSync(path.join(root, 'build'));
|
||||
fs.writeFileSync(path.join(root, 'src', 'hello.py'), 'first\nHello world\n');
|
||||
fs.writeFileSync(path.join(root, 'build', 'hidden.py'), 'Hello');
|
||||
fs.writeFileSync(path.join(root, 'large.txt'), 'x'.repeat(1048577));
|
||||
fs.writeFileSync(path.join(root, 'binary.txt'), Buffer.from([0, 1]));
|
||||
fs.symlinkSync('/etc/passwd', path.join(root, 'outside.txt'));
|
||||
const listing = read({ action: 'list' });
|
||||
assert.deepEqual(listing, { filePaths: ['binary.txt', 'large.txt', 'src/hello.py'], folders: ['src'] });
|
||||
assert.equal(read({ action: 'content', path: 'src/hello.py' }).content, 'first\nHello world\n');
|
||||
for (const file of ['large.txt', 'binary.txt']) {
|
||||
assert.equal(read({ action: 'content', path: file }).content, 'Binary or large file preview is not available.');
|
||||
}
|
||||
for (const file of ['../outside.txt', '/etc/passwd', 'outside.txt', 'missing.py']) {
|
||||
assert.notEqual(run({ action: 'content', path: file }).status, 0);
|
||||
}
|
||||
assert.deepEqual(read({ action: 'search', query: 'HELLO' }), [{ file: 'src/hello.py', lineNumber: 2, lineText: 'Hello world' }]);
|
||||
fs.writeFileSync(path.join(root, 'src', 'many.txt'), 'hello\n'.repeat(300));
|
||||
assert.equal(read({ action: 'search', query: 'hello' }).length, 200);
|
||||
console.log('Remote module listing, on-demand content, path validation and search tests passed.');
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
@@ -4,12 +4,13 @@ const vm = require('node:vm');
|
||||
const path = require('node:path');
|
||||
const html = fs.readFileSync(path.join(__dirname, '../src/renderer/public/slam-viewer/index.html'), 'utf8');
|
||||
const code = html.slice(html.indexOf('let maxLinear ='), html.indexOf('// A static Gazebo model'));
|
||||
function create(real = true, yahboom = true) {
|
||||
function create(real = true, yahboom = true, mkmini = !yahboom) {
|
||||
const handlers = {}, messages = [], intervals = new Map();
|
||||
let id = 0;
|
||||
const context = vm.createContext({
|
||||
REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_YAHBOOM_TELEOP: real && yahboom,
|
||||
USE_MKMINI_MODEL: !yahboom, DIRECT_BRIDGE: true,
|
||||
REAL_RUNTIME: real, USE_YAHBOOM_MODEL: yahboom, REAL_TELEOP: real && (yahboom || mkmini),
|
||||
REAL_MKMINI_TELEOP: real && mkmini, EDITOR_MODE: false,
|
||||
USE_MKMINI_MODEL: mkmini, DIRECT_BRIDGE: true,
|
||||
latestSlamState: {teleop_deadman: true}, latestSlamStateReceivedAt: Date.now(),
|
||||
controlSocket: {readyState: 1, bufferedAmount: 0, send: data => messages.push(JSON.parse(data)), close() {this.readyState = 3;}},
|
||||
WebSocket: {OPEN: 1}, document: {hidden: false, hasFocus: () => true, addEventListener: (name, fn) => handlers[name] = fn},
|
||||
@@ -45,8 +46,49 @@ assert.equal(t.messages.at(-1).linear, 0);
|
||||
assert.equal(t.intervals.size, 0);
|
||||
t = create(); t.context.latestSlamState.teleop_deadman = false; t.key('keydown', 'i');
|
||||
assert.equal(t.messages.length, 0);
|
||||
t = create(true, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
|
||||
t = create(true, false, false); t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
|
||||
for (const [forward, left, reverse] of [['i', 'j', ','], ['ArrowUp', 'ArrowLeft', 'ArrowDown']]) {
|
||||
t = create(true, false);
|
||||
t.key('keydown', left);
|
||||
assert.equal(t.messages.at(-1).linear, 0);
|
||||
assert.equal(t.messages.at(-1).angular, 0);
|
||||
t.key('keydown', forward);
|
||||
assert.equal(t.messages.at(-1).linear, 0.1);
|
||||
assert.equal(t.messages.at(-1).angular, 0.08);
|
||||
assert.equal(t.messages.at(-1).deadman, true);
|
||||
t.key('keyup', left, {target: {tagName: 'INPUT'}});
|
||||
assert.equal(t.messages.at(-1).linear, 0);
|
||||
assert.equal(t.intervals.size, 0);
|
||||
t.key('keydown', reverse);
|
||||
t.key('keydown', 'l');
|
||||
assert.equal(t.messages.at(-1).linear, -0.1);
|
||||
assert.equal(t.messages.at(-1).angular, -0.08);
|
||||
t.key('keydown', ' ');
|
||||
assert.equal(t.messages.at(-1).linear, 0);
|
||||
assert.equal(t.messages.at(-1).angular, 0);
|
||||
}
|
||||
for (const event of ['blur', 'pagehide', 'visibilitychange']) {
|
||||
t = create(true, false);
|
||||
t.key('keydown', 'i');
|
||||
if (event === 'visibilitychange') t.context.document.hidden = true;
|
||||
t.handlers[event]();
|
||||
assert.equal(t.messages.at(-1).linear, 0);
|
||||
assert.equal(t.intervals.size, 0);
|
||||
}
|
||||
t = create(true, false);
|
||||
t.key('keydown', 'q'); t.key('keydown', 'i');
|
||||
assert.equal(t.messages.at(-1).linear, 0.1);
|
||||
t.context.latestSlamStateReceivedAt = 0;
|
||||
for (const tick of t.intervals.values()) tick();
|
||||
assert.equal(t.messages.at(-1).linear, 0);
|
||||
t = create(true, false); t.context.latestSlamState.teleop_deadman = false;
|
||||
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
|
||||
t = create(true, false); t.context.controlSocket.bufferedAmount = 1;
|
||||
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
|
||||
assert.equal(t.context.controlSocket.readyState, 3);
|
||||
t = create(true, false); t.context.EDITOR_MODE = true;
|
||||
t.key('keydown', 'i'); assert.equal(t.messages.length, 0);
|
||||
t = create(false); t.key('keydown', 'i'); t.key('keyup', 'i');
|
||||
assert.equal(t.messages.at(-1).linear, 0.22);
|
||||
assert.equal(t.intervals.size, 1);
|
||||
console.log('PASS: deadman release, focus/visibility/stale-state stops, legacy bridge and other real vehicles blocked, simulation unchanged');
|
||||
console.log('PASS: Yahboom/MK-mini deadman, combined steering, arrows/reverse, release/focus/stale-state stops, speed cap, legacy/read-only blocking, simulation unchanged');
|
||||
|
||||
Reference in New Issue
Block a user