Skip to Content
Native IntegrationFiles Controller

FilesController — FPC v0.1

What this is. The mobile control plane for the workspace file tree. Phone clients can browse directories, read file content (with mtime + sha256 + binary/truncated flags), watch for live external changes, and — when the user opts in — write, create, rename, and delete files in the workspace.

Scope: browse + view + edit + create/rename/delete. Optimistic (mtime, sha256) concurrency; conflict acks carry currentContent so the renderer renders the merge UI from server data.

Why this exists

Coding-agent control on the phone is half-blind without the ability to inspect or edit the actual source. FPC v0.1 closes that gap with a doctrine-safe, capability-gated, server-authoritative file editor. The PWA uses CodeMirror 6 with statically-bundled language packs (TypeScript/JavaScript, JSON, Markdown, Python); future native iOS/Android clients ship their own editor widgets but consume the same protocol.

Capabilities

Two capability strings, additive to the existing set:

  • files_observe — always-on in PWA buildCapabilities(). Gates the file_changed broadcast channel only (the legacy list_files / read_file paths remain available without it for back-compat).
  • files_control — opt-in via VersionsPanel toggle (vsrelay_files_control_enabled in localStorage). Gates all destructive ops: write_file, create_file, create_directory, rename_file, delete_file.

Server enforces both gates regardless of client opt-in — opt-in is purely the client-side switch. Unknown clients without either capability still see the v0.x list_files / read_file legacy surface; they simply lose access to live updates and destructive ops.

Wire shapes

// FileContentMessage is extended (additive). Legacy clients ignore the new fields. interface FileContentMessage { type: 'file_content' path: string content: string // FPC v0.1 additions — load-bearing for write_file concurrency: mtime?: number size?: number sha256?: string // ALWAYS hashes the full on-disk file (NOT truncated content) isBinary?: boolean // true → UTF-8 decode failed; content === '' truncated?: boolean // true → size > 10 MB; content is head slice; sha256 still covers full file } // Client → Server interface WatchFileMessage { type: 'watch_file'; path: string } interface UnwatchFileMessage { type: 'unwatch_file'; path: string } interface WriteFileMessage { type: 'write_file'; path: string; content: string; expectedMtime?: number; expectedSha256?: string } // omit BOTH to force-overwrite interface CreateFileMessage { type: 'create_file'; path: string; content?: string } interface CreateDirectoryMessage{ type: 'create_directory';path: string } interface RenameFileMessage { type: 'rename_file'; path: string; newPath: string } interface DeleteFileMessage { type: 'delete_file'; path: string; expectedSha256?: string } // Server → Client interface WriteFileAckMessage { type: 'write_file_ack' path: string delivered: boolean errorCode?: string // OPEN string union — see taxonomy below error?: string // On delivered:true — fresh post-write state: mtime?: number sha256?: string // On errorCode='concurrent_modification' — server-provided current state: currentContent?: string currentMtime?: number currentSha256?: string seqId: number } interface CreateFileAckMessage { type: 'create_file_ack'; path: string; delivered: boolean; errorCode?; error?; mtime?: number; sha256?: string; seqId: number } interface CreateDirectoryAckMessage{ type: 'create_directory_ack'; path: string; delivered: boolean; errorCode?; error?; seqId: number } interface RenameFileAckMessage { type: 'rename_file_ack'; path: string; newPath: string; delivered: boolean; errorCode?; error?; seqId: number } interface DeleteFileAckMessage { type: 'delete_file_ack'; path: string; delivered: boolean; errorCode?; error?; seqId: number } // Broadcast — capability-scoped to 'files_observe' + per-path subscriber filter. interface FileChangedEventMessage { type: 'file_changed' path: string kind: 'modified' | 'deleted' | 'created' | (string & {}) // open union mtime?: number sha256?: string seqId: number }

errorCode taxonomy (open string union — render error verbatim on unknowns)

Cross-cutting (any ack):

  • capability_not_negotiated · path_outside_workspace · path_invalid · permission_denied

Per-op:

  • write_file: concurrent_modification · is_directory · file_too_large · write_failed
  • create_file: file_already_exists · parent_not_found · is_directory (parent is file) · create_failed
  • create_directory: file_already_exists · parent_not_found · create_failed
  • rename_file: file_not_found · file_already_exists · is_directory · cross_workspace_rename · rename_failed
  • delete_file: file_not_found · is_directory (v0.1 refuses dirs) · concurrent_modification · delete_failed

Native renderers MUST display the server-provided error string on any unknown errorCode. New codes ship server-side without a binary update.

Stable APIs only

APIPurpose
vscode.workspace.onDidSaveTextDocumentIn-buffer save → triggers debounced file_changed event
Node fs/promisesAll disk ops (stat, read, write, unlink, mkdir, rename, access)
crypto.createHash('sha256')Content hashing (concurrency + delete safety check)

No proposed APIs. No @enabledApiProposals flag. No filesystem watch beyond onDidSaveTextDocument in v0.1.

Concurrency model

Optimistic — client sends (expectedMtime, expectedSha256) on write_file; server compares; mismatch returns currentContent + currentMtime + currentSha256 so the PWA can render a merge banner from server data alone.

Force-overwrite is implicit-by-absence. Sending write_file with neither expectedMtime nor expectedSha256 bypasses the check entirely. expectedMtime: 0 is NOT force-overwrite — it’ll be compared and mismatch. This is deliberate: there’s no force: true flag for someone to accidentally pass.

sha256 always reflects the full file on disk, never the truncated content field. Large files (>10 MB) get truncated:true + a head slice in content; sha256 is computed via a streamed digest pass over the full file.

Doctrine rules clients MUST follow

  1. All errorCode + event-kind fields are open string unions. Render unknown values gracefully — known values get tailored UI, unknown values fall back to the server-provided error text.
  2. Concurrency state lives on the client. After a successful read, store mtime + sha256. Send both on write_file. On concurrent_modification, render the server-provided currentContent as the merge target — do NOT re-fetch.
  3. Force-overwrite is opt-in by the user. Wire the “Force overwrite” button to a separate code path that omits both expected fields. Don’t share state with normal save.
  4. Delete confirmation is phone-side. No Cursor-side modal (the user is at the phone). Add a confirm dialog before sending delete_file. The server enforces the optional expectedSha256 safety check regardless.
  5. Content bytes never reach diagnostic logs. Logger redaction strips content and currentContent from all log entries. The PWA should treat its own client-side telemetry the same way — never include file content in user-facing error reports or telemetry pings.

What clients MUST NOT do

  • ❌ Treat errorCode as a closed enum
  • ❌ Hardcode “if errorCode == X, do Y” branches that lack a fallback for unknown values
  • ❌ Persist the open file’s content or the merge banner to local storage (ephemeral by design; survives reload only via re-fetch)
  • ❌ Send expectedMtime: 0 or expectedSha256: '' thinking it bypasses concurrency — those are sentinel values that WILL mismatch
  • ❌ Bypass the server validator with relative paths or symlinks (the server resolves both; clients pass workspace-relative or absolute as the user sees them, never try to “pre-validate”)
  • ❌ Auto-save while editing (v0.1 is explicit save only; auto-save is a v0.2 conversation)

Reference implementation pointers

When you port this to Swift / Kotlin, the dispatcher → store flow maps cleanly to your platform’s state container. The editor is the only piece that needs platform-specific work (UITextView + syntax highlighter on iOS, Compose TextField + grammar-renderer on Android). Everything else — protocol, concurrency model, merge UX state machine — transfers verbatim.