fuzzyworld

fuzzyworld-tools / files / vscode-web / fw-fs / extension.js

extension.js

7.9 KB · history · blame

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.
7const vscode = require("vscode");
8
9const CODE = "https://code.fuzzyworld.net";
10const SEARCH_MAX_FILE = 256 * 1024; // larger files are skipped by text search
11const FETCHES = 8; // parallel requests during text search
12
13const manifests = new Map(); // "<repo>[@<branch>]" -> Promise<{files, dirs, maxBytes}>
14const contents = new Map(); // blob sha -> Promise<Uint8Array>
15
16function 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
28function 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
56async 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
74const readOnly = () => { throw vscode.FileSystemError.NoPermissions("code.fuzzyworld.net is read-only: clone the repository to change it"); };
75
76const 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.
100const 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.
119const 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
159exports.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.)
174async 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}