Skip to content

Commit 5393933

Browse files
committed
web: "Open Folder from Your Computer" reaches the browser's folder picker
Run for the first time against a real browser session and the real login page, the command did nothing useful from the default scratch workspace: the workbench chooses between its own file browser and the browser's picker by the scheme of the default location, and in the scratch workspace that scheme is ours — so it showed the scratch workspace's own folders. The command now asks the dialog from the `file` scheme, which the web workbench serves with the File System Access handles the user picks, and opens what came back in this tab. Tests: a unit test of the command against a stand-in for vscode (mutation-checked), and a section of the end-to-end check that opens a folder through a real FileSystemDirectoryHandle (the origin-private file system stands in for the OS picker) and has the agent list, read and write there.
1 parent 8282fd0 commit 5393933

3 files changed

Lines changed: 130 additions & 4 deletions

File tree

‎web/test/e2e.mjs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,44 @@ try {
231231
check('after a reload the editor is still signed in', after > before, `${before} -> ${after}`);
232232
check('after a reload the file that was kept is still there', (await scratchFile('/scratch/notes.md')) === 'notes\n');
233233
await shot(page, '4-reload');
234+
235+
/* 5. a folder from "the computer". The OS picker needs a person, so the page's File System Access entry point is
236+
replaced by one that hands back a real FileSystemDirectoryHandle (a folder of the origin-private file system);
237+
everything after the picker — the command, the workbench's file provider, the agent's reads and writes — is real. */
238+
await page.eval(`(async () => {
239+
const root = await navigator.storage.getDirectory();
240+
try { await root.removeEntry('project', { recursive: true }); } catch (e) {}
241+
const dir = await root.getDirectoryHandle('project', { create: true });
242+
const f = await dir.getFileHandle('main.py', { create: true });
243+
const w = await f.createWritable(); await w.write('print("hello from main.py")\\n'); await w.close();
244+
window.showDirectoryPicker = async () => { const r = await navigator.storage.getDirectory(); return r.getDirectoryHandle('project', { create: true }); };
245+
return true;
246+
})()`);
247+
await page.key('F1', 'F1');
248+
await page.waitFor(() => page.eval(`!!document.querySelector('.quick-input-widget:not([style*="display: none"]) .quick-input-box input')`), { ms: 8000, label: 'command palette' });
249+
await page.type('Open Folder from Your Computer');
250+
await sleep(900);
251+
await page.key('Enter', 'Enter');
252+
const folderRows = await page.waitFor(async () => {
253+
const r = await page.eval(`[...document.querySelectorAll('.explorer-folders-view .monaco-list-row')].map((e) => e.getAttribute('aria-label'))`);
254+
return r.some((x) => /main\.py/.test(x)) ? r : null;
255+
}, { ms: 90000, label: 'the folder in the Explorer' }).catch(() => null);
256+
check('"Open Folder from Your Computer" opens the folder the picker returned, and the Explorer lists its file', !!folderRows, JSON.stringify(folderRows));
257+
const folderWindow = await page.eval('document.title');
258+
check('the workspace is that folder (the window title), not the scratch workspace', /project/.test(String(folderWindow)) && !/scratch/i.test(String(folderWindow)), JSON.stringify(folderWindow));
259+
await shot(page, '5-folder');
260+
const opfs = (name) => page.eval(`(async () => { try { const root = await navigator.storage.getDirectory(); const dir = await root.getDirectoryHandle('project'); const f = await dir.getFileHandle(${JSON.stringify(name)}); return await (await f.getFile()).text(); } catch (e) { return null; } })()`);
261+
await activateTab(/LevelCode AI/);
262+
await page.waitFor(async () => (await inChat('!!d.getElementById("send")')) === true, { ms: 30000, label: 'chat webview' });
263+
await ask('Please list the files');
264+
check('list_files sees the folder\'s files (the workbench searches a File System Access folder)', /main\.py/.test(lastToolResult()), JSON.stringify(lastToolResult().slice(0, 120)));
265+
await ask('Please read main.py');
266+
check('read_file reads a file of the folder through the browser\'s file handle', /hello from main\.py/.test(lastToolResult()), JSON.stringify(lastToolResult().slice(0, 120)));
267+
await ask('Please make a notes file');
268+
let notes = null;
269+
for (let i = 0; i < 20 && notes === null; i++) { notes = await opfs('notes.md'); if (notes === null) { await sleep(500); } }
270+
check('write_file creates the file in the folder on "the computer"', notes === 'notes\n', JSON.stringify(notes));
271+
await shot(page, '5b-folder-agent');
234272
} catch (e) {
235273
check('the run completed', false, String((e && e.stack) || e));
236274
}

