Files
galaxy/specs/APP-3790/TECH.md
T

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.rsInlineDiffView struct and all LocalFileModel usage
  • app/src/code/diff_viewer.rs (113-171)DiffViewer trait with accept_and_save_diff, restore_diff_base defaults
  • app/src/ai/blocklist/inline_action/code_diff_view.rs (954-1005)set_candidate_diffs() which constructs InlineDiffView and calls register_file()
  • crates/warp_files/src/lib.rs (282-315, 548-706)LocalFileModel::register_file_path(), save(), delete()
  • crates/warp_util/src/standardized_path.rsStandardizedPath for platform-aware remote paths
  • app/src/remote_server/manager.rsRemoteServerManager and RemoteServerClient (future remote implementation)

Current state

InlineDiffView manages file persistence through a backing_file_id: Option<FileId> field:

  • Construction: InlineDiffView::new() creates the view with backing_file_id: None (read-only).
  • Registration: register_file() (non-WASM only) calls LocalFileModel::register_file_path(), stores the FileId, subscribes to FileModelEvent::FileSaved / FailedToSave, and re-emits them as InlineDiffViewEvent.
  • Save: save_content() calls LocalFileModel::save(file_id, content, version).
  • Delete: restore_diff_base() calls LocalFileModel::delete(file_id, version) for new files or LocalFileModel::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:

  1. register_file() — 6 LocalFileModel calls + event subscription (lines 100-136)
  2. save_content() — 1 LocalFileModel::save() call (lines 166-181)
  3. restore_diff_base() — 2 LocalFileModel calls (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 FileModelEvent via ctx.subscribe_to_model(&file_model, ...) and filter by FileId. 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 up Arc<RemoteServerClient> from RemoteServerManager via host_id at call time. If the client is connected, spawn async task calling client.write_file(path, content).await or client.delete_file(path).await, emit FileModelEvent::FileSaved / FailedToSave on completion. If disconnected, emit FailedToSave immediately 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

  1. Rename: LocalFileModelFileModel (type alias LocalFileModel = FileModel for backward compat during migration).
  2. Add FileBackend enum: wrap existing LocalFile in FileBackend::Local. All existing code paths unchanged.
  3. Add register_remote_file(): creates a FileBackend::Remote entry.
  4. Extend save() / delete(): match on FileBackend variant, dispatch to local or remote code path.
  5. Update InlineDiffView: replace backing_file_id: Option<FileId> (unchanged type) but call register_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 through FileModel
  • None → 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: ServerModel subscribes to LocalFileModel once at startup. Each write/delete handler inserts a PendingFileOp keyed by FileId, and the subscription callback removes it on completion to send the correlated response. No per-request subscription leaks.
  • Reuse LocalFileModel: avoids reimplementing async_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 through LocalFileModel's internal ctx.spawn(). The handler returns None and the response is sent asynchronously when FileModelEvent::FileSaved / FailedToSave fires.
  • No watcher overhead: register_file_path(path, false) skips watcher subscription, so the BulkFilesystemWatcher stays 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.rsFileModel (renamed from LocalFileModel), FileBackend enum, 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 LocalFileModelFileModel 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

  1. Existing diff application tests (diff_application_tests.rs): verify that apply_edits() still produces correct AIRequestedCodeDiff with original_content. These don't touch BackingFileStore directly.

  2. Manual testing: accept, reject, and revert agent-generated diffs in local sessions. Verify files are written/deleted correctly.

  3. WASM build: cargo clippy --target wasm32-unknown-unknown --profile release-wasm-debug_assertions --no-deps — verify no regressions.

  4. Unit test for FileModel remote backend (optional): construct with a mock RemoteServerClient, call save(), verify FileSaved event is emitted. Requires App::test() harness.

Follow-ups

  • ApplyEditModel: dispatch diff matching to local apply_edits() vs remote ApplyEdits RPC based on session type.
  • Tool gating for remote sessions: enable ApplyFileDiffs tool in get_supported_tools() when RemoteServerClient is connected.
  • Memory optimization: clear DiffBase.content after editor initialization to avoid holding two copies (one in DiffBase, one in DiffModel.base).
  • Disconnect handling: when RemoteServerManager emits SessionDisconnected, in-flight FileModel remote RPCs should fail with FailedToSave rather than hanging. FileModel could subscribe to RemoteServerManagerEvent::SessionDisconnected and fail all pending remote ops for that session.
  • Migrate LocalCodeEditorView: replace direct LocalFileModel::handle(ctx).update(...) calls with FileModel::handle(ctx).update(...). Pure rename, no behavior change.
  • Migrate GlobalBufferModel: same rename. The FileModelEvent subscriptions are already generic over FileId.