Files API
client.files reads and changes files on the machine the workspace is connected to. Locations are opaque values owned by the provider, listings are paged, and every write is checked against the revision you read.
Availability
Needs a connected workspace whose provider offers file capabilities, plus the matching permission in your manifest:
| Method | Permission | Capability |
|---|---|---|
list | files.read | files.read |
readText | files.read | files.read, operation readText |
saveText | files.edit | files.edit |
createText | files.create | files.create |
makeDirectory, renameEntry, removeEntry | files.manage | files.manage |
moveEntry | files.move | files.move |
Check first, rather than calling and handling failure:
const env = await client.environment.get();
const canBrowse = env.connection === "connected" && env.capabilities.includes("files.read");Interface
interface RemoteFileLocation { binding: string; path: string }
interface RemoteEntryLocation extends RemoteFileLocation { revision: string }
interface RemoteTextDocument extends TextDocument { binding: string }
interface FileEntry {
revision?: string;
name: string;
path: string;
kind: "directory" | "file" | "symlink";
size: number;
modified: number | null; // seconds, or null when unknown
}
interface RemoteDirectoryPage {
binding: string; path: string; name: string; parent: string | null;
home: { path: string; name: string } | null;
roots: { path: string; name: string }[];
entries: FileEntry[];
}
interface AppFilesAPI {
list(location: { binding: string; path?: string }, signal?: AbortSignal): AsyncIterable<RemoteDirectoryPage>;
readText(location: RemoteFileLocation, signal?: AbortSignal): Promise<RemoteTextDocument>;
saveText(document: RemoteTextDocument, text: string, signal?: AbortSignal): Promise<RemoteTextDocument>;
createText(destination: { binding: string; parent: string; name: string; text: string }, signal?: AbortSignal): Promise<RemoteTextDocument>;
makeDirectory(destination: { binding: string; parent: string; name: string }, signal?: AbortSignal): Promise<RemoteFileLocation>;
renameEntry(entry: RemoteEntryLocation, name: string, signal?: AbortSignal): Promise<RemoteFileLocation>;
moveEntry(entry: RemoteEntryLocation, parent: RemoteFileLocation, signal?: AbortSignal): Promise<RemoteFileLocation>;
removeEntry(entry: RemoteEntryLocation, signal?: AbortSignal): Promise<void>;
}Three rules keep apps correct across providers:
pathis opaque. Never split, join, normalise or guess one. Build new locations fromparentandnamefields the provider gave you, and userootsandhomefrom a directory page for navigation.bindingidentifies the connection the location came from. Passing a location from a previous connection fails withclosedinstead of acting on the wrong machine.- Two kinds of revision exist. A
FileEntry.revisionidentifies a directory entry for move, rename and delete. ARemoteTextDocument.revisionidentifies document content forsaveText. They are not interchangeable.
Examples
Listing a folder, page by page:
const env = await client.environment.get();
if (!env.binding) throw new Error("No connection in this workspace.");
for await (const page of client.files.list({ binding: env.binding })) {
for (const entry of page.entries) {
console.log(entry.kind === "directory" ? `${entry.name}/` : entry.name, entry.size);
}
// `page.parent`, `page.home` and `page.roots` drive navigation.
}Leaving the loop — break, return or an exception — releases the listing. Abort it from outside with a signal:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
for await (const page of client.files.list({ binding, path: someFolder }, controller.signal)) { /* … */ }Read, modify and save with conflict detection:
const doc = await client.files.readText({ binding, path: entry.path });
const updated = await client.files.saveText(doc, doc.text + "\n# appended\n");
// `updated.revision` is the new revision: keep it for the next save.If someone else changed the file in the meantime, saveText fails rather than overwriting, and the message explains that your draft is intact. Keep the user's text, offer to reload, and save again against the fresh revision.
Creating a file and a folder:
await client.files.makeDirectory({ binding, parent: page.path, name: "reports" });
const created = await client.files.createText({ binding, parent: page.path, name: "notes.md", text: "# Notes\n" });Neither call replaces anything that already exists.
Errors
| Code | Message | Meaning |
|---|---|---|
invalid | Supply the documented file parameters only. | Unexpected keys or wrong types. |
invalid | Move source and destination must belong to the same workspace binding. | Cross-connection move. |
unavailable | No file source in this workspace. | The workspace has no file service. |
closed | This location belongs to a previous workspace binding. Choose a destination on the current connection. | The connection changed; re-read the folder. |
closed | Directory listing has closed. | The listing was released or the connection changed. |
busy | Close an existing directory listing before opening another. | More than 16 listings in one window. |
busy | A directory page is already being read. | Two reads on one listing. |
aborted | File operation canceled before dispatch. | Cancelled before it was sent. |
aborted | File operation ended after cancellation or a connection change. A dispatched write may have completed; inspect before retrying. | The outcome is genuinely unknown. |
failed | The provider's own message | For example a permission denial or a conflict from the server. |
Treat that second aborted message as a real warning: check the folder before retrying a write.
Lifecycle and permissions
Listings are handles owned by your window: at most 16 open at once, each delivering at most 128 entries per page within a 1 MiB envelope. Release them by leaving the loop; the desktop releases all of them if the connection or workspace changes.
A permission in your manifest authorises a call; the capability on the connection decides whether it can work. A workspace whose server has no SFTP reports no file capabilities at all, and every method above is unavailable while the rest of your app keeps working. Subscribe to environment events to re-enable your interface when a connection returns, and re-acquire locations afterwards — old ones belong to the previous binding.
Related guides
- Transfers API — moving bytes instead of editing text.
- System dialogs API — letting the user pick a file or a save location.
- Events and environment API — bindings, capabilities and change notifications.
- App lifecycle and services — the error model and cleanup rules.