
# Workspace, files and storage

`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

```ts
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

```ts
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:

```ts
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:

```ts
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](/api/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:

```ts
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:

```ts
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

```ts
console.log(vscode.workspace.workspaceFolders?.[0].uri.fsPath); // always the one root
console.log(vscode.workspace.name); // whatever the host named it, or undefined
```

`workspace.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:

```ts
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:

```ts
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

```ts
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

```ts
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

```ts
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](/guide/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

```ts
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:

```ts
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.