‎web/test/unit/workspace.test.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* web/workspace/extension.js — run: node web/test/unit/workspace.test.js
3+
*
4+
* The scratch-workspace extension is evaluated as ONE file by the web extension host, so it cannot be split into
5+
* modules; this test loads that file against a stand-in for `vscode` and runs what it exports.
6+
*
7+
* openLocalFolder must ask the dialog from the `file` scheme. From the scratch scheme the workbench shows its
8+
* own file browser instead of the browser's picker (the command did nothing useful until
9+
* it was run in a real browser).
10+
*--------------------------------------------------------------------------------------------*/
11+
// @ts-check
12+
'use strict';
13+
14+
const assert = require('assert');
15+
const path = require('path');
16+
const Module = require('module');
17+
18+
class Uri {
19+
constructor(scheme, p) { this.scheme = scheme; this.path = p; }
20+
static from(c) { return new Uri(c.scheme, c.path || '/'); }
21+
toString() { return this.scheme + '://' + this.path; }
22+
}
23+
const calls = { dialogs: /** @type {any[]} */ ([]), commands: /** @type {any[][]} */ ([]) };
24+
let nextPick = /** @type {any} */ (undefined);
25+
const vscodeMock = {
26+
Uri,
27+
window: { async showOpenDialog(o) { calls.dialogs.push(o); return nextPick; } },
28+
commands: { async executeCommand(...a) { calls.commands.push(a); } },
29+
};
30+
const origLoad = Module._load;
31+
// @ts-ignore
32+
Module._load = function (request, parent, isMain) { return request === 'vscode' ? vscodeMock : origLoad.call(this, request, parent, isMain); };
33+
const ext = require(path.join(__dirname, '..', '..', 'workspace', 'extension.js'));
34+
35+
let n = 0;
36+
async function test(name, fn) { calls.dialogs.length = 0; calls.commands.length = 0; await fn(); n++; console.log(' ok - ' + name); }
37+
38+
(async () => {
39+
await test('the picker is asked for a folder, starting from the file scheme, not the scratch scheme', async () => {
40+
nextPick = [new Uri('file', '/my-project')];
41+
await ext.openLocalFolder(vscodeMock);
42+
assert.strictEqual(calls.dialogs.length, 1);
43+
const o = calls.dialogs[0];
44+
assert.strictEqual(o.defaultUri.scheme, 'file', 'a default location in another scheme gets the workbench\'s own file browser');
45+
assert.strictEqual(o.canSelectFolders, true);
46+
assert.strictEqual(o.canSelectFiles, false);
47+
assert.strictEqual(o.canSelectMany, false);
48+
});
49+
await test('the folder that was picked becomes the workspace, in this tab', async () => {
50+
const picked = new Uri('file', '/my-project');
51+
nextPick = [picked];
52+
assert.strictEqual(await ext.openLocalFolder(vscodeMock), true);
53+
assert.deepStrictEqual(calls.commands, [['vscode.openFolder', picked, { forceReuseWindow: true }]]);
54+
});
55+
await test('cancelling the picker does nothing', async () => {
56+
for (const none of [undefined, []]) {
57+
nextPick = none;
58+
calls.commands.length = 0;
59+
assert.strictEqual(await ext.openLocalFolder(vscodeMock), false);
60+
assert.deepStrictEqual(calls.commands, []);
61+
}
62+
});
63+
console.log(`\n${n} tests passed`);
64+
})().catch((e) => { console.error(e); process.exit(1); });

‎web/workspace/extension.js‎

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -388,9 +388,7 @@ async function activate(context) {
388388
context.subscriptions.push(item, vscode.workspace.onDidChangeWorkspaceFolders(refresh));
389389

390390
context.subscriptions.push(
391-
// The workbench owns the picker (File System Access lives on the window, not in this worker)
392-
// and tells the user itself when the browser has no support for it.
393-
vscode.commands.registerCommand('levelcode.web.openLocalFolder', () => vscode.commands.executeCommand('workbench.action.files.openFolder')),
391+
vscode.commands.registerCommand('levelcode.web.openLocalFolder', () => openLocalFolder(vscode)),
394392
vscode.commands.registerCommand('levelcode.web.openScratch', () => vscode.commands.executeCommand(
395393
'vscode.openFolder', vscode.Uri.from({ scheme: SCHEME, path: HOME }), { forceReuseWindow: true })),
396394
vscode.commands.registerCommand('levelcode.web.aboutScratch', async () => {
@@ -403,6 +401,32 @@ async function activate(context) {
403401
);
404402
}
405403

404+
/**
405+
* Open a folder from the user's computer: the browser's own directory picker, then that folder as the workspace.
406+
*
407+
* The workbench chooses between its own file browser and the browser's picker by the SCHEME of the default
408+
* location, and from inside the scratch workspace that scheme is this extension's: `File: Open Folder` would show
409+
* the scratch workspace's own folders and never reach the picker. A dialog that starts from the `file` scheme —
410+
* which the web workbench serves with the File System Access handles the user picks — does. A browser without
411+
* File System Access (Safari, Firefox) is told so by the workbench itself, and nothing here runs after that.
412+
*
413+
* @param {typeof import('vscode')} api
414+
* @returns {Promise<boolean>} true when a folder was opened; false when the user cancelled
415+
*/
416+
async function openLocalFolder(api) {
417+
const picked = await api.window.showOpenDialog({
418+
canSelectFolders: true,
419+
canSelectFiles: false,
420+
canSelectMany: false,
421+
defaultUri: api.Uri.from({ scheme: 'file', path: '/' }),
422+
openLabel: 'Open Folder',
423+
title: 'Open a folder from your computer',
424+
});
425+
if (!picked || picked.length === 0) { return false; }
426+
await api.commands.executeCommand('vscode.openFolder', picked[0], { forceReuseWindow: true });
427+
return true;
428+
}
429+
406430
function deactivate() { }
407431

408-
module.exports = { activate, deactivate };
432+
module.exports = { activate, deactivate, openLocalFolder };

0 commit comments

Comments
 (0)