Skip to content

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:

MethodPermissionCapability
listfiles.readfiles.read
readTextfiles.readfiles.read, operation readText
saveTextfiles.editfiles.edit
createTextfiles.createfiles.create
makeDirectory, renameEntry, removeEntryfiles.managefiles.manage
moveEntryfiles.movefiles.move

Check first, rather than calling and handling failure:

ts
const env = await client.environment.get();
const canBrowse = env.connection === "connected" && env.capabilities.includes("files.read");

Interface

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

  • path is opaque. Never split, join, normalise or guess one. Build new locations from parent and name fields the provider gave you, and use roots and home from a directory page for navigation.
  • binding identifies the connection the location came from. Passing a location from a previous connection fails with closed instead of acting on the wrong machine.
  • Two kinds of revision exist. A FileEntry.revision identifies a directory entry for move, rename and delete. A RemoteTextDocument.revision identifies document content for saveText. They are not interchangeable.

Examples

Listing a folder, page by page:

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

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

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

ts
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

CodeMessageMeaning
invalidSupply the documented file parameters only.Unexpected keys or wrong types.
invalidMove source and destination must belong to the same workspace binding.Cross-connection move.
unavailableNo file source in this workspace.The workspace has no file service.
closedThis location belongs to a previous workspace binding. Choose a destination on the current connection.The connection changed; re-read the folder.
closedDirectory listing has closed.The listing was released or the connection changed.
busyClose an existing directory listing before opening another.More than 16 listings in one window.
busyA directory page is already being read.Two reads on one listing.
abortedFile operation canceled before dispatch.Cancelled before it was sent.
abortedFile operation ended after cancellation or a connection change. A dispatched write may have completed; inspect before retrying.The outcome is genuinely unknown.
failedThe provider's own messageFor 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.

ShellCanvas documentation