diff --git a/manifest.json b/manifest.json index a2e7871..aafd69b 100644 --- a/manifest.json +++ b/manifest.json @@ -3,7 +3,7 @@ "name": "browser.cpp", "short_name": "browser.cpp", "description": "In-browser C++20 IDE powered by Monaco Editor and WASM Clang", - "version": "0.4.1", + "version": "0.4.2", "minimum_chrome_version": "105", "icons": { "16": "icons/icon16.png", diff --git a/package-lock.json b/package-lock.json index d0d023a..60cd86a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "browser.cpp", - "version": "0.4.1", + "version": "0.4.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "browser.cpp", - "version": "0.4.1", + "version": "0.4.2", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/addon-web-links": "^0.12.0", diff --git a/package.json b/package.json index a26b505..37ae02a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "browser.cpp", - "version": "0.4.1", + "version": "0.4.2", "description": "In-browser C++20 IDE with WASM Clang toolchain", "private": true, "scripts": { diff --git a/scripts/e2e-workspace-file-tracking.test.mjs b/scripts/e2e-workspace-file-tracking.test.mjs index d462c74..fc033d2 100644 --- a/scripts/e2e-workspace-file-tracking.test.mjs +++ b/scripts/e2e-workspace-file-tracking.test.mjs @@ -622,6 +622,7 @@ class FakeElement { this.children = []; this.listeners = new Map(); this.dataset = {}; + this.attributes = new Map(); this.style = {}; this._className = ''; this.classList = { @@ -693,8 +694,9 @@ class FakeElement { setAttribute(name, value) { if (name === 'id') { this.id = value; return; } if (name.startsWith('data-')) this.dataset[name.slice(5)] = value; + this.attributes.set(name, String(value)); } - getAttribute() { return null; } + getAttribute(name) { return this.attributes.get(name) ?? null; } querySelectorAll(selector) { if (selector !== 'li') return []; @@ -844,6 +846,12 @@ function renderedTreePaths(document) { .filter(Boolean) ?? []; } +function renderedTreeItem(document, path) { + return document.getElementById('file-tree') + ?.querySelectorAll('li') + .find((item) => item.dataset.path === path) ?? null; +} + function shortcutEvent(key, { metaKey = false, ctrlKey = false, shiftKey = false } = {}) { let prevented = false; return { @@ -926,6 +934,85 @@ test('e2e: submitting a nested path creates parent directories and opens the fil assert.deepEqual(ctx.fsCalls.create.map((c) => c.path), ['src/lib/util.hpp']); assert.ok(ctx.toolbar.getOpenTabPaths().includes('src/lib/util.hpp')); assert.ok(ctx.terminalCalls.refresh.length >= 1, 'terminal workspace refreshed'); + assert.deepEqual(renderedTreePaths(ctx.document), ['src', 'src/lib', 'src/lib/util.hpp']); + assert.equal(renderedTreeItem(ctx.document, 'src').getAttribute('aria-expanded'), 'true'); + assert.equal(renderedTreeItem(ctx.document, 'src/lib').getAttribute('aria-expanded'), 'true'); +}); + +test('e2e: restored nested directories start collapsed until the user expands each level', async () => { + const ctx = await setupToolbar(); + await ctx.toolbar.restoreWorkspace({ + name: 'p', + entries: [ + { path: 'src', kind: 'directory' }, + { path: 'src/lib', kind: 'directory' }, + { path: 'src/lib/util.hpp', kind: 'file' }, + ], + }, [], null); + + assert.deepEqual(renderedTreePaths(ctx.document), ['src']); + assert.equal(renderedTreeItem(ctx.document, 'src').getAttribute('aria-expanded'), 'false'); + + renderedTreeItem(ctx.document, 'src').click(); + assert.deepEqual(renderedTreePaths(ctx.document), ['src', 'src/lib']); + assert.equal(renderedTreeItem(ctx.document, 'src/lib').getAttribute('aria-expanded'), 'false'); + + renderedTreeItem(ctx.document, 'src/lib').click(); + assert.deepEqual(renderedTreePaths(ctx.document), ['src', 'src/lib', 'src/lib/util.hpp']); +}); + +test('e2e: refresh keeps expanded directories but prunes directories that no longer exist', async () => { + const ctx = await setupToolbar(); + const initial = { + name: 'p', + entries: [ + { path: 'src', kind: 'directory' }, + { path: 'src/lib', kind: 'directory' }, + { path: 'src/lib/util.hpp', kind: 'file' }, + ], + }; + await ctx.toolbar.restoreWorkspace(initial, [], null); + + renderedTreeItem(ctx.document, 'src').click(); + ctx.toolbar.applyWorkspaceSnapshot({ + name: 'p', + entries: [...initial.entries, { path: 'src/lib/deep.hpp', kind: 'file' }], + }); + + assert.deepEqual(renderedTreePaths(ctx.document), ['src', 'src/lib']); + assert.equal(renderedTreeItem(ctx.document, 'src').getAttribute('aria-expanded'), 'true'); + assert.equal(renderedTreeItem(ctx.document, 'src/lib').getAttribute('aria-expanded'), 'false'); + + ctx.toolbar.applyWorkspaceSnapshot({ name: 'p', entries: [] }); + ctx.toolbar.applyWorkspaceSnapshot(initial); + assert.equal(renderedTreeItem(ctx.document, 'src').getAttribute('aria-expanded'), 'false'); +}); + +test('e2e: external disk refresh does not expand newly added nested directories', async () => { + const ctx = await setupToolbar({ + refreshWorkspaceResult: { + snapshot: { + name: 'p', + entries: [ + { path: 'generated', kind: 'directory' }, + { path: 'generated/output.txt', kind: 'file' }, + ], + }, + added: [ + { path: 'generated', kind: 'directory' }, + { path: 'generated/output.txt', kind: 'file' }, + ], + removed: [], + changed: [], + }, + }); + await ctx.toolbar.restoreWorkspace({ name: 'p', entries: [] }, [], null); + + ctx.document.dispatch('visibilitychange'); + await tick(); + + assert.deepEqual(renderedTreePaths(ctx.document), ['generated']); + assert.equal(renderedTreeItem(ctx.document, 'generated').getAttribute('aria-expanded'), 'false'); }); test('e2e: Escape cancels inline creation and removes the row cleanly', async () => { @@ -1224,7 +1311,8 @@ test('e2e: runtime-created files appear in the refreshed workspace snapshot', as const latestWorkspace = ctx.terminalCalls.refresh.at(-1); assert.ok(latestWorkspace.entries.some((entry) => entry.path === 'generated' && entry.kind === 'directory')); assert.ok(latestWorkspace.entries.some((entry) => entry.path === createdPath && entry.kind === 'file')); - assert.deepEqual(renderedTreePaths(ctx.document), ['generated', createdPath]); + assert.deepEqual(renderedTreePaths(ctx.document), ['generated']); + assert.equal(renderedTreeItem(ctx.document, 'generated').getAttribute('aria-expanded'), 'false'); }); test('e2e: runtime file writes warn when no writable folder workspace is open', async () => { diff --git a/src/ui/toolbar.js b/src/ui/toolbar.js index c03765c..83aedc2 100644 --- a/src/ui/toolbar.js +++ b/src/ui/toolbar.js @@ -307,7 +307,7 @@ async function handleWorkerMessage(data) { } } if (snapshot) { - applyWorkspaceSnapshot(snapshot, changedPaths); + applyWorkspaceSnapshot(snapshot); await reloadOverwrittenTabs(changedPaths); } } @@ -445,7 +445,8 @@ async function submitInlineFileCreation(rawName, errorEl, onReject) { } endInlineFileCreation(); - applyWorkspaceSnapshot(result.snapshot, [result.path]); + applyWorkspaceSnapshot(result.snapshot); + revealWorkspacePath(result.path); openTabForFile(result.path, ''); markDirty(false); highlightWorkspaceFile(result.path); @@ -467,26 +468,27 @@ function inlineCreateErrorMessage(error) { /** * Adopt a refreshed workspace snapshot after an incremental mutation: update the - * in-memory workspace, refresh the terminal index (preserving cwd), expand the - * ancestor directories of any newly created paths so they are visible, and - * re-render the Explorer. + * in-memory workspace, refresh the terminal index (preserving cwd), preserve + * the user's directory expansion choices, and re-render the Explorer. * * @param {object} snapshot – workspace snapshot from filesystem.js - * @param {string[]} [revealPaths] – paths whose ancestor dirs should be expanded */ -export function applyWorkspaceSnapshot(snapshot, revealPaths = []) { +export function applyWorkspaceSnapshot(snapshot) { if (!snapshot) return; _workspace = snapshot; _terminalAPI.refreshWorkspace?.(snapshot); pruneExpandedWorkspaceDirectories(snapshot); - for (const path of revealPaths) { - for (const dir of directoriesForPath(path)) { - _expandedWorkspaceDirectories.add(dir); - } - } renderWorkspaceSidebar(snapshot); } +/** Reveal a path created through the Explorer's explicit New File action. */ +function revealWorkspacePath(path) { + for (const dir of directoriesForPath(path)) { + _expandedWorkspaceDirectories.add(dir); + } + renderWorkspaceSidebar(_workspace); +} + function pruneExpandedWorkspaceDirectories(snapshot) { const existingDirs = new Set( (snapshot.entries || []) @@ -511,8 +513,7 @@ async function syncWorkspaceFromDisk(reason) { if (!result?.snapshot) return null; if (!hasWorkspaceDiff(result)) return result; - const revealPaths = (result.added || []).map((entry) => normalizeOverlayPath(entry.path)); - applyWorkspaceSnapshot(result.snapshot, revealPaths); + applyWorkspaceSnapshot(result.snapshot); const changedPaths = (result.changed || []).map((entry) => normalizeOverlayPath(entry.path)); if (changedPaths.length) { @@ -548,7 +549,7 @@ function hasWorkspaceDiff(result) { async function persistWorkspaceFile(path, bytes) { try { const snapshot = await _fsAPI.writeWorkspaceFile(path, bytes); - if (snapshot) applyWorkspaceSnapshot(snapshot, [normalizeOverlayPath(path)]); + if (snapshot) applyWorkspaceSnapshot(snapshot); await syncWorkspaceFromDisk('compile-result'); } catch (err) { console.warn('[browser.cpp] Failed to persist workspace file:', path, err);