diff --git a/scripts/e2e-session-persistence.test.mjs b/scripts/e2e-session-persistence.test.mjs index 0b37f2f..b1a9257 100644 --- a/scripts/e2e-session-persistence.test.mjs +++ b/scripts/e2e-session-persistence.test.mjs @@ -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 = { @@ -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: [] }; }, }, @@ -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(); @@ -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 () => { diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index 1a0de8c..822653f 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -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'); @@ -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); @@ -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 = { diff --git a/src/ui/app.js b/src/ui/app.js index b40d746..dc70799 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -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()); diff --git a/src/ui/filesystem.js b/src/ui/filesystem.js index 18acc33..125e324 100644 --- a/src/ui/filesystem.js +++ b/src/ui/filesystem.js @@ -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; @@ -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 = [ { @@ -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; @@ -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); } /** @@ -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], @@ -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(); @@ -698,7 +705,7 @@ function openFileFallback() { }); } -function openFolderFallback() { +function openFolderFallback({ onScanStart = null } = {}) { return new Promise((resolve) => { const input = document.createElement('input'); input.type = 'file'; @@ -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(); @@ -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, @@ -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()) { @@ -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) { @@ -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; diff --git a/src/ui/session-persistence.mjs b/src/ui/session-persistence.mjs index 7a1b437..e06a135 100644 --- a/src/ui/session-persistence.mjs +++ b/src/ui/session-persistence.mjs @@ -149,6 +149,7 @@ export function createSessionPersistence({ handleStore = createIndexedDBHandleStore(), confirmReload = () => true, startNewProject = () => {}, + setExplorerLoading = () => {}, }) { function filterTabContentSnapshot(session) { const entries = session?.openTabContentsByPath; @@ -214,7 +215,14 @@ export function createSessionPersistence({ } if (permission === 'granted') { - const workspace = await fsAPI.openFolderFromHandle(handle); + let workspace; + try { + workspace = await fsAPI.openFolderFromHandle(handle, { + onScanStart: () => setExplorerLoading(true), + }); + } finally { + setExplorerLoading(false); + } if (workspace) { await restoreWorkspace( workspace, diff --git a/src/ui/styles.css b/src/ui/styles.css index 95180a0..6cd929d 100644 --- a/src/ui/styles.css +++ b/src/ui/styles.css @@ -221,6 +221,35 @@ select:focus { color: var(--blue); } +#file-tree li.workspace-loading { + cursor: progress; + opacity: 0.65; +} + +#file-tree li.explorer-loading { + color: var(--subtext0); + cursor: progress; + font-size: 12px; +} + +#file-tree li.explorer-loading:hover { + background: transparent; +} + +.explorer-loading-spinner { + width: 12px; + height: 12px; + box-sizing: border-box; + border: 2px solid var(--surface1); + border-top-color: var(--blue); + border-radius: 50%; + animation: explorer-loading-spin 0.8s linear infinite; +} + +@keyframes explorer-loading-spin { + to { transform: rotate(360deg); } +} + /* Inline VS Code-style new-file naming row */ #file-tree li.file-tree-new { flex-direction: column; diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index 748883e..ca0e16f 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -39,6 +39,7 @@ let _workspaceSyncRunning = false; let _workspaceSyncQueued = false; let _workspaceSyncEventsBound = false; let _lastCompatibilityMessage = null; +let _explorerLoading = false; // ── Multi-tab state ─────────────────────────────────────────────────────────── // Map @@ -104,6 +105,7 @@ export function initToolbar(worker, editorAPI, terminalAPI, fsAPI, persistSessio setWorker, getLastRunBinaryBytes, setRunPreparing, + setExplorerLoading, }; } @@ -355,6 +357,7 @@ async function handleWorkerMessage(data) { * Explorer rather than resetting the editor. */ async function actionNewFile() { + if (_explorerLoading) return; if (!_workspace) { let opened = false; try { @@ -962,12 +965,34 @@ function renderWorkspaceSidebar(workspace) { const tree = document.getElementById('file-tree'); if (!tree) return; tree.innerHTML = ''; + tree.setAttribute('aria-busy', String(_explorerLoading)); - const childrenByParent = buildWorkspaceChildrenMap(workspace.entries); + const childrenByParent = buildWorkspaceChildrenMap(workspace?.entries || []); renderWorkspaceChildren(tree, childrenByParent, '', 0); + if (_explorerLoading) renderExplorerLoading(tree); highlightWorkspaceFile(_fileName); } +function renderExplorerLoading(tree) { + const row = document.createElement('li'); + row.className = 'explorer-loading'; + row.setAttribute('role', 'presentation'); + + const spinner = document.createElement('span'); + spinner.className = 'explorer-loading-spinner'; + spinner.setAttribute('aria-hidden', 'true'); + + const text = document.createElement('span'); + text.className = 'explorer-loading-text'; + text.setAttribute('role', 'status'); + text.setAttribute('aria-live', 'polite'); + text.textContent = 'Loading folder…'; + + row.appendChild(spinner); + row.appendChild(text); + tree.appendChild(row); +} + function buildWorkspaceChildrenMap(entries) { const childrenByParent = new Map(); childrenByParent.set('', []); @@ -999,12 +1024,17 @@ 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) { + li.setAttribute('aria-disabled', 'true'); + li.classList.add('workspace-loading'); + } if (entry.kind === 'directory') { const isExpanded = _expandedWorkspaceDirectories.has(entry.path); li.setAttribute('aria-expanded', String(isExpanded)); li.textContent = `${isExpanded ? '📂' : '📁'} ${workspaceBaseName(entry.path)}`; li.addEventListener('click', (event) => { + if (_explorerLoading) return; event.stopPropagation(); if (_expandedWorkspaceDirectories.has(entry.path)) { _expandedWorkspaceDirectories.delete(entry.path); @@ -1023,6 +1053,7 @@ function renderWorkspaceChildren(tree, childrenByParent, parentPath, depth) { li.textContent = `📄 ${workspaceBaseName(entry.path)}`; li.addEventListener('click', (event) => { + if (_explorerLoading) return; event.stopPropagation(); void openWorkspaceFile(entry.path); }); @@ -1193,18 +1224,25 @@ function isMacPlatform() { } async function openFolderWorkspace() { - const workspace = await _fsAPI.openFolder(); - if (!workspace) return false; - clearTransientProjectState(); - closeAllTabs(); - setWorkspaceMode(workspace); - await openWorkspaceInitialFile(workspace); - renderWorkspaceSidebar(workspace); - _persistSession?.(); // persist immediately so the new workspace survives unload - return true; + try { + const workspace = await _fsAPI.openFolder({ + onScanStart: () => setExplorerLoading(true), + }); + if (!workspace) return false; + clearTransientProjectState(); + closeAllTabs(); + setWorkspaceMode(workspace); + await openWorkspaceInitialFile(workspace); + renderWorkspaceSidebar(workspace); + _persistSession?.(); // persist immediately so the new workspace survives unload + return true; + } finally { + setExplorerLoading(false); + } } async function actionOpen() { + if (_explorerLoading) return; if (hasUnsavedChanges() && !confirm('Discard unsaved changes?')) return; try { await openFolderWorkspace(); @@ -1213,6 +1251,13 @@ async function actionOpen() { } } +function setExplorerLoading(loading) { + _explorerLoading = Boolean(loading); + document.getElementById('btn-new').disabled = _explorerLoading; + document.getElementById('btn-open').disabled = _explorerLoading; + renderWorkspaceSidebar(_workspace); +} + // ── Session persistence helpers ─────────────────────────────────────────────── /** Return the workspace-relative paths of all currently open tabs. */