fuzzyworld-tools / files / vscode-web / fw-fs / extension.js
extension.js
1758548c 2026-10-27 Pavel Safronov | 1 | // Read-only file system for VS Code in the browser: fuzzyworld:/<repo>[@<branch>]/<path>. |
| 2 | // | |
| 3 | // code.fuzzyworld.net publishes, for each browsable branch, files.json ({path: [blob sha, size]}) | |
| 4 | // and, for each file up to `maxBytes`, its contents at /<repo>/objects/<sha>. This extension | |
| 5 | // turns those into folders and files, plus quick open (Ctrl+P) and search across files. | |
| 6 | // Nothing can be written: the repositories change only by pushing to them. | |
| 7 | const vscode = require("vscode"); | |
| 8 | ||
| 9 | const CODE = "https://code.fuzzyworld.net"; | |
| 10 | const SEARCH_MAX_FILE = 256 * 1024; // larger files are skipped by text search | |
| 11 | const FETCHES = 8; // parallel requests during text search | |
| 12 | ||
| 13 | const manifests = new Map(); // "<repo>[@<branch>]" -> Promise<{files, dirs, maxBytes}> | |
| 14 | const contents = new Map(); // blob sha -> Promise<Uint8Array> | |
| 15 | ||
| 16 | function parse(uri) { | |
| 17 | const seg = uri.path.split("/").filter(Boolean); | |
| 18 | const root = seg[0] || ""; | |
| 19 | const at = root.indexOf("@"); | |
| 20 | return { | |
| 21 | root, | |
| 22 | repo: at < 0 ? root : root.slice(0, at), | |
| 23 | branch: at < 0 ? "" : root.slice(at + 1), | |
| 24 | rel: seg.slice(1).join("/"), | |
| 25 | }; | |
| 26 | } | |
| 27 | ||
| 28 | function manifest(uri) { | |
| 29 | const { root, repo, branch } = parse(uri); | |
| 30 | if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(repo)) throw vscode.FileSystemError.FileNotFound(uri); | |
| 31 | if (!manifests.has(root)) { | |
| 32 | const base = `${CODE}/${encodeURIComponent(repo)}` + | |
| 33 | (branch ? `/branch/${branch.split("/").map(encodeURIComponent).join("~")}` : ""); | |
| 34 | manifests.set(root, fetch(`${base}/files.json`).then(async (r) => { | |
| 35 | if (!r.ok) { | |
| 36 | manifests.delete(root); | |
| 37 | throw vscode.FileSystemError.FileNotFound(`${repo}${branch ? " (branch " + branch + ")" : ""} is not on code.fuzzyworld.net`); | |
| 38 | } | |
| 39 | const m = await r.json(); | |
| 40 | const files = new Map(Object.entries(m.files)); | |
| 41 | const dirs = new Map([["", new Map()]]); | |
| 42 | for (const p of files.keys()) { | |
| 43 | const parts = p.split("/"); | |
| 44 | for (let i = 0; i < parts.length; i++) { | |
| 45 | const dir = parts.slice(0, i).join("/"); | |
| 46 | if (!dirs.has(dir)) dirs.set(dir, new Map()); | |
| 47 | dirs.get(dir).set(parts[i], i === parts.length - 1 ? vscode.FileType.File : vscode.FileType.Directory); | |
| 48 | } | |
| 49 | } | |
| 50 | return { repo, files, dirs, maxBytes: m.maxBytes }; | |
| 51 | })); | |
| 52 | } | |
| 53 | return manifests.get(root); | |
| 54 | } | |
| 55 | ||
| 56 | async function read(m, rel, uri) { | |
| 57 | const entry = m.files.get(rel); | |
| 58 | if (!entry) throw vscode.FileSystemError.FileNotFound(uri); | |
| 59 | const [sha, size] = entry; | |
| 60 | if (size < 0) throw vscode.FileSystemError.Unavailable(`${rel} is a git submodule`); | |
| 61 | if (size > m.maxBytes) throw vscode.FileSystemError.Unavailable(`${rel} is too large to open here; clone the repository to get it`); | |
| 62 | if (!contents.has(sha)) { | |
| 63 | contents.set(sha, fetch(`${CODE}/${encodeURIComponent(m.repo)}/objects/${sha}`).then(async (r) => { | |
| 64 | if (!r.ok) { | |
| 65 | contents.delete(sha); | |
| 66 | throw vscode.FileSystemError.Unavailable(uri); | |
| 67 | } | |
| 68 | return new Uint8Array(await r.arrayBuffer()); | |
| 69 | })); | |
| 70 | } | |
| 71 | return contents.get(sha); | |
| 72 | } | |
| 73 | ||
| 74 | const readOnly = () => { throw vscode.FileSystemError.NoPermissions("code.fuzzyworld.net is read-only: clone the repository to change it"); }; | |
| 75 | ||
| 76 | const fileSystem = { | |
| 77 | onDidChangeFile: new vscode.EventEmitter().event, | |
| 78 | watch: () => new vscode.Disposable(() => {}), | |
| 79 | async stat(uri) { | |
| 80 | const m = await manifest(uri); | |
| 81 | const { rel } = parse(uri); | |
| 82 | if (m.dirs.has(rel)) return { type: vscode.FileType.Directory, ctime: 0, mtime: 0, size: 0 }; | |
| 83 | const entry = m.files.get(rel); | |
| 84 | if (!entry) throw vscode.FileSystemError.FileNotFound(uri); | |
| 85 | return { type: vscode.FileType.File, ctime: 0, mtime: 0, size: Math.max(entry[1], 0), permissions: vscode.FilePermission?.Readonly }; | |
| 86 | }, | |
| 87 | async readDirectory(uri) { | |
| 88 | const m = await manifest(uri); | |
| 89 | const dir = m.dirs.get(parse(uri).rel); | |
| 90 | if (!dir) throw vscode.FileSystemError.FileNotDirectory(uri); | |
| 91 | return [...dir.entries()]; | |
| 92 | }, | |
| 93 | async readFile(uri) { | |
| 94 | return read(await manifest(uri), parse(uri).rel, uri); | |
| 95 | }, | |
| 96 | createDirectory: readOnly, writeFile: readOnly, delete: readOnly, rename: readOnly, copy: readOnly, | |
| 97 | }; | |
| 98 | ||
| 99 | // Quick open: files whose path contains the typed characters in order. | |
| 100 | const fileSearch = { | |
| 101 | async provideFileSearchResults(query, options) { | |
| 102 | const m = await manifest(options.folder); | |
| 103 | const want = query.pattern.toLowerCase().replace(/\s+/g, ""); | |
| 104 | const out = []; | |
| 105 | for (const p of m.files.keys()) { | |
| 106 | const lower = p.toLowerCase(); | |
| 107 | let i = 0; | |
| 108 | for (const ch of lower) if (ch === want[i]) i++; | |
| 109 | if (i === want.length) { | |
| 110 | out.push(vscode.Uri.joinPath(options.folder, p)); | |
| 111 | if (options.maxResults && out.length >= options.maxResults) break; | |
| 112 | } | |
| 113 | } | |
| 114 | return out; | |
| 115 | }, | |
| 116 | }; | |
| 117 | ||
| 118 | // Search across files: fetches each text file (up to SEARCH_MAX_FILE) once, then matches line by line. | |
| 119 | const textSearch = { | |
| 120 | async provideTextSearchResults(query, options, progress, token) { | |
| 121 | const m = await manifest(options.folder); | |
| 122 | let source = query.isRegExp ? query.pattern : query.pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| 123 | if (query.isWordMatch) source = `\\b${source}\\b`; | |
| 124 | const re = new RegExp(source, query.isCaseSensitive ? "g" : "gi"); | |
| 125 | const paths = [...m.files.entries()].filter(([, [, size]]) => size >= 0 && size <= SEARCH_MAX_FILE).map(([p]) => p); | |
| 126 | const max = options.maxResults || 2000; | |
| 127 | let found = 0; | |
| 128 | let next = 0; | |
| 129 | const decoder = new TextDecoder(); | |
| 130 | async function worker() { | |
| 131 | while (next < paths.length && found < max && !token.isCancellationRequested) { | |
| 132 | const p = paths[next++]; | |
| 133 | const uri = vscode.Uri.joinPath(options.folder, p); | |
| 134 | let bytes; | |
| 135 | try { bytes = await read(m, p, uri); } catch { continue; } | |
| 136 | if (bytes.subarray(0, 8000).includes(0)) continue; // binary | |
| 137 | const lines = decoder.decode(bytes).split("\n"); | |
| 138 | for (let n = 0; n < lines.length && found < max; n++) { | |
| 139 | re.lastIndex = 0; | |
| 140 | let hit; | |
| 141 | while ((hit = re.exec(lines[n])) && found < max) { | |
| 142 | const end = hit.index + Math.max(hit[0].length, 1); | |
| 143 | progress.report({ | |
| 144 | uri, | |
| 145 | ranges: new vscode.Range(n, hit.index, n, end), | |
| 146 | preview: { text: lines[n], matches: new vscode.Range(0, hit.index, 0, end) }, | |
| 147 | }); | |
| 148 | found++; | |
| 149 | if (!hit[0].length) re.lastIndex++; | |
| 150 | } | |
| 151 | } | |
| 152 | } | |
| 153 | } | |
| 154 | await Promise.all(Array.from({ length: FETCHES }, worker)); | |
| 155 | return { limitHit: found >= max }; | |
| 156 | }, | |
| 157 | }; | |
| 158 | ||
| 159 | exports.activate = function (context) { | |
| 160 | context.subscriptions.push( | |
| 161 | vscode.workspace.registerFileSystemProvider("fuzzyworld", fileSystem, { isCaseSensitive: true, isReadonly: true })); | |
| 162 | // Proposed APIs (enabled for this extension in index.html's product configuration). | |
| 163 | if (vscode.workspace.registerFileSearchProvider) { | |
| 164 | context.subscriptions.push(vscode.workspace.registerFileSearchProvider("fuzzyworld", fileSearch)); | |
| 165 | } | |
| 166 | if (vscode.workspace.registerTextSearchProvider) { | |
| 167 | context.subscriptions.push(vscode.workspace.registerTextSearchProvider("fuzzyworld", textSearch)); | |
| 168 | } | |
| 169 | openReadme(); | |
| 170 | }; | |
| 171 | ||
| 172 | // Start with the README as text. (Markdown preview is a webview, and webviews need their own | |
| 173 | // domain per view, which this static site doesn't have yet; so it isn't opened by default.) | |
| 174 | async function openReadme() { | |
| 175 | const folder = vscode.workspace.workspaceFolders && vscode.workspace.workspaceFolders[0]; | |
db784ff5 2026-10-29 Pavel Safronov | 176 | // index.html adds a query to the folder when the address has &file=: that file is opened instead. |
| 177 | if (!folder || folder.uri.scheme !== "fuzzyworld" || folder.uri.query || vscode.window.visibleTextEditors.length) return; | |
1758548c 2026-10-27 Pavel Safronov | 178 | try { |
| 179 | const m = await manifest(folder.uri); | |
| 180 | const readme = [...m.dirs.get("").keys()].find((n) => /^readme(\.md|\.markdown|\.txt)?$/i.test(n)); | |
| 181 | if (readme) await vscode.window.showTextDocument(vscode.Uri.joinPath(folder.uri, readme), { preview: true }); | |
| 182 | } catch { | |
| 183 | // no manifest: the explorer shows the error | |
| 184 | } | |
| 185 | } |