Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions scripts/e2e-workspace-file-tracking.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
71 changes: 71 additions & 0 deletions scripts/e2e-workspace-scan-progress.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
34 changes: 34 additions & 0 deletions specs/issue-88-progressive-workspace-scan.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
87 changes: 66 additions & 21 deletions src/ui/filesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/ui/session-persistence.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ export function createSessionPersistence({
confirmReload = () => true,
startNewProject = () => {},
setExplorerLoading = () => {},
setExplorerScanProgress = () => {},
}) {
function filterTabContentSnapshot(session) {
const entries = session?.openTabContentsByPath;
Expand Down Expand Up @@ -219,6 +220,7 @@ export function createSessionPersistence({
try {
workspace = await fsAPI.openFolderFromHandle(handle, {
onScanStart: () => setExplorerLoading(true),
onScanProgress: (update) => setExplorerScanProgress(update),
});
} finally {
setExplorerLoading(false);
Expand Down
10 changes: 10 additions & 0 deletions src/ui/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
}
Expand Down
Loading
Loading