24 KiB
Unified FileModel for Local and Remote File Persistence
Problem
LocalFileModel only supports local filesystem operations. When the agent generates file edits for a remote SSH session, the diff view needs to write files to the remote host via RemoteServerClient, not to the local disk. Today this is impossible because LocalFileModel::save() and delete() always call async_fs::write / async_fs::remove_file on local paths.
The goal is to extend LocalFileModel into a unified FileModel that dispatches to local or remote backends based on how the file was registered, so all existing consumers (InlineDiffView, LocalCodeEditorView, GlobalBufferModel, ServerModel) continue using the same FileId + FileModelEvent event pattern without any changes to their subscription logic.
Relevant code
app/src/code/inline_diff.rs—InlineDiffViewstruct and allLocalFileModelusageapp/src/code/diff_viewer.rs (113-171)—DiffViewertrait withaccept_and_save_diff,restore_diff_basedefaultsapp/src/ai/blocklist/inline_action/code_diff_view.rs (954-1005)—set_candidate_diffs()which constructsInlineDiffViewand callsregister_file()crates/warp_files/src/lib.rs (282-315, 548-706)—LocalFileModel::register_file_path(),save(),delete()crates/warp_util/src/standardized_path.rs—StandardizedPathfor platform-aware remote pathsapp/src/remote_server/manager.rs—RemoteServerManagerandRemoteServerClient(future remote implementation)
Current state
InlineDiffView manages file persistence through a backing_file_id: Option<FileId> field:
- Construction:
InlineDiffView::new()creates the view withbacking_file_id: None(read-only). - Registration:
register_file()(non-WASM only) callsLocalFileModel::register_file_path(), stores theFileId, subscribes toFileModelEvent::FileSaved/FailedToSave, and re-emits them asInlineDiffViewEvent. - Save:
save_content()callsLocalFileModel::save(file_id, content, version). - Delete:
restore_diff_base()callsLocalFileModel::delete(file_id, version)for new files orLocalFileModel::save(file_id, base_content, version)for existing files. - Editability gate:
backing_file_id.is_some()determines whether the editor is editable and whether accept/save/revert are allowed.
The coupling to LocalFileModel is spread across:
register_file()— 6LocalFileModelcalls + event subscription (lines 100-136)save_content()— 1LocalFileModel::save()call (lines 166-181)restore_diff_base()— 2LocalFileModelcalls (save or delete) (lines 238-282)
All three are gated behind #[cfg(not(target_family = "wasm"))]. On WASM, backing_file_id stays None and all operations are no-ops.
Proposed changes
Unified FileModel singleton (replaces per-view trait)
Instead of a BackingFileStore trait with callbacks, extend the existing LocalFileModel singleton into a unified FileModel that handles both local and remote file entries. All consumers (InlineDiffView, LocalCodeEditorView, GlobalBufferModel, ServerModel) continue to use the same FileId + FileModelEvent pattern they already use today.
Why not a trait with callbacks
A trait-based approach (BackingFileStore) was considered but rejected because:
- Callbacks can't carry mutable
ViewContext/ModelContext, making it impossible to emit events or access singletons from within the completion handler. - All four consumers already subscribe to
FileModelEventviactx.subscribe_to_model(&file_model, ...)and filter byFileId. This pattern works with any context type. - Introducing a new abstraction layer adds complexity when the existing singleton event pattern already solves the problem.
Design: extend LocalFileModel to handle remote files
Rename LocalFileModel to FileModel. Internally, each FileId is backed by either a local file entry (existing LocalFile struct) or a remote file entry:
/// Per-file backing store. Local files use std::fs via async_fs.
/// Remote files use RemoteServerClient RPCs.
enum FileBackend {
Local(LocalFile),
Remote {
/// Identifies the remote host. The actual client is looked up from
/// RemoteServerManager at call time, which naturally handles
/// disconnect (lookup returns None) without holding the Arc alive.
host_id: HostId,
/// Platform-aware path on the remote host.
path: StandardizedPath,
},
}
The FileModel (née LocalFileModel) stores HashMap<FileId, FileBackend> instead of HashMap<FileId, LocalFile>. The public API stays the same:
impl FileModel {
/// Register a local file path. Existing behavior, unchanged.
pub fn register_file_path(
&mut self, path: &Path, subscribe_to_updates: bool, ctx: ...
) -> FileId;
/// Register a remote file path. New.
pub fn register_remote_file(
&mut self, host_id: HostId, path: StandardizedPath,
) -> FileId;
/// Save content. Dispatches to local async_fs::write or remote WriteFile RPC
/// based on the FileId's backend. Emits FileModelEvent::FileSaved / FailedToSave.
pub fn save(
&mut self, file_id: FileId, content: String, version: ContentVersion, ctx: ...
) -> Result<(), FileSaveError>;
/// Delete a file. Dispatches to local async_fs::remove_file or remote DeleteFile RPC.
pub fn delete(
&mut self, file_id: FileId, version: ContentVersion, ctx: ...
) -> Result<(), FileSaveError>;
}
save() and delete() check the FileBackend variant and dispatch accordingly:
FileBackend::Local: existing code path (async_fs::write, ensure_parent_directories)FileBackend::Remote: look upArc<RemoteServerClient>fromRemoteServerManagerviahost_idat call time. If the client is connected, spawn async task callingclient.write_file(path, content).awaitorclient.delete_file(path).await, emitFileModelEvent::FileSaved/FailedToSaveon completion. If disconnected, emitFailedToSaveimmediately with a descriptive error.
Both paths emit the same FileModelEvent, so all downstream subscribers work unchanged.
What changes for each consumer
| Consumer | Change |
|---|---|
InlineDiffView |
Calls FileModel::register_file_path() for local or FileModel::register_remote_file() for remote. Stores FileId. All existing event subscriptions unchanged. |
LocalCodeEditorView |
LocalFileModel::handle(ctx) → FileModel::handle(ctx). No other changes. |
GlobalBufferModel |
Same rename. All FileModelEvent subscriptions unchanged. |
ServerModel |
Uses FileModel::register_file_path() + save() / delete() with pending_file_ops dispatch map (unchanged from previous spec section). |
code_diff_view.rs |
set_candidate_diffs() calls register_file_path() for local sessions, register_remote_file() for remote sessions. |
Migration strategy
- Rename:
LocalFileModel→FileModel(type aliasLocalFileModel = FileModelfor backward compat during migration). - Add
FileBackendenum: wrap existingLocalFileinFileBackend::Local. All existing code paths unchanged. - Add
register_remote_file(): creates aFileBackend::Remoteentry. - Extend
save()/delete(): match onFileBackendvariant, dispatch to local or remote code path. - Update
InlineDiffView: replacebacking_file_id: Option<FileId>(unchanged type) but callregister_remote_file()for remote sessions.
Steps 1-2 are pure refactors with no behavior change. Steps 3-4 add the remote capability. Step 5 wires it up.
backing_file_id semantics in InlineDiffView (unchanged)
The backing_file_id: Option<FileId> field retains its current semantics:
Some(file_id)→ editable, save/revert write throughFileModelNone→ selection-only, save/revert are no-ops
The FileId is opaque — InlineDiffView doesn't know or care whether it's backed by local or remote. It calls FileModel::save(file_id, content, version) and subscribes to FileModelEvent filtered by file_id, exactly as today.
Proto definition
Extend remote_server.proto with two new request/response pairs. These follow the existing pattern: the ClientMessage oneof gets new variants, and corresponding response variants are added to ServerMessage.
// ── In ClientMessage oneof ────────────────────────────────────────
// WriteFile write_file = 5;
// DeleteFile delete_file = 6;
// ── File write/delete operations ──────────────────────────────────
// Client → server: write content to a file, creating parent dirs if needed.
message WriteFile {
string path = 1;
string content = 2;
}
// Server → client: file was written successfully.
message WriteFileResponse {}
// Client → server: delete a file.
message DeleteFile {
string path = 1;
}
// Server → client: file was deleted successfully.
message DeleteFileResponse {}
// ── In ServerMessage oneof ────────────────────────────────────────
// WriteFileResponse write_file_response = 7;
// DeleteFileResponse delete_file_response = 8;
Errors are returned using the existing ErrorResponse variant (field 3 in ServerMessage), with ErrorCode::INTERNAL for I/O failures and a human-readable message. No new error codes are needed.
Client-side methods on RemoteServerClient
Follows the existing initialize() / navigate_to_directory() pattern:
impl RemoteServerClient {
/// Writes content to a file on the remote host.
/// Creates parent directories if they don't exist.
pub async fn write_file(
&self,
path: String,
content: String,
) -> Result<(), ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::WriteFile(
WriteFile { path, content },
)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::WriteFileResponse(_)) => Ok(()),
_ => Err(ClientError::UnexpectedResponse),
}
}
/// Deletes a file on the remote host.
pub async fn delete_file(&self, path: String) -> Result<(), ClientError> {
let request_id = RequestId::new();
let msg = ClientMessage {
request_id: request_id.to_string(),
message: Some(client_message::Message::DeleteFile(
DeleteFile { path },
)),
};
let response = self.send_request(request_id, msg).await?;
match response.message {
Some(server_message::Message::DeleteFileResponse(_)) => Ok(()),
_ => Err(ClientError::UnexpectedResponse),
}
}
}
Both use the existing send_request() which handles timeout, abort, and error unwrapping.
Server-side handler in ServerModel
Reuses LocalFileModel on the server to avoid reimplementing async file I/O, parent directory creation, and error handling. LocalFileModel is already a singleton model with save() and delete() that run on background threads via ctx.spawn(async { async_fs::write(...) }). The remote server can register it as a singleton in run() and the handlers can use LocalFileModel::handle(ctx) just like the client does.
Setup: register LocalFileModel in the remote server
In app/src/remote_server/mod.rs, add LocalFileModel to the headless app's singleton models:
pub fn run() -> anyhow::Result<()> {
// ...
AppBuilder::new_headless(AppCallbacks::default(), Box::new(()), None).run(|ctx| {
ctx.add_singleton_model(DirectoryWatcher::new);
ctx.add_singleton_model(|_ctx| DetectedRepositories::default());
ctx.add_singleton_model(RepoMetadataModel::new_with_incremental_updates);
ctx.add_singleton_model(LocalFileModel::new); // NEW
ctx.add_singleton_model(ServerModel::new);
})?;
Ok(())
}
LocalFileModel::new() creates a BulkFilesystemWatcher internally, but since we never call register_file_path(path, true /* subscribe */), the watcher stays idle with no overhead.
Event dispatch: subscribe once, dispatch via file_id map
Instead of subscribing to LocalFileModel per-request (which leaks subscriptions), ServerModel subscribes once at startup and uses a HashMap<FileId, PendingFileOp> to correlate FileModelEvents back to their originating request.
/// Tracks an in-flight file write or delete so the async completion
/// event can be correlated back to the originating client request.
enum FileOpKind {
Write,
Delete,
}
struct PendingFileOp {
request_id: RequestId,
kind: FileOpKind,
}
Add to ServerModel:
pub struct ServerModel {
response_tx: async_channel::Sender<ServerMessage>,
in_progress: HashMap<RequestId, tokio::sync::oneshot::Sender<()>>,
host_id: String,
/// Maps FileId → pending file operation for write/delete correlation.
pending_file_ops: HashMap<FileId, PendingFileOp>,
}
In ServerModel::new(), subscribe to LocalFileModel once:
// Subscribe to LocalFileModel events for write/delete completion.
{
let file_model = LocalFileModel::handle(ctx);
ctx.subscribe_to_model(&file_model, |me, event, _ctx| {
let file_id = event.file_id();
let Some(pending) = me.pending_file_ops.remove(&file_id) else {
return; // Not a file op we're tracking.
};
let response_message = match (event, &pending.kind) {
(FileModelEvent::FileSaved { .. }, FileOpKind::Write) => {
server_message::Message::WriteFileResponse(WriteFileResponse {})
}
(FileModelEvent::FileSaved { .. }, FileOpKind::Delete) => {
server_message::Message::DeleteFileResponse(DeleteFileResponse {})
}
(FileModelEvent::FailedToSave { error, .. }, _) => {
server_message::Message::Error(ErrorResponse {
code: ErrorCode::Internal.into(),
message: format!("File operation failed: {error}"),
})
}
_ => return,
};
let _ = me.response_tx.try_send(ServerMessage {
request_id: pending.request_id.into(),
message: Some(response_message),
});
});
}
Handlers: register file, insert pending op, trigger I/O
The handlers are now simple: register the path, record the pending op, trigger the async I/O, return None.
In ServerModel::handle_message(), add two new arms:
Some(client_message::Message::WriteFile(msg)) => {
self.handle_write_file(msg, &request_id, ctx)
}
Some(client_message::Message::DeleteFile(msg)) => {
self.handle_delete_file(msg, &request_id, ctx)
}
Handler implementations:
fn handle_write_file(
&mut self,
msg: WriteFile,
request_id: &RequestId,
ctx: &mut ModelContext<Self>,
) -> Option<server_message::Message> {
log::info!("Handling WriteFile path={} (request_id={request_id})", msg.path);
let path = std::path::Path::new(&msg.path);
let file_model = LocalFileModel::handle(ctx);
let file_id = file_model.update(ctx, |m, ctx| m.register_file_path(path, false, ctx));
let version = ContentVersion::new();
file_model.update(ctx, |m, _| m.set_version(file_id, version));
// Track this op so the event subscription can correlate the result.
self.pending_file_ops.insert(file_id, PendingFileOp {
request_id: request_id.clone(),
kind: FileOpKind::Write,
});
if let Err(err) = file_model.update(ctx, |m, ctx| m.save(file_id, msg.content, version, ctx)) {
self.pending_file_ops.remove(&file_id);
return Some(server_message::Message::Error(ErrorResponse {
code: ErrorCode::Internal.into(),
message: format!("Failed to initiate write: {err}"),
}));
}
None // Response sent asynchronously via the event subscription.
}
fn handle_delete_file(
&mut self,
msg: DeleteFile,
request_id: &RequestId,
ctx: &mut ModelContext<Self>,
) -> Option<server_message::Message> {
log::info!("Handling DeleteFile path={} (request_id={request_id})", msg.path);
let path = std::path::Path::new(&msg.path);
let file_model = LocalFileModel::handle(ctx);
let file_id = file_model.update(ctx, |m, ctx| m.register_file_path(path, false, ctx));
let version = ContentVersion::new();
file_model.update(ctx, |m, _| m.set_version(file_id, version));
self.pending_file_ops.insert(file_id, PendingFileOp {
request_id: request_id.clone(),
kind: FileOpKind::Delete,
});
if let Err(err) = file_model.update(ctx, |m, ctx| m.delete(file_id, version, ctx)) {
self.pending_file_ops.remove(&file_id);
return Some(server_message::Message::Error(ErrorResponse {
code: ErrorCode::Internal.into(),
message: format!("Failed to initiate delete: {err}"),
}));
}
None
}
Key design decisions for the server handlers:
- Subscribe once, dispatch via map:
ServerModelsubscribes toLocalFileModelonce at startup. Each write/delete handler inserts aPendingFileOpkeyed byFileId, and the subscription callback removes it on completion to send the correlated response. No per-request subscription leaks. - Reuse
LocalFileModel: avoids reimplementingasync_fs::write,ensure_parent_directories, error wrapping, and the async completion callback pattern.LocalFileModel::save()already handles all of this. - Async via
LocalFileModel: file I/O runs on the background thread throughLocalFileModel's internalctx.spawn(). The handler returnsNoneand the response is sent asynchronously whenFileModelEvent::FileSaved/FailedToSavefires. - No watcher overhead:
register_file_path(path, false)skips watcher subscription, so theBulkFilesystemWatcherstays idle. - Cleanup on sync failure: if
save()/delete()returns an immediate error (e.g.NoFilePath), the pending op is removed and a sync error response is returned.
Changes to InlineDiffView
No structural change — backing_file_id: Option<FileId> stays as-is. The FileId is opaque and works for both local and remote files.
Unify register_file() to accept a DiffSessionType and dispatch internally:
pub fn register_file(
&mut self,
session_type: &DiffSessionType,
ctx: &mut ViewContext<Self>,
) {
let file_model = FileModel::handle(ctx);
let file_id = match session_type {
DiffSessionType::Local => file_model.update(ctx, |m, ctx| {
m.register_file_path(file_path, false, ctx)
}),
DiffSessionType::Remote(host_id) => {
let remote_path = StandardizedPath::try_new(...);
file_model.update(ctx, |m, _| m.register_remote_file(host_id, remote_path))
}
};
self.finish_file_registration(file_id, ctx);
}
save_content(), restore_diff_base(), and all is_some()/is_none() checks remain identical — they call FileModel::save(file_id, ...) / FileModel::delete(file_id, ...) which dispatches internally.
Changes to set_candidate_diffs() in code_diff_view.rs
The caller passes the session type; the local vs. remote dispatch is an internal detail of register_file():
#[cfg(not(target_family = "wasm"))]
diff_viewer.update(ctx, |view, ctx| {
view.register_file(&self.diff_session_type, ctx);
});
File placement
crates/warp_files/src/lib.rs—FileModel(renamed fromLocalFileModel),FileBackendenum,register_remote_file()- No new files needed for the trait — the abstraction lives inside the singleton.
End-to-end flow
Accept + save (local session)
sequenceDiagram
participant User
participant CodeDiffView
participant InlineDiffView
participant FileModel
participant Disk
User->>CodeDiffView: Accept diff
CodeDiffView->>InlineDiffView: accept_and_save_diff()
InlineDiffView->>InlineDiffView: retrieve_unified_diff() [async]
InlineDiffView->>FileModel: save(file_id, content, version)
Note over FileModel: FileBackend::Local → async_fs::write()
FileModel->>Disk: async_fs::write()
Disk-->>FileModel: Ok
FileModel-->>InlineDiffView: FileModelEvent::FileSaved
InlineDiffView-->>CodeDiffView: InlineDiffViewEvent::FileSaved
CodeDiffView->>CodeDiffView: mark_diff_saved()
Accept + save (remote session)
sequenceDiagram
participant User
participant CodeDiffView
participant InlineDiffView
participant FileModel
participant RemoteServerClient
participant RemoteHost
User->>CodeDiffView: Accept diff
CodeDiffView->>InlineDiffView: accept_and_save_diff()
InlineDiffView->>InlineDiffView: retrieve_unified_diff() [async]
InlineDiffView->>FileModel: save(file_id, content, version)
Note over FileModel: FileBackend::Remote → RPC
FileModel->>RemoteServerClient: write_file(path, content) [async]
RemoteServerClient->>RemoteHost: WriteFile proto message
RemoteHost-->>RemoteServerClient: WriteFileResponse
RemoteServerClient-->>FileModel: Ok
FileModel-->>InlineDiffView: FileModelEvent::FileSaved
InlineDiffView-->>CodeDiffView: InlineDiffViewEvent::FileSaved
CodeDiffView->>CodeDiffView: mark_diff_saved()
Risks and mitigations
Risk: Remote save() / delete() latency.
Remote RPCs are slower than local async_fs::write. The SavingDiffs state machine in CodeDiffView already handles async completion, so latency is tolerated. But UI responsiveness may degrade on high-latency SSH connections. Mitigation: the accept flow already shows a loading state via CodeDiffState::Accepted(Some(SavingDiffs)).
Risk: FileModel becomes a larger singleton.
Adding FileBackend::Remote entries increases the surface area of FileModel. Mitigation: remote entries are simple (no watcher, no repo subscription) and the save()/delete() dispatch is a single match arm. The rename from LocalFileModel → FileModel is the biggest churn.
Risk: WASM cfg gates.
On WASM, FileModel is not available. InlineDiffView continues to use backing_file_id: None (selection-only, no save). The cfg gates stay at the registration site in set_candidate_diffs(), same as today.
Testing and validation
-
Existing diff application tests (
diff_application_tests.rs): verify thatapply_edits()still produces correctAIRequestedCodeDiffwithoriginal_content. These don't touchBackingFileStoredirectly. -
Manual testing: accept, reject, and revert agent-generated diffs in local sessions. Verify files are written/deleted correctly.
-
WASM build:
cargo clippy --target wasm32-unknown-unknown --profile release-wasm-debug_assertions --no-deps— verify no regressions. -
Unit test for
FileModelremote backend (optional): construct with a mockRemoteServerClient, callsave(), verifyFileSavedevent is emitted. RequiresApp::test()harness.
Follow-ups
ApplyEditModel: dispatch diff matching to localapply_edits()vs remoteApplyEditsRPC based on session type.- Tool gating for remote sessions: enable
ApplyFileDiffstool inget_supported_tools()whenRemoteServerClientis connected. - Memory optimization: clear
DiffBase.contentafter editor initialization to avoid holding two copies (one inDiffBase, one inDiffModel.base). - Disconnect handling: when
RemoteServerManageremitsSessionDisconnected, in-flightFileModelremote RPCs should fail withFailedToSaverather than hanging.FileModelcould subscribe toRemoteServerManagerEvent::SessionDisconnectedand fail all pending remote ops for that session. - Migrate
LocalCodeEditorView: replace directLocalFileModel::handle(ctx).update(...)calls withFileModel::handle(ctx).update(...). Pure rename, no behavior change. - Migrate
GlobalBufferModel: same rename. TheFileModelEventsubscriptions are already generic overFileId.