fuzzyworld-tools / files / vscode-web / fw-fs / extension.js
extension.js
| 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]; |
| 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; |
| 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 | } |