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
6 changes: 5 additions & 1 deletion scripts/e2e-session-persistence.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ test('e2e: prompts to reload and re-requests readwrite, restoring live workspace
const handleStore = createHandleStore();
const permissionModes = [];
const restored = [];
const loadingStates = [];
let confirmCalls = 0;

const directoryHandle = {
Expand Down Expand Up @@ -302,8 +303,9 @@ test('e2e: prompts to reload and re-requests readwrite, restoring live workspace
const secondSession = createSessionPersistence({
fsAPI: {
getDirectoryHandle: () => null,
openFolderFromHandle: async (handle) => {
openFolderFromHandle: async (handle, { onScanStart }) => {
assert.equal(handle, directoryHandle);
onScanStart();
return { name: 'project', entries: [] };
},
},
Expand All @@ -320,6 +322,7 @@ test('e2e: prompts to reload and re-requests readwrite, restoring live workspace
confirmCalls += 1;
return true; // user chooses to reload the previous project
},
setExplorerLoading: (loading) => loadingStates.push(loading),
});

await secondSession.restoreSession();
Expand All @@ -329,6 +332,7 @@ test('e2e: prompts to reload and re-requests readwrite, restoring live workspace
assert.equal(restored.length, 1);
assert.deepEqual(restored[0].openTabPaths, ['bitmap.h', 'bitmap.cpp']);
assert.equal(restored[0].activeTabPath, 'bitmap.cpp');
assert.deepEqual(loadingStates, [true, false]);
});

test('e2e: choosing start-new abandons previous state and clears persisted session', async () => {
Expand Down
111 changes: 110 additions & 1 deletion scripts/e2e-workspace-file-tracking.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,51 @@ async function importFreshFilesystem() {
return import(`../src/ui/filesystem.js?fs=${Math.random()}`);
}

test('e2e: folder scanning signals progress before committing a replacement workspace', async () => {
const fs = await importFreshFilesystem();
const oldRoot = new FakeDirHandle('old-project');
oldRoot.children.set('old.cpp', new FakeFileHandle('old.cpp'));
await fs.openFolderFromHandle(oldRoot);

let releaseScan;
const newRoot = new FakeDirHandle('new-project');
newRoot.entries = async function* entries() {
await new Promise((resolve) => { releaseScan = resolve; });
yield ['new.cpp', new FakeFileHandle('new.cpp')];
};

let scanStarted = false;
const opening = fs.openFolderFromHandle(newRoot, {
onScanStart() { scanStarted = true; },
});
await new Promise((resolve) => setTimeout(resolve, 0));

assert.equal(scanStarted, true, 'the UI can enter its loading state before scan completion');
assert.equal(fs.getWorkspaceSnapshot().name, 'old-project', 'existing workspace stays available');

releaseScan();
const result = await opening;
assert.equal(result.name, 'new-project');
assert.deepEqual(result.entries, [{ path: 'new.cpp', kind: 'file' }]);
});

test('e2e: failed replacement folder scan leaves the previous workspace intact', async () => {
const fs = await importFreshFilesystem();
const oldRoot = new FakeDirHandle('old-project');
oldRoot.children.set('old.cpp', new FakeFileHandle('old.cpp'));
await fs.openFolderFromHandle(oldRoot);

const brokenRoot = new FakeDirHandle('broken-project');
brokenRoot.entries = async function* entries() {
yield* [];
throw new Error('scan failed');
};

await assert.rejects(() => fs.openFolderFromHandle(brokenRoot), /scan failed/);
assert.deepEqual(fs.getWorkspaceSnapshot().entries, [{ path: 'old.cpp', kind: 'file' }]);
assert.equal(fs.getDirectoryHandle(), oldRoot);
});

test('e2e: createWorkspaceFile writes a root file and refreshes the snapshot', async () => {
const fs = await importFreshFilesystem();
const root = new FakeDirHandle('project');
Expand Down Expand Up @@ -796,7 +841,11 @@ async function setupToolbar(fsOverrides = {}) {
if (fsOverrides.readWorkspaceFile) return fsOverrides.readWorkspaceFile(path);
return '';
},
openFolder: async () => { fsCalls.openFolder += 1; return fsOverrides.openFolderResult ?? null; },
openFolder: async (options) => {
fsCalls.openFolder += 1;
if (fsOverrides.openFolder) return fsOverrides.openFolder(options);
return fsOverrides.openFolderResult ?? null;
},
createWorkspaceFile: async (path, content) => {
fsCalls.create.push({ path, content });
if (fsOverrides.createWorkspaceFile) return fsOverrides.createWorkspaceFile(path, content);
Expand Down Expand Up @@ -961,6 +1010,66 @@ test('e2e: restored nested directories start collapsed until the user expands ea
assert.deepEqual(renderedTreePaths(ctx.document), ['src', 'src/lib', 'src/lib/util.hpp']);
});

test('e2e: Explorer shows an accessible animated loading indicator while indexing', async () => {
const ctx = await setupToolbar();
await ctx.toolbar.restoreWorkspace({
name: 'old-project',
entries: [{ path: 'old.cpp', kind: 'file' }],
}, [], null);

ctx.controller.setExplorerLoading(true);

const tree = ctx.document.getElementById('file-tree');
const loadingRow = tree.children.find((child) => child.className === 'explorer-loading');
assert.ok(loadingRow, 'loading row is rendered in the Explorer');
assert.equal(loadingRow.getAttribute('role'), 'presentation');
const loadingText = loadingRow.children.find((child) => child.className === 'explorer-loading-text');
assert.equal(loadingText.getAttribute('role'), 'status');
assert.equal(loadingText.getAttribute('aria-live'), 'polite');
assert.ok(
loadingRow.children.some((child) => child.className === 'explorer-loading-spinner'),
'loading row includes the animated spinner'
);
assert.deepEqual(renderedTreePaths(ctx.document), ['old.cpp'], 'old tree remains visible');
assert.equal(ctx.document.getElementById('btn-new').disabled, true);
assert.equal(ctx.document.getElementById('btn-open').disabled, true);

ctx.controller.setExplorerLoading(false);
assert.equal(tree.children.some((child) => child.className === 'explorer-loading'), false);
assert.equal(ctx.document.getElementById('btn-new').disabled, false);
assert.equal(ctx.document.getElementById('btn-open').disabled, false);
});

test('e2e: opening a folder keeps the old Explorer tree visible while it indexes', async () => {
let finishOpen;
const ctx = await setupToolbar({
openFolder: ({ onScanStart }) => {
onScanStart();
return new Promise((resolve) => { finishOpen = resolve; });
},
});
await ctx.toolbar.restoreWorkspace({
name: 'old-project',
entries: [{ path: 'old.cpp', kind: 'file' }],
}, [], null);

ctx.document.getElementById('btn-open').click();
await tick();
assert.ok(
ctx.document.getElementById('file-tree').children.some((child) => child.className === 'explorer-loading')
);
assert.deepEqual(renderedTreePaths(ctx.document), ['old.cpp']);

finishOpen({ name: 'new-project', entries: [{ path: 'new.cpp', kind: 'file' }] });
await tick();
await tick();
assert.equal(
ctx.document.getElementById('file-tree').children.some((child) => child.className === 'explorer-loading'),
false
);
assert.deepEqual(renderedTreePaths(ctx.document), ['new.cpp']);
});

test('e2e: refresh keeps expanded directories but prunes directories that no longer exist', async () => {
const ctx = await setupToolbar();
const initial = {
Expand Down
1 change: 1 addition & 0 deletions src/ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ window.addEventListener('DOMContentLoaded', async () => {
restoreNoWorkspaceSource,
confirmReload: promptReloadPreviousProject,
startNewProject: resetToNewProject,
setExplorerLoading: (loading) => toolbarController?.setExplorerLoading(loading),
});
const persistenceGate = createPersistenceGate(persistSession);
toolbarController = initToolbar(worker, editorAPI, terminalAPI, fsAPI, () => persistenceGate.persist());
Expand Down
103 changes: 69 additions & 34 deletions src/ui/filesystem.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import {

/** Milliseconds before a blob URL created for download is revoked. */
const BLOB_URL_REVOKE_DELAY_MS = 2_000;
/** Number of indexed entries between browser task yields. */
const SCAN_YIELD_INTERVAL = 64;

/** @type {FileSystemFileHandle|null} */
let currentHandle = null;
Expand All @@ -31,6 +33,7 @@ const workspaceEntries = [];
const workspaceFiles = new Map();
const workspaceFileFingerprints = new Map();
let workspaceGit = { isRepo: false, branch: null, remotes: [] };
let workspaceScanRequestId = 0;

const CPP_TYPES = [
{
Expand Down Expand Up @@ -82,11 +85,9 @@ export async function openFile() {
* Open a local folder and index its files/subdirectories.
* @returns {Promise<{ name: string, entries: Array<{path:string, kind:'file'|'directory'}>, git: {isRepo:boolean, branch:string|null, remotes:string[]} }|null>}
*/
export async function openFolder() {
clearWorkspace();

export async function openFolder(options = {}) {
if (!supportsDirectoryAccess()) {
return openFolderFallback();
return openFolderFallback(options);
}

let handle;
Expand All @@ -97,16 +98,7 @@ export async function openFolder() {
throw err;
}

currentDirectoryHandle = handle;
workspaceName = handle.name;
replaceWorkspaceIndex(await scanDirectoryHandle(handle));
workspaceGit = await detectGitMetadata();

return {
name: workspaceName,
entries: [...workspaceEntries],
git: workspaceGit,
};
return openFolderFromHandle(handle, options);
}

/**
Expand Down Expand Up @@ -207,12 +199,23 @@ export function getWorkspaceSnapshot() {
* @param {FileSystemDirectoryHandle} handle
* @returns {Promise<{name:string, entries:Array, git:object}>}
*/
export async function openFolderFromHandle(handle) {
clearWorkspace();
export async function openFolderFromHandle(handle, { onScanStart = 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 git = await detectGitMetadata(scanned.entries, scanned.files);

// A newer open request owns the workspace. Ignore stale scan completions.
if (requestId !== workspaceScanRequestId) return null;

currentDirectoryHandle = handle;
workspaceName = handle.name;
replaceWorkspaceIndex(await scanDirectoryHandle(handle));
workspaceGit = await detectGitMetadata();
replaceWorkspaceIndex(scanned);
workspaceGit = git;
return {
name: workspaceName,
entries: [...workspaceEntries],
Expand All @@ -222,8 +225,12 @@ export async function openFolderFromHandle(handle) {

/** Read a file from the currently opened workspace folder. */
export async function readWorkspaceFile(path) {
return readWorkspaceFileFromMap(path, workspaceFiles);
}

async function readWorkspaceFileFromMap(path, files) {
const key = normalizeWorkspacePath(path);
const item = workspaceFiles.get(key);
const item = files.get(key);
if (!item) return null;
if (item.handle) {
const file = await item.handle.getFile();
Expand Down Expand Up @@ -698,7 +705,7 @@ function openFileFallback() {
});
}

function openFolderFallback() {
function openFolderFallback({ onScanStart = null } = {}) {
return new Promise((resolve) => {
const input = document.createElement('input');
input.type = 'file';
Expand All @@ -711,22 +718,29 @@ function openFolderFallback() {
return;
}

const requestId = ++workspaceScanRequestId;
onScanStart?.();

const nextEntries = [];
const nextFiles = new Map();
const nextFingerprints = new Map();

const firstParts = (files[0].webkitRelativePath || '').split('/');
workspaceName = firstParts.length > 1 && firstParts[0]
const nextName = firstParts.length > 1 && firstParts[0]
? firstParts[0]
: 'workspace';

const dirSet = new Set();
for (const file of files) {
const full = file.webkitRelativePath || file.name;
const withoutRoot = full.startsWith(`${workspaceName}/`)
? full.slice(workspaceName.length + 1)
const withoutRoot = full.startsWith(`${nextName}/`)
? full.slice(nextName.length + 1)
: full;
const path = normalizeWorkspacePath(withoutRoot);
workspaceFiles.set(path, { file });
nextFiles.set(path, { file });
const fingerprint = fingerprintForFile(file);
if (fingerprint) workspaceFileFingerprints.set(path, fingerprint);
workspaceEntries.push({ path, kind: 'file' });
if (fingerprint) nextFingerprints.set(path, fingerprint);
nextEntries.push({ path, kind: 'file' });

const segments = path.split('/');
segments.pop();
Expand All @@ -738,11 +752,22 @@ function openFolderFallback() {
}

for (const dir of dirSet) {
workspaceEntries.push({ path: dir, kind: 'directory' });
nextEntries.push({ path: dir, kind: 'directory' });
}

workspaceEntries.sort((a, b) => a.path.localeCompare(b.path));
detectGitMetadata().then((git) => {
nextEntries.sort((a, b) => a.path.localeCompare(b.path));
detectGitMetadata(nextEntries, nextFiles).then((git) => {
if (requestId !== workspaceScanRequestId) {
resolve(null);
return;
}
currentDirectoryHandle = null;
workspaceName = nextName;
replaceWorkspaceIndex({
entries: nextEntries,
files: nextFiles,
fingerprints: nextFingerprints,
});
workspaceGit = git;
resolve({
name: workspaceName,
Expand Down Expand Up @@ -779,11 +804,16 @@ function clearWorkspace() {
currentDirectoryHandle = null;
}

function yieldToBrowser() {
return new Promise((resolve) => setTimeout(resolve, 0));
}

async function scanDirectoryHandle(dirHandle, prefix = '', scan = null) {
const result = scan || {
entries: [],
files: new Map(),
fingerprints: new Map(),
scannedEntries: 0,
};

for await (const [name, entry] of dirHandle.entries()) {
Expand All @@ -797,6 +827,11 @@ async function scanDirectoryHandle(dirHandle, prefix = '', scan = null) {
const fingerprint = await fingerprintForFileHandle(entry);
if (fingerprint) result.fingerprints.set(relPath, fingerprint);
}

result.scannedEntries += 1;
if (result.scannedEntries % SCAN_YIELD_INTERVAL === 0) {
await yieldToBrowser();
}
}

if (!scan) {
Expand Down Expand Up @@ -832,23 +867,23 @@ function fingerprintForFile(file) {
return { size, lastModified };
}

async function detectGitMetadata() {
const hasGitDir = workspaceEntries.some(
async function detectGitMetadata(entries = workspaceEntries, files = workspaceFiles) {
const hasGitDir = entries.some(
(entry) => entry.kind === 'directory' && entry.path === '.git'
);
if (!hasGitDir && !workspaceFiles.has('.git/HEAD')) {
if (!hasGitDir && !files.has('.git/HEAD')) {
return { isRepo: false, branch: null, remotes: [] };
}

let branch = null;
const head = await readWorkspaceFile('.git/HEAD');
const head = await readWorkspaceFileFromMap('.git/HEAD', files);
if (head?.startsWith('ref:')) {
const ref = head.slice(5).trim();
branch = ref.split('/').pop() || null;
}

const remotes = [];
const config = await readWorkspaceFile('.git/config');
const config = await readWorkspaceFileFromMap('.git/config', files);
if (config) {
const lines = config.split('\n');
let inRemote = false;
Expand Down
Loading
Loading