From e1ee407783e12c72fd6ea0e9c958f32a80a1c0ad Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Sat, 5 Sep 2026 14:21:00 -0700 Subject: [PATCH 1/2] fix: scan workspaces breadth-first --- package.json | 2 +- scripts/e2e-workspace-scan-progress.test.mjs | 71 ++++++++++++++++ specs/issue-88-progressive-workspace-scan.md | 34 ++++++++ src/ui/filesystem.js | 87 +++++++++++++++----- 4 files changed, 172 insertions(+), 22 deletions(-) create mode 100644 scripts/e2e-workspace-scan-progress.test.mjs create mode 100644 specs/issue-88-progressive-workspace-scan.md diff --git a/package.json b/package.json index 2e5f5c3..b8f98fd 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "lint": "eslint .", "build": "npm run build:webpack && npm run build:targets", "build:firefox": "npm run build", - "test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", + "test:e2e": "node --experimental-detect-module --test scripts/e2e-session-persistence.test.mjs scripts/e2e-session-restore-choice.test.mjs scripts/e2e-multifile-build.test.mjs scripts/e2e-workspace-file-tracking.test.mjs scripts/e2e-workspace-scan-progress.test.mjs scripts/e2e-terminal-mkdir.test.mjs scripts/e2e-terminal-stop.test.mjs scripts/e2e-terminal-git-removal.test.mjs scripts/e2e-terminal-stop-icon.test.mjs scripts/e2e-browser-compatibility.test.mjs scripts/e2e-firefox-compatibility.test.mjs scripts/e2e-firefox-jspi-stdin.test.mjs scripts/e2e-wasi-shim.test.mjs scripts/e2e-run-request.test.mjs scripts/e2e-release-packaging.test.mjs", "test:e2e:compiler": "npm run test:preflight-clang && node --experimental-detect-module --test scripts/e2e-compiler-link.test.mjs", "test:preflight-clang": "node scripts/preflight-clang-artifacts.js", "test:browser:chrome": "npm run test:e2e:compiler && node scripts/smoke-browser.mjs chrome", diff --git a/scripts/e2e-workspace-scan-progress.test.mjs b/scripts/e2e-workspace-scan-progress.test.mjs new file mode 100644 index 0000000..cba3681 --- /dev/null +++ b/scripts/e2e-workspace-scan-progress.test.mjs @@ -0,0 +1,71 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +class FakeFileHandle { + constructor(name) { + this.kind = 'file'; + this.name = name; + } + + async getFile() { + return { lastModified: 1, size: 1 }; + } +} + +class FakeDirectoryHandle { + constructor(name) { + this.kind = 'directory'; + this.name = name; + this.children = new Map(); + } + + async *entries() { + yield* this.children; + } +} + +async function importFreshFilesystem() { + return import(`../src/ui/filesystem.js?scan-progress=${Math.random()}`); +} + +test('e2e: folder scanning reports a preview after each completed directory depth', async () => { + const fs = await importFreshFilesystem(); + const root = new FakeDirectoryHandle('project'); + const src = new FakeDirectoryHandle('src'); + const lib = new FakeDirectoryHandle('lib'); + root.children.set('README.md', new FakeFileHandle('README.md')); + root.children.set('src', src); + src.children.set('main.cpp', new FakeFileHandle('main.cpp')); + src.children.set('lib', lib); + lib.children.set('util.hpp', new FakeFileHandle('util.hpp')); + + const progress = []; + const workspace = await fs.openFolderFromHandle(root, { + onScanProgress(update) { + progress.push({ + depth: update.completedDepth, + paths: update.workspace.entries.map((entry) => entry.path), + loadingDirectoryPaths: update.loadingDirectoryPaths, + }); + }, + }); + + assert.deepEqual(progress, [ + { + depth: 0, + paths: ['README.md', 'src'], + loadingDirectoryPaths: ['src'], + }, + { + depth: 1, + paths: ['README.md', 'src', 'src/lib', 'src/main.cpp'], + loadingDirectoryPaths: ['src', 'src/lib'], + }, + { + depth: 2, + paths: ['README.md', 'src', 'src/lib', 'src/lib/util.hpp', 'src/main.cpp'], + loadingDirectoryPaths: [], + }, + ]); + assert.deepEqual(workspace.entries.map((entry) => entry.path), progress.at(-1).paths); +}); diff --git a/specs/issue-88-progressive-workspace-scan.md b/specs/issue-88-progressive-workspace-scan.md new file mode 100644 index 0000000..bdf68a2 --- /dev/null +++ b/specs/issue-88-progressive-workspace-scan.md @@ -0,0 +1,34 @@ +# Issue #88: Progressive workspace scanning + +## Objective + +Prevent deep directory trees from overflowing the JavaScript call stack while +making a newly selected workspace visibly load one directory depth at a time. + +## Decision + +- Traverse `FileSystemDirectoryHandle` trees iteratively, breadth-first. +- Publish a preview after each completed depth, starting with the root. +- Start each new-workspace preview with every directory collapsed. +- Permit directory expand/collapse while scanning, but keep file actions and + workspace mutations unavailable until the final workspace is committed. +- Show a right-aligned spinner on every visible directory whose subtree still + contains queued work. This includes queued directories themselves and their + indexed ancestors, so an expanded path exposes progress at each visible level. +- Show the preview immediately; do not delay it behind a duration threshold. + +## Public callback contract + +`openFolderFromHandle` and `openFolder` accept an `onScanProgress` callback. +Each callback receives a preview workspace, the completed depth, and the paths +whose visible directory rows should show a spinner. The final return value and +workspace-index commit remain unchanged. + +## Verification + +- A fake workspace deeper than the JavaScript call-stack limit scans correctly. +- Depth-zero progress exposes root entries immediately. +- Progress payloads and Explorer spinners correctly track unresolved subtrees. +- The preview does not change compile, terminal, persistence, or open-tab state + before the complete workspace scan succeeds. +- `npm run lint`, `npm run build`, and `npm run test:e2e` pass. diff --git a/src/ui/filesystem.js b/src/ui/filesystem.js index 125e324..4df40ae 100644 --- a/src/ui/filesystem.js +++ b/src/ui/filesystem.js @@ -199,14 +199,26 @@ export function getWorkspaceSnapshot() { * @param {FileSystemDirectoryHandle} handle * @returns {Promise<{name:string, entries:Array, git:object}>} */ -export async function openFolderFromHandle(handle, { onScanStart = null } = {}) { +export async function openFolderFromHandle(handle, { + onScanStart = null, + onScanProgress = null, +} = {}) { const requestId = ++workspaceScanRequestId; onScanStart?.(); // Allow a loading state rendered by the caller to paint before traversing a // potentially large directory tree. await yieldToBrowser(); - const scanned = await scanDirectoryHandle(handle); + const scanned = await scanDirectoryHandle(handle, { + onDepthComplete(update) { + if (requestId !== workspaceScanRequestId) return; + onScanProgress?.({ + workspace: { name: handle.name, entries: update.entries }, + completedDepth: update.completedDepth, + loadingDirectoryPaths: update.loadingDirectoryPaths, + }); + }, + }); const git = await detectGitMetadata(scanned.entries, scanned.files); // A newer open request owns the workspace. Ignore stale scan completions. @@ -808,38 +820,71 @@ function yieldToBrowser() { return new Promise((resolve) => setTimeout(resolve, 0)); } -async function scanDirectoryHandle(dirHandle, prefix = '', scan = null) { - const result = scan || { +async function scanDirectoryHandle(dirHandle, { onDepthComplete = null } = {}) { + const result = { entries: [], files: new Map(), fingerprints: new Map(), scannedEntries: 0, }; + let directoriesAtDepth = [{ handle: dirHandle, prefix: '' }]; + let completedDepth = 0; + + while (directoriesAtDepth.length) { + const nextDepth = []; + for (const directory of directoriesAtDepth) { + for await (const [name, entry] of directory.handle.entries()) { + const relPath = directory.prefix ? `${directory.prefix}/${name}` : name; + if (entry.kind === 'directory') { + result.entries.push({ path: relPath, kind: 'directory' }); + nextDepth.push({ handle: entry, prefix: relPath }); + } else if (entry.kind === 'file') { + result.entries.push({ path: relPath, kind: 'file' }); + result.files.set(relPath, { handle: entry }); + const fingerprint = await fingerprintForFileHandle(entry); + if (fingerprint) result.fingerprints.set(relPath, fingerprint); + } - for await (const [name, entry] of dirHandle.entries()) { - const relPath = prefix ? `${prefix}/${name}` : name; - if (entry.kind === 'directory') { - result.entries.push({ path: relPath, kind: 'directory' }); - await scanDirectoryHandle(entry, relPath, result); - } else if (entry.kind === 'file') { - result.entries.push({ path: relPath, kind: 'file' }); - result.files.set(relPath, { handle: entry }); - const fingerprint = await fingerprintForFileHandle(entry); - if (fingerprint) result.fingerprints.set(relPath, fingerprint); - } - - result.scannedEntries += 1; - if (result.scannedEntries % SCAN_YIELD_INTERVAL === 0) { - await yieldToBrowser(); + result.scannedEntries += 1; + if (result.scannedEntries % SCAN_YIELD_INTERVAL === 0) { + await yieldToBrowser(); + } + } } - } - if (!scan) { result.entries.sort((a, b) => a.path.localeCompare(b.path)); + onDepthComplete?.({ + entries: [...result.entries], + completedDepth, + loadingDirectoryPaths: loadingDirectoryPaths(result.entries, nextDepth), + }); + directoriesAtDepth = nextDepth; + completedDepth += 1; } return result; } +function loadingDirectoryPaths(entries, queuedDirectories) { + const indexedDirectories = new Set( + entries.filter((entry) => entry.kind === 'directory').map((entry) => entry.path) + ); + const loading = new Set(); + + for (const { prefix } of queuedDirectories) { + let path = prefix; + while (path) { + if (indexedDirectories.has(path)) loading.add(path); + path = parentWorkspacePath(path); + } + } + return [...loading].sort((a, b) => a.localeCompare(b)); +} + +function parentWorkspacePath(path) { + const index = path.lastIndexOf('/'); + return index === -1 ? '' : path.slice(0, index); +} + function replaceWorkspaceIndex({ entries, files, fingerprints }) { workspaceEntries.length = 0; workspaceEntries.push(...entries); From 0cba6b0f780f2881fca266e52e30ec9efe9ae346 Mon Sep 17 00:00:00 2001 From: Kevin Buffardi Date: Sat, 5 Sep 2026 14:23:28 -0700 Subject: [PATCH 2/2] feat: show workspace scan progress in Explorer --- scripts/e2e-workspace-file-tracking.test.mjs | 42 +++++++++++++++ src/ui/app.js | 1 + src/ui/session-persistence.mjs | 2 + src/ui/styles.css | 10 ++++ src/ui/toolbar.js | 55 +++++++++++++++++--- 5 files changed, 104 insertions(+), 6 deletions(-) diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index c02fa83..d350970 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -1107,6 +1107,48 @@ test('e2e: opening a folder keeps the old Explorer tree visible while it indexes assert.deepEqual(renderedTreePaths(ctx.document), ['new.cpp']); }); +test('e2e: Explorer preview keeps folders collapsed and shows subtree progress while scanning', async () => { + const ctx = await setupToolbar(); + await ctx.toolbar.restoreWorkspace({ + name: 'old-project', + entries: [{ path: 'old.cpp', kind: 'file' }], + }, [], null); + + ctx.controller.setExplorerLoading(true); + ctx.controller.setExplorerScanProgress({ + workspace: { + name: 'new-project', + entries: [{ path: 'src', kind: 'directory' }], + }, + loadingDirectoryPaths: ['src'], + }); + + assert.deepEqual(renderedTreePaths(ctx.document), ['src']); + let src = renderedTreeItem(ctx.document, 'src'); + assert.equal(src.getAttribute('aria-expanded'), 'false'); + assert.equal(src.getAttribute('aria-busy'), 'true'); + assert.ok(src.children.some((child) => child.className.includes('workspace-folder-progress'))); + + ctx.controller.setExplorerScanProgress({ + workspace: { + name: 'new-project', + entries: [ + { path: 'src', kind: 'directory' }, + { path: 'src/lib', kind: 'directory' }, + ], + }, + loadingDirectoryPaths: ['src', 'src/lib'], + }); + src = renderedTreeItem(ctx.document, 'src'); + src.click(); + await tick(); + + const lib = renderedTreeItem(ctx.document, 'src/lib'); + assert.ok(lib, 'expanding during scan reveals the next depth'); + assert.equal(lib.getAttribute('aria-busy'), 'true'); + assert.ok(lib.children.some((child) => child.className.includes('workspace-folder-progress'))); +}); + test('e2e: refresh keeps expanded directories but prunes directories that no longer exist', async () => { const ctx = await setupToolbar(); const initial = { diff --git a/src/ui/app.js b/src/ui/app.js index dc70799..bffe82d 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -115,6 +115,7 @@ window.addEventListener('DOMContentLoaded', async () => { confirmReload: promptReloadPreviousProject, startNewProject: resetToNewProject, setExplorerLoading: (loading) => toolbarController?.setExplorerLoading(loading), + setExplorerScanProgress: (update) => toolbarController?.setExplorerScanProgress(update), }); const persistenceGate = createPersistenceGate(persistSession); toolbarController = initToolbar(worker, editorAPI, terminalAPI, fsAPI, () => persistenceGate.persist()); diff --git a/src/ui/session-persistence.mjs b/src/ui/session-persistence.mjs index e06a135..8f569d0 100644 --- a/src/ui/session-persistence.mjs +++ b/src/ui/session-persistence.mjs @@ -150,6 +150,7 @@ export function createSessionPersistence({ confirmReload = () => true, startNewProject = () => {}, setExplorerLoading = () => {}, + setExplorerScanProgress = () => {}, }) { function filterTabContentSnapshot(session) { const entries = session?.openTabContentsByPath; @@ -219,6 +220,7 @@ export function createSessionPersistence({ try { workspace = await fsAPI.openFolderFromHandle(handle, { onScanStart: () => setExplorerLoading(true), + onScanProgress: (update) => setExplorerScanProgress(update), }); } finally { setExplorerLoading(false); diff --git a/src/ui/styles.css b/src/ui/styles.css index 32f5c9f..226f35e 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -263,6 +263,16 @@ select:focus { animation: explorer-loading-spin 0.8s linear infinite; } +.workspace-folder-label { + overflow: hidden; + text-overflow: ellipsis; +} + +.workspace-folder-progress { + margin-left: auto; + flex: 0 0 auto; +} + @keyframes explorer-loading-spin { to { transform: rotate(360deg); } } diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index 9cab3d4..7d55f3d 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -42,6 +42,10 @@ let _workspaceSyncQueued = false; let _workspaceSyncEventsBound = false; let _lastCompatibilityMessage = null; let _explorerLoading = false; +let _explorerScanPreview = null; +let _loadingDirectoryPaths = new Set(); +let _explorerScanCompletedDepth = null; +let _explorerScanPendingDirectoryCount = 0; // ── Multi-tab state ─────────────────────────────────────────────────────────── // Map @@ -108,6 +112,7 @@ export function initToolbar(worker, editorAPI, terminalAPI, fsAPI, persistSessio getLastRunBinaryBytes, setRunPreparing, setExplorerLoading, + setExplorerScanProgress, }; } @@ -1003,13 +1008,22 @@ function renderExplorerLoading(tree) { text.className = 'explorer-loading-text'; text.setAttribute('role', 'status'); text.setAttribute('aria-live', 'polite'); - text.textContent = 'Loading folder…'; + text.textContent = explorerLoadingMessage(); row.appendChild(spinner); row.appendChild(text); tree.appendChild(row); } +function explorerLoadingMessage() { + if (_explorerScanCompletedDepth == null) return 'Loading folder…'; + const level = _explorerScanCompletedDepth === 0 ? 'root' : `depth ${_explorerScanCompletedDepth}`; + const count = _explorerScanPendingDirectoryCount; + return count + ? `Loaded ${level}; scanning ${count} folder${count === 1 ? '' : 's'}…` + : `Loaded ${level}; finalizing folder…`; +} + function buildWorkspaceChildrenMap(entries) { const childrenByParent = new Map(); childrenByParent.set('', []); @@ -1041,7 +1055,7 @@ function renderWorkspaceChildren(tree, childrenByParent, parentPath, depth) { li.setAttribute('aria-level', String(depth + 1)); li.dataset.path = entry.path; li.style.paddingLeft = `${16 + depth * 14}px`; - if (_explorerLoading) { + if (_explorerLoading && entry.kind === 'file') { li.setAttribute('aria-disabled', 'true'); li.classList.add('workspace-loading'); } @@ -1049,16 +1063,26 @@ function renderWorkspaceChildren(tree, childrenByParent, parentPath, depth) { if (entry.kind === 'directory') { const isExpanded = _expandedWorkspaceDirectories.has(entry.path); li.setAttribute('aria-expanded', String(isExpanded)); - li.textContent = `${isExpanded ? '📂' : '📁'} ${workspaceBaseName(entry.path)}`; + const label = document.createElement('span'); + label.className = 'workspace-folder-label'; + label.textContent = `${isExpanded ? '📂' : '📁'} ${workspaceBaseName(entry.path)}`; + li.appendChild(label); + if (_explorerLoading && _loadingDirectoryPaths.has(entry.path)) { + li.setAttribute('aria-busy', 'true'); + li.setAttribute('aria-label', `${workspaceBaseName(entry.path)}, loading subfolders`); + const spinner = document.createElement('span'); + spinner.className = 'explorer-loading-spinner workspace-folder-progress'; + spinner.setAttribute('aria-hidden', 'true'); + li.appendChild(spinner); + } li.addEventListener('click', (event) => { - if (_explorerLoading) return; event.stopPropagation(); if (_expandedWorkspaceDirectories.has(entry.path)) { _expandedWorkspaceDirectories.delete(entry.path); } else { _expandedWorkspaceDirectories.add(entry.path); } - renderWorkspaceSidebar(_workspace); + renderWorkspaceSidebar(_explorerScanPreview ?? _workspace); }); tree.appendChild(li); @@ -1244,6 +1268,7 @@ async function openFolderWorkspace() { try { const workspace = await _fsAPI.openFolder({ onScanStart: () => setExplorerLoading(true), + onScanProgress: (update) => setExplorerScanProgress(update), }); if (!workspace) return false; clearTransientProjectState(); @@ -1270,9 +1295,27 @@ async function actionOpen() { function setExplorerLoading(loading) { _explorerLoading = Boolean(loading); + if (!_explorerLoading) { + _explorerScanPreview = null; + _loadingDirectoryPaths = new Set(); + _explorerScanCompletedDepth = null; + _explorerScanPendingDirectoryCount = 0; + } document.getElementById('btn-new').disabled = _explorerLoading; document.getElementById('btn-open').disabled = _explorerLoading; - renderWorkspaceSidebar(_workspace); + renderWorkspaceSidebar(_explorerScanPreview ?? _workspace); +} + +function setExplorerScanProgress(update) { + if (!_explorerLoading || !update?.workspace) return; + if (_explorerScanPreview?.name !== update.workspace.name) { + _expandedWorkspaceDirectories.clear(); + } + _explorerScanPreview = update.workspace; + _loadingDirectoryPaths = new Set(update.loadingDirectoryPaths || []); + _explorerScanCompletedDepth = update.completedDepth ?? null; + _explorerScanPendingDirectoryCount = _loadingDirectoryPaths.size; + renderWorkspaceSidebar(_explorerScanPreview); } // ── Session persistence helpers ───────────────────────────────────────────────