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 carrycurrentContentso 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 PWAbuildCapabilities(). Gates thefile_changedbroadcast channel only (the legacylist_files/read_filepaths remain available without it for back-compat).files_control— opt-in via VersionsPanel toggle (vsrelay_files_control_enabledin 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_failedcreate_file:file_already_exists·parent_not_found·is_directory(parent is file) ·create_failedcreate_directory:file_already_exists·parent_not_found·create_failedrename_file:file_not_found·file_already_exists·is_directory·cross_workspace_rename·rename_faileddelete_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
| API | Purpose |
|---|---|
vscode.workspace.onDidSaveTextDocument | In-buffer save → triggers debounced file_changed event |
Node fs/promises | All 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
- 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
errortext. - Concurrency state lives on the client. After a successful read, store
mtime+sha256. Send both onwrite_file. Onconcurrent_modification, render the server-providedcurrentContentas the merge target — do NOT re-fetch. - 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.
- 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 optionalexpectedSha256safety check regardless. - Content bytes never reach diagnostic logs. Logger redaction strips
contentandcurrentContentfrom 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
errorCodeas 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: 0orexpectedSha256: ''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
- Wire shapes:
packages/protocol/src/messages.ts→ search for “FPC v0.1”. - Server registry:
packages/extension/src/files-registry.ts— disk ops with atomic temp+rename writes, mtime/sha256 concurrency, watch lifecycle, path validator interface. - MessageHandler:
packages/extension/src/message-handler.ts— 7 new wire cases gated onfiles_observe/files_control. - Server broadcast:
packages/extension/src/ws-server.ts—broadcastIfCapable('files_observe', …)subscription. - Logger redaction:
packages/extension/src/logger.ts— content / currentContent / sha256 NEVER in log; pathLength + contentBytes + flags only. - PWA dispatcher:
packages/web/src/core/dispatcher.ts— extendedfile_content+ 6 new ack/event cases. - PWA store actions:
packages/web/src/store/index.ts—applyFileContent,applyWriteFileAck,applyFileChanged,saveOpenFile/forceOverwriteOpenFile,reloadOpenFileFromServer, create/rename/delete. - PWA components:
packages/web/src/components/FilesView.tsx,FileEditor.tsx(CodeMirror 6),FileMergeBanner.tsx,DeleteFileModal.tsx. - Settings toggle:
packages/web/src/components/VersionsPanel.tsx—Allow file editingcheckbox withnegotiated/reconnecting…chip.
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.