Workspace, files and storage
workspace.fs, watching and serving files, opening a workspace of your own, configuration, storage and secrets
workspace is the tree the reader has open, and everything an extension can read, write or
remember about it. This page covers the filesystem, finding and watching files, serving
documents of your own, opening a second workspace, settings, and the three places an extension
can keep state.
#workspace.fs
const uri = vscode.Uri.file("/notes/todo.md");
const bytes = await vscode.workspace.fs.readFile(uri);
console.log(new TextDecoder().decode(bytes));
await vscode.workspace.fs.createDirectory(vscode.Uri.file("/notes"));
await vscode.workspace.fs.writeFile(uri, new TextEncoder().encode("# Todo\n\n- [ ] write docs\n"));
const entries = await vscode.workspace.fs.readDirectory(vscode.Uri.file("/notes"));
for (const [name, type] of entries) {
console.log(name, type === vscode.FileType.Directory ? "dir" : "file");
}workspace.fs reads and writes bytes, async throughout, addressed by Uri rather than a path
string. A file: URI is the workbench's own tree; Uri.file(path) builds one. The full surface:
stat, readDirectory, createDirectory, readFile, writeFile, delete, rename,
copy(source, target, options?). delete is always recursive; rename never overwrites, and
copy only where { overwrite: true } says to. A call that can't complete rejects rather than
returning something to check.
#Finding and watching
const tests = await vscode.workspace.findFiles("**/*.test.ts", "**/node_modules/**");
const watcher = vscode.workspace.createFileSystemWatcher("**/*.css");
context.subscriptions.push(
watcher,
watcher.onDidChange((uri) => console.log("changed", uri.fsPath)),
watcher.onDidCreate((uri) => console.log("created", uri.fsPath)),
watcher.onDidDelete((uri) => console.log("deleted", uri.fsPath)),
);findFiles(include, exclude?, maxResults?) is a one-shot glob search. createFileSystemWatcher
answers ongoing change: pass true for any of the three trailing flags
(ignoreCreateEvents/ignoreChangeEvents/ignoreDeleteEvents) to skip that kind entirely.
For events rather than a query, workspace also fires directly on every write to the tree —
cheaper than a glob watcher for an extension that only wants to be told:
context.subscriptions.push(
vscode.workspace.onDidChangeTextDocument((event) =>
console.log("edited", event.document.uri.fsPath),
),
vscode.workspace.onDidCreateFiles((event) =>
event.files.forEach((uri) => console.log("new", uri.fsPath)),
),
vscode.workspace.onDidDeleteFiles((event) =>
event.files.forEach((uri) => console.log("gone", uri.fsPath)),
),
vscode.workspace.onDidRenameFiles((event) =>
event.files.forEach(({ oldUri, newUri }) => console.log(oldUri.fsPath, "→", newUri.fsPath)),
),
);Each of those three has an onWill… counterpart (onWillCreateFiles, onWillDeleteFiles,
onWillRenameFiles), fired before the gesture happens with a waitUntil(edit) — the way a language
server fixes up imports in the moment a file is renamed:
vscode.workspace.onWillRenameFiles((event) => {
for (const { oldUri, newUri } of event.files) {
const edit = new vscode.WorkspaceEdit();
// ... build fixups for oldUri → newUri
event.waitUntil(Promise.resolve(edit));
}
});onDidSaveTextDocument is the reader's own Mod-S; onWillSaveTextDocument is covered on
Editors alongside the rest of saving. codelet.workspace.onDidExpandDirectory
fires when the reader unfolds a directory in the explorer — the one thing here that has no
vscode equivalent, VS Code's explorer being chrome no extension can reach.
#Serving documents of your own
Two ways to answer for a scheme other than file: — one provider per scheme, and a scheme a
FileSystemProvider already holds is refused rather than shadowed.
registerFileSystemProvider is the fuller shape, for anything that looks like a real filesystem
— a .d.ts fetched from a CDN:
const provider: vscode.FileSystemProvider = {
onDidChangeFile: new vscode.EventEmitter<vscode.FileChangeEvent[]>().event,
watch: () => new vscode.Disposable(() => {}),
stat: async () => ({
type: vscode.FileType.File,
ctime: 0,
mtime: 0,
size: 0,
permissions: vscode.FilePermission.Readonly,
}),
readFile: async (uri) => {
const response = await fetch(`https://esm.sh/${uri.path}`);
if (!response.ok) throw vscode.FileSystemError.FileNotFound(uri);
return new Uint8Array(await response.arrayBuffer());
},
readDirectory: () => [],
createDirectory: () => {},
writeFile: () => {},
delete: () => {},
rename: () => {},
};
context.subscriptions.push(
vscode.workspace.registerFileSystemProvider("cdn-types", provider, { isReadonly: true }),
);All nine members have to be there — every one VS Code's own FileSystemProvider declares — even
though codelet only calls stat and readFile for a document like this; the rest exist so a
provider written for VS Code compiles unchanged. isReadonly: true refuses every edit to a tab
opened on this scheme; leave it off and edits reach writeFile as they're typed, since there's no
buffer here to batch them into a single write later.
registerTextDocumentContentProvider is the same seam without the filesystem shape, for a
document that's computed rather than fetched:
context.subscriptions.push(
vscode.workspace.registerTextDocumentContentProvider("shout", {
provideTextDocumentContent: async (uri) => {
const source = await vscode.workspace.openTextDocument(vscode.Uri.file(uri.path));
return source.getText().toUpperCase();
},
}),
);
// vscode.window.showTextDocument(vscode.Uri.parse("shout:/notes/todo.md"));A document served either way is never part of the tree: it never goes dirty, Mod-S on it is a
no-op, and findFiles/search see the tree alone.
#Workspaces
console.log(vscode.workspace.workspaceFolders?.[0].uri.fsPath); // always the one root
console.log(vscode.workspace.name); // whatever the host named it, or undefinedworkspace.workspaceFolders is always the one root — codelet has no multi-root model, a path
being a file's whole identity — but the array shape matches VS Code's, so
workspaceFolders?.[0] still reads naturally.
An extension can also open a tree of its own in place of the reader's, empty until you fill it:
const handle = codelet.workspace.open();
await vscode.workspace.fs.writeFile(
vscode.Uri.file("/README.md"),
new TextEncoder().encode("# Mirrored\n"),
);
// ... later, back to the reader's own files:
handle.dispose();Once open, your extension's own vscode.workspace — fs, the watchers, every document event —
follows whichever tree is showing, so opening one is by that fact reading and writing nothing
else. Nothing is lost from the tree underneath: the reader's tabs, carets and unsaved text are
still there when you dispose() it. Stopping the extension closes it too, so a tree is never
left showing with nothing running to explain it.
An extension that has to stay pointed at the reader's own tree regardless of what's showing — mirroring it to a server, say — pins itself in the manifest instead of following:
manifest: {
name: "my-remote",
workspace: "home",
// ...
},workspace defaults to "showing". An extension pinned "home" never hears
codelet.workspace.onDidChangeWorkspace — its tree doesn't change — where a "showing"
extension hears it whenever a workspace opens over the reader's own or closes, which is the
moment to re-sweep anything it cached from workspace.fs.
#Configuration
const config = vscode.workspace.getConfiguration("myExt");
console.log(config.get<string>("greeting", "hi"));
await config.update("greeting", "hello");
context.subscriptions.push(
vscode.workspace.onDidChangeConfiguration((event) => {
if (event.affectsConfiguration("myExt.greeting")) console.log("greeting changed");
}),
);getConfiguration(section?) reads over two layers: every manifest's declared
contributes.configuration defaults underneath, and whatever the reader has written over them.
A section is a prefix and nothing more — getConfiguration("myExt").get("greeting") and
getConfiguration().get("myExt.greeting") are the same key. inspect(key) splits the two apart
(defaultValue, globalValue); update writes the top layer and fires
onDidChangeConfiguration for that key, whether you wrote it or the host's own settings store
reports somebody else did.
Note
There's one written layer and one tree, so ConfigurationTarget is here (update's third
argument) but only Global means anything — Workspace and WorkspaceFolder land in the same
place. There's no ConfigurationScope and no [typescript]-style per-language section.
#Storage
await context.globalState.update(
"installCount",
(context.globalState.get<number>("installCount") ?? 0) + 1,
);
await context.workspaceState.update("lastOpenedFile", "/src/index.ts");
console.log(context.workspaceState.keys());globalState and workspaceState are both get/update/keys, undefined taking a key out.
globalState survives a reload; workspaceState is the same, per tree — but only the reader's
own tree is written down. A workspace your extension opened is a fresh tree on the next page
load too, so its workspaceState lives only as long as the page does rather than handing a
reload a stranger's state.
#Secrets
const token = await context.secrets.get("apiToken");
if (!token) {
const typed = await vscode.window.showInputBox({ prompt: "API token", password: true });
if (typed) await context.secrets.store("apiToken", typed);
}
context.subscriptions.push(context.secrets.onDidChange(({ key }) => console.log(`${key} changed`)));context.secrets is get/store/delete, plus onDidChange. The honest guarantee: by
default a secret lives in the page and nothing more — it doesn't survive a reload, and it's as
legible as anything else running here, since a browser tab is one security principal. A host
that wants more can pass a store that seals secrets at rest (webauthnSecrets() is the one that
ships); either way the plaintext lives in the page, never in localStorage under an API that
implies otherwise. See Settings for setting that up as a host.
Warning
Never log a secret. And don't read one to decide what to draw at activation — a status item, a badge — since a sealed store may need to ask the reader to unlock it first, and activation is the wrong moment for that prompt. Read a secret only where the answer is what the reader came for.
#Environment
console.log(vscode.env.appName, vscode.env.uiKind === vscode.UIKind.Web);
await vscode.env.clipboard.writeText("copied!");
await vscode.env.openExternal(vscode.Uri.parse("https://example.com"));| Member | Description |
|---|---|
appName / appHost | "codelet" and "web" — an extension branching on the name is asking where it runs. |
uiKind | Always UIKind.Web. |
language | The browser's own display language. |
clipboard | readText()/writeText() over navigator.clipboard; rejects where the browser gives the page none. |
openExternal(uri) | Opens a new tab, resolving whether it opened — a blocked popup is false. |
uriScheme | The page's own protocol. |
asExternalUri(uri) | Turns an address into one that reaches this workbench from outside it. |
uriScheme and asExternalUri are one mechanism, used together: a page has no scheme an
operating system routes back to it, so a callback built out of uriScheme alone would be a live
address on a domain the extension doesn't own. asExternalUri resolves through the host's own
externalUri option instead — a workbench given none rejects the call, rather than handing back
a URL that goes somewhere else. Pair it with window.registerUriHandler for the way back in:
const callback = await vscode.env.asExternalUri(
vscode.Uri.parse(`${vscode.env.uriScheme}://myExt/callback`),
);
await vscode.env.openExternal(
vscode.Uri.parse(`https://auth.example.com/login?callback=${callback}`),
);
context.subscriptions.push(
vscode.window.registerUriHandler({
handleUri(uri) {
const code = new URLSearchParams(uri.query).get("code");
console.log("signed in with", code);
},
}),
);Routing a link back into the page is the embedding host's business, not codelet's — handleUri
only runs where the host calls Workbench.handleUri(uri) when one arrives. A host that never
does costs nothing: registerUriHandler still returns a real Disposable, and the handler
never fires.