Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+254
View File
@@ -0,0 +1,254 @@
# APP-3790: Remote Apply Diff
## Problem
When an AI agent runs in an SSH session, the `ApplyFileDiffs` tool is disabled because the diff preprocessing step reads files from the local filesystem (`std::fs::read_to_string`, `std::fs::exists`). The remote host's files are inaccessible to the client. We need to:
1. Route file reads through the remote server during diff application
2. Wire the `CodeDiffView` save/delete/create flow through the remote `FileModel` backend
3. Return accepted buffer content to the LLM without a network re-read
4. Update the agent context so the server knows `ApplyFileDiffs` is available on remote sessions
## Relevant Code
- `crates/remote_server/proto/remote_server.proto` — proto schema; has `WriteFile`/`DeleteFile`, needs `ReadFile`
- `crates/remote_server/src/client.rs (210-244)` — `RemoteServerClient::write_file` / `delete_file`; pattern for `read_file`
- `app/src/remote_server/server_model.rs (498-573)` — `handle_write_file` / `handle_delete_file`; async-via-background-executor pattern
- `app/src/ai/blocklist/action_model/execute/request_file_edits/diff_application.rs` — `apply_edits` / `apply_edits_internal`; all local file I/O
- `app/src/ai/blocklist/action_model/execute/request_file_edits.rs (299-421)` — `RequestFileEditsExecutor::preprocess_action` / `on_diffs_applied`
- `app/src/ai/blocklist/inline_action/code_diff_view.rs (459-464)` — `DiffSessionType` enum
- `app/src/ai/blocklist/inline_action/code_diff_view.rs (981-1050)` — `set_candidate_diffs`; already routes `register_file` vs `register_remote_file`
- `app/src/ai/blocklist/controller.rs (86-121)` — `SessionContext`
- `app/src/ai/agent/api/impl.rs (146-206)` — `get_supported_tools`; gates tools on session type
- `crates/remote_server/src/manager.rs (134-139)` — `RemoteServerManager::client_for_host`
- `crates/warp_files/src/lib.rs (95-106)` — `FileBackend::Remote`; already supports remote save/delete
## Current State
**Diff application** (`diff_application.rs`): `apply_edits_internal` parses `FileEdit` into grouped maps (search-replace, v4a, create, delete), then calls helpers (`apply_search_replace`, `apply_v4a_update`, `apply_create_file`, `apply_delete_file`) that each call `std::fs::read_to_string` or `std::fs::exists`. Purely local I/O — no code path exists for remote files.
**CodeDiffView save/delete/create**: `DiffSessionType` already exists with `Local` and `Remote(HostId)` variants. `set_candidate_diffs` routes to `register_file` (local) or `register_remote_file` (remote). `FileModel` has `FileBackend::Remote` that dispatches save/delete through `RemoteServerClient`. However, `RequestFileEditsExecutor` never sets `diff_session_type` — it defaults to `Local`.
**Agent tool gating**: `get_supported_tools` excludes `ApplyFileDiffs`, `ReadFiles`, and `SearchCodebase` when `session_type` is `WarpifiedRemote`. There is no field on `SessionContext` to indicate whether a `RemoteServerClient` is available.
**Post-accept context**: After diffs are accepted, `execute` re-reads files from disk via `read_local_file_context` and sends updated content to the LLM. This would require a network round-trip for remote sessions.
**Proto**: `remote_server.proto` has `WriteFile` and `DeleteFile` but no `ReadFile`.
## Proposed Changes
### 1. Add `ReadFile` proto message
Add `ReadFile` / `ReadFileResponse` to the remote server protocol, following the same `oneof result { success, error }` pattern as `WriteFileResponse` and `DeleteFileResponse`.
**`remote_server.proto`**:
```protobuf
message ReadFile {
string path = 1;
}
message ReadFileResponse {
oneof result {
ReadFileSuccess success = 1;
FileOperationError error = 2;
}
}
message ReadFileSuccess {
string content = 1;
bool exists = 2;
}
```
`ReadFile` is field 9 in `ClientMessage`, `ReadFileResponse` is field 10 in `ServerMessage`.
**`server_model.rs`**: `handle_read_file` spawns the read onto the background executor, returns `None` from the handler, and sends the response asynchronously through `response_tx`. If the file doesn't exist, returns `ReadFileSuccess { content: "", exists: false }`. I/O errors return `FileOperationError` (not the generic `ErrorResponse`).
**`client.rs`**: `read_file(&self, path: String) -> Result<ReadFileSuccess, ClientError>` unwraps the `oneof`, mapping `FileOperationError` to `ClientError::FileOperationFailed`.
### 2. Async-parameterized `apply_edits` + `ApplyDiffModel` dispatch
The core insight is that the local and remote diff application paths differ **only** in how they read file contents. Everything else — parsing edits, iteration, conflict checking, fuzzy matching, building `AIRequestedCodeDiff` — is identical. Rather than duplicating the application logic, we parameterize `apply_edits` over a file-reading closure so a single codepath handles both.
#### `FileReadResult` and `apply_edits` signature
A new enum in `diff_application.rs` abstracts over the file-read outcome:
```rust
pub(crate) enum FileReadResult {
Found(String),
NotFound,
ReadError(String),
}
impl From<std::io::Result<String>> for FileReadResult { ... }
```
`apply_edits` (the public entry point with telemetry) and `apply_edits_internal` (the core logic) become async and generic over the reader:
```rust
pub(crate) async fn apply_edits<F, Fut>(
edits: Vec<FileEdit>,
session_context: &SessionContext,
ai_identifiers: &AIIdentifiers,
background_executor: Arc<Background>,
auth_state: Arc<AuthState>,
passive_diff: bool,
read_file: F,
) -> Result<Vec<AIRequestedCodeDiff>, Vec1<DiffApplicationError>>
where
F: Fn(String) -> Fut,
Fut: Future<Output = FileReadResult>,
```
The four leaf helpers (`apply_search_replace`, `apply_v4a_update`, `apply_create_file`, `apply_delete_file`) each take `&F` and call `read_file(absolute_path).await` instead of `std::fs` directly. They match on `FileReadResult` variants instead of `io::Result`.
The edit parsing/grouping stays inline in `apply_edits_internal` (no `GroupedEdits` struct) — this keeps the code close to the original master version.
#### Unified error variant
The previous `UnreadableFile { source: io::Error, file }` and `RemoteReadFailed { file, message }` variants are merged into a single `ReadFailed { file, message }` that works for both local and remote I/O errors.
#### `ApplyDiffModel` (Entity submodel)
**New file**: `app/src/ai/blocklist/action_model/execute/request_file_edits/apply_diff_model.rs`
`ApplyDiffModel` is a thin Entity that holds `ModelHandle<ActiveSession>`. Its `apply_diffs` method resolves session context, remote client, background executor, and auth state from its `ModelContext`, then passes the appropriate closure to `apply_edits`:
- **Local**: `|path| async { FileReadResult::from(std::fs::read_to_string(path)) }`
- **Remote**: `|path| { let client = client.clone(); async move { read_remote_file(&client, &path).await } }` where `read_remote_file` is a small adapter (~10 lines) that maps `RemoteServerClient::read_file` → `FileReadResult`.
The local-vs-remote dispatch is a single unified code path with no `cfg` gating — `RemoteServerManager` and `RemoteServerClient` compile on all targets including WASM. On WASM, `RemoteServerManager::connect_session` is a no-op, so `client_for_host` returns `None` and the local closure is used.
The executor creates `ApplyDiffModel` in its constructor and delegates via `self.apply_diff_model.update(ctx, |model, ctx| model.apply_diffs(...))`.
### 3. Wire `RequestFileEditsExecutor` through `ApplyDiffModel`
- `preprocess_action` calls `ApplyDiffModel::apply_diffs` instead of `apply_edits` directly.
- `on_diffs_applied`: when `session_context.host_id()` is `Some`, set `diff_session_type` on the `CodeDiffView` to `DiffSessionType::Remote(host_id)` before calling `set_candidate_diffs`. This ensures save/delete/create routes through `FileModel`'s remote backend.
- For local sessions, behavior is identical to today.
### 4. Post-accept context reads
After diffs are accepted and saved, `execute` calls `read_local_file_context` to re-read files from disk and send updated content to the LLM. For remote sessions, instead of round-tripping to the server, we build `ReadFileContextResult` directly from the `InlineDiffView` editor buffers.
The buffer content is the accepted state — it's what we just wrote via `FileModel::save` — so this is both correct and avoids a network round-trip.
In the `SavedAcceptedDiffs` handler inside `execute`, when `session_context.host_id()` is `Some` (remote), extract text from each `InlineDiffView`'s editor and construct `FileContext` entries directly. For local sessions, the existing `read_local_file_context` path remains unchanged.
### 5. Split `BootstrapSessionType` / `SessionType` and agent tool gating
Session type is modeled as two distinct enums to separate immutable bootstrap-time data from mutable runtime state:
**`BootstrapSessionType`** — immutable, determined at bootstrap, lives on `SessionInfo`:
```rust
pub enum BootstrapSessionType {
Local,
WarpifiedRemote,
}
```
**`SessionType`** — the authoritative runtime type, lives on `Session` behind a `parking_lot::Mutex`:
```rust
pub enum SessionType {
Local,
WarpifiedRemote { host_id: Option<HostId> },
}
```
`Session::new()` converts `BootstrapSessionType` → `SessionType` via a `From` impl (remote maps to `host_id: None`). `Session::session_type()` returns an owned `SessionType` from the mutex. `Session::set_remote_host_id()` mutates the `host_id` in-place through `Arc<Session>`.
This separation means `SessionInfo` never carries mutable state, and the `Session`'s `session_type` is the single source of truth that evolves as the remote server connection lifecycle progresses.
**`Sessions` subscription**: `Sessions::new()` subscribes to `RemoteServerManager` events. On `SessionConnected`, it calls `session.set_remote_host_id(Some(host_id))`. On `SessionDisconnected`, it clears it. A race-condition guard in `initialize_bootstrapped_session` also checks `RemoteServerManager` when a session is first inserted, covering the case where the handshake completes before the session is stored.
**`get_supported_tools`** (`api/impl.rs`): gates on `host_id` presence (set only after a successful handshake):
```rust
match session_context.session_type() {
None | Some(SessionType::Local) => {
supported_tools.extend(&[
api::ToolType::ReadFiles,
api::ToolType::ApplyFileDiffs,
api::ToolType::SearchCodebase,
]);
}
Some(SessionType::WarpifiedRemote { host_id: Some(_) }) => {
supported_tools.push(api::ToolType::ApplyFileDiffs);
}
Some(SessionType::WarpifiedRemote { host_id: None }) => {
// Feature flag off or not yet connected — no remote tools.
}
}
```
`ReadFiles` and `SearchCodebase` remain disabled for remote sessions (follow-up).
## End-to-End Flow
```mermaid
sequenceDiagram
participant LLM as LLM Server
participant Executor as RequestFileEditsExecutor
participant Model as ApplyDiffModel
participant Client as RemoteServerClient
participant Server as Remote Server
participant View as CodeDiffView
participant FileModel as FileModel (Remote)
LLM->>Executor: RequestFileEdits action (file edits)
Executor->>Model: apply_diffs(edits, session_context)
Note over Model: session_context.host_id is Some → remote path
loop For each file in edits
Model->>Client: read_file(absolute_path)
Client->>Server: ReadFile { path }
Server-->>Client: ReadFileResponse { ReadFileSuccess { content, exists } }
Client-->>Model: Ok(ReadFileResponse)
end
Note over Model: fuzzy_match_diffs / fuzzy_match_v4a_diffs on fetched content
Model-->>Executor: Ok(Vec<AIRequestedCodeDiff>)
Executor->>View: set_diff_session_type(Remote(host_id))
Executor->>View: set_candidate_diffs(diffs)
Note over View: registers files via register_remote_file
Note over View: User accepts diff
View->>FileModel: save(file_id, content, version)
FileModel->>Client: write_file(path, content)
Client->>Server: WriteFile { path, content }
Server-->>Client: WriteFileResponse
FileModel-->>View: FileSaved event
Note over Executor: Build ReadFileContextResult from editor buffers
Executor-->>LLM: RequestFileEditsResult::Success { updated_files, ... }
```
## Risks and Mitigations
**Network latency during diff application**: Each file requires a `ReadFile` round-trip. For diffs touching many files, this could be slow. Mitigation: `apply_edits_remote` can be extended to batch reads or use concurrent futures in a follow-up. The model boundary makes this change contained.
**Disconnected remote server**: If the `RemoteServerClient` disconnects between tool gating and diff application, `read_file` will fail. Mitigation: `ClientError::Disconnected` propagates as a `DiffApplicationError`, which the executor reports to the LLM so it can retry or inform the user.
**Large file reads over the wire**: `ReadFileResponse` returns entire file content as a string. For very large files this could be slow or memory-intensive. Mitigation: this matches the local path (`std::fs::read_to_string`) which also loads full files. Existing per-file size limits apply equally.
**Editor buffer staleness for post-accept context**: We return buffer content that was just saved rather than re-reading. If `FileModel::save` silently fails, the buffer might not match disk. Mitigation: `FailedToSave` events already propagate through `CodeDiffView` and are reported as errors, so the buffer-read code path would not be reached on failure.
## Testing and Validation
- **Proto round-trip test**: Add a test in `protocol_tests.rs` for `ReadFile` / `ReadFileResponse` encode/decode.
- **`handle_read_file` server test**: Verify the handler reads existing files, returns `exists: false` for missing files, and returns an error for unreadable files.
- **Unit tests for remote path**: Since local and remote share a single codepath, existing `diff_application_tests.rs` covers the core logic. Additional tests can pass a mock `read_file` closure that simulates remote behavior (e.g. returning `ReadError` for connectivity failures) without needing to mock `RemoteServerClient` directly.
- **Regression**: Run existing `diff_application_tests.rs` and `cargo nextest run -p warp_files` to verify local path is unchanged.
- **Integration**: Manually test agent mode in an SSH session — verify `ApplyFileDiffs` appears in supported tools, diff preview renders correctly, accept/save writes to the remote host, and the LLM receives updated file context after acceptance.
## Follow-ups
- **`ReadFiles` tool for remote sessions**: Use the same `ReadFile` proto to implement remote file reading for the `ReadFiles` agent tool.
- **`SearchCodebase` for remote sessions**: Requires remote codebase indexing infrastructure — separate project.
- **Batch/concurrent `ReadFile`**: The `read_file` closure could be extended to batch or pre-fetch reads for independent files before entering the application loop.
- **Post-accept context from buffer for local sessions**: The buffer-based approach could also eliminate the re-read for local sessions — a minor optimization.
+204
View File
@@ -0,0 +1,204 @@
# APP-3790: Remote ReadFiles Tool
## Problem
The `ReadFiles` agent tool is disabled for remote SSH sessions. `get_supported_tools` skips `ToolType::ReadFiles` when `SessionType::WarpifiedRemote`, because the underlying `read_local_file_context` reads files via `async_fs`, `FileModel::read_text_file`, and local image processing — all local-only APIs.
The remote server already runs on the host machine with full filesystem access and has access to the same dependencies (`warp_files`, `warp_util`, `mime_guess`). Rather than building a degraded client-side approximation, we push the file-reading logic to the server so the ReadFiles tool has full feature parity with local: line-range extraction, binary/image support, metadata, and size limits.
## Relevant Code
- `app/src/ai/blocklist/action_model/execute/read_files.rs` — `ReadFilesExecutor`; dispatches to `read_local_file_context`
- `app/src/ai/blocklist/action_model/execute.rs:941` — `read_local_file_context`; per-file reading logic (metadata, binary detection, text/binary read, image processing, byte limits)
- `crates/warp_files/src/lib.rs:528` — `FileModel::read_text_file`; line-range extraction and byte-limit truncation
- `crates/warp_util/src/file_type.rs:46` — `is_binary_file`; extension-based binary detection
- `app/src/util/image.rs:96` — `process_image_for_agent`; image processing for LLM context
- `crates/remote_server/proto/remote_server.proto:234-249` — current `ReadFile`/`ReadFileResponse`/`ReadFileSuccess` proto (too simple)
- `crates/remote_server/src/client.rs:266` — `RemoteServerClient::read_file`; current simple read
- `app/src/remote_server/server_model.rs:883` — `handle_read_file_context`; calls `read_local_file_context` and converts result to proto
- `app/src/ai/agent/api/impl.rs:146` — `get_supported_tools`; gates tools on session type
- `app/src/ai/agent/api/impl.rs:221` — `get_supported_cli_agent_tools`; same gating for CLI agent
- `app/src/ai/blocklist/action_model/execute/request_file_edits/apply_diff_model.rs` — `read_remote_file` adapter; uses old `ReadFile` proto
## Current State
**`ReadFilesExecutor::execute`** resolves cwd/shell from `ActiveSession`, then calls `read_local_file_context(locations, cwd, shell, None)` inside a `BoxFuture`.
**`read_local_file_context`** iterates over `FileLocations` and for each file:
1. Resolves the absolute path via `host_native_absolute_path`
2. Reads metadata via `async_fs::metadata` → `last_modified`, `file_size`
3. Computes effective byte budget (per-file `MAX_FILE_READ_BYTES` ∩ remaining batch budget)
4. Binary detection via `is_binary_file` (extension-based, `warp_util::file_type`)
5. Text path: `FileModel::read_text_file` — reads with line-range extraction and byte-limit truncation, returns segments
6. Binary path: reads raw bytes, checks MIME via `mime_guess`, runs `process_image_for_agent` for supported images, skips oversized files
7. Returns `ReadFileContextResult { file_contexts, missing_files }`
**Current `ReadFile` proto** (`remote_server.proto`): `ReadFile { path }` → `ReadFileSuccess { content, exists }`. The server handler just calls `tokio::fs::read_to_string` — no metadata, no line ranges, no size limits, no binary support.
**Tool gating**: `get_supported_tools` excludes `ReadFiles` for `WarpifiedRemote` sessions. `get_supported_cli_agent_tools` also excludes it.
## Proposed Changes
### 1. Replace `ReadFile` proto with `ReadFileContext` (richer batch request/response)
Replace the current `ReadFile`/`ReadFileResponse`/`ReadFileSuccess` messages in-place (no backward compat needed — both changes land in the same release). Reuse field slots 10/11 in `ClientMessage`/`ServerMessage`.
**`remote_server.proto`**:
```protobuf
// A single file to read, with optional line ranges.
message ReadFileContextFile {
string path = 1;
// 1-indexed line ranges (start..end). Empty = read entire file.
repeated LineRange line_ranges = 2;
}
message LineRange {
uint32 start = 1;
uint32 end = 2;
}
// Client → server: batch read multiple files with full context.
message ReadFileContextRequest {
repeated ReadFileContextFile files = 1;
// Per-file byte limit. Absent = use server default (MAX_FILE_READ_BYTES).
optional uint32 max_file_bytes = 2;
// Cumulative byte budget across all files. Absent = no batch limit.
optional uint32 max_batch_bytes = 3;
}
// Server → client: result of ReadFileContextRequest.
// Per-file failures are reported in `failed_files`, not as a top-level error.
// Catastrophic server errors (malformed request, etc.) use the generic ErrorResponse.
message ReadFileContextResponse {
repeated FileContextProto file_contexts = 1;
repeated FailedFileRead failed_files = 2;
}
message FailedFileRead {
string path = 1;
FileOperationError error = 2; // reuses the existing shared error type
}
message FileContextProto {
string file_name = 1;
oneof content {
string text_content = 2;
bytes binary_content = 3;
}
// Optional 1-indexed line range this segment covers.
optional uint32 line_range_start = 4;
optional uint32 line_range_end = 5;
optional uint64 last_modified_epoch_millis = 6;
uint32 line_count = 7;
}
```
This is a batch API — one round-trip reads all requested files, avoiding serial latency.
### 2. Share file-reading logic via `read_local_file_context`
Rather than extracting a new per-file helper, the server handler calls the existing `read_local_file_context` directly. This function already implements the full pipeline (metadata → binary detection → text line-range extraction via `FileModel::read_text_file` → image processing → byte-limit enforcement) and is accessible as `pub(crate)` from `crate::ai::blocklist::read_local_file_context`.
The server handler converts its proto request into `Vec<FileLocations>` (paths are already absolute, so `cwd: None` and `shell: None` are passed, causing `host_native_absolute_path` to act as an identity on absolute paths). The `ReadFileContextResult` is then converted to proto `ReadFileContextResponse` via a `file_context_result_to_proto` helper that maps `FileContext` → `FileContextProto` and `missing_files` → `FailedFileRead`.
### 3. Implement `handle_read_file_context` on the server
New handler in `ServerModel`:
- Deserialize `ReadFileContextRequest` → convert `ReadFileContextFile` list into `Vec<FileLocations>`
- Call `read_local_file_context(file_locations, None, None, max_batch_bytes)` to read all files
- Convert the `ReadFileContextResult` to proto via `file_context_result_to_proto`
- If `read_local_file_context` returns an `Err`, convert to a single `FailedFileRead` in the response
- Return `ReadFileContextResponse`
The handler uses `spawn_request_handler` (like `handle_run_command`) so it runs on the background executor and is cancellable via `Abort`.
### 4. Add `RemoteServerClient::read_file_context`
New method on the client:
```rust
pub async fn read_file_context(
&self,
files: Vec<ReadFileContextFile>,
max_file_bytes: Option<u32>,
max_batch_bytes: Option<u32>,
) -> Result<ReadFileContextResponse, ClientError>
```
Follows the same pattern as `write_file` / `delete_file` — sends request, awaits correlated response, maps error variant.
### 5. Update `ReadFilesExecutor::execute` to dispatch on session type
In the `execute` method, after resolving cwd/shell, check `active_session.session_type(ctx)`:
- **Local / None**: call `read_local_file_context` as today (unchanged).
- **WarpifiedRemote with host_id**: resolve `RemoteServerClient` via `RemoteServerManager::client_for_host`, call `client.read_file_context(...)`, convert `ReadFileContextResponse` → `ReadFileContextResult` (mapping proto `FileContextProto` → `FileContext`, `FailedFileRead` → `missing_files`).
- **WarpifiedRemote without host_id**: fall through to the local `read_local_file_context` path.
The remote client lookup uses a unified code path with no `cfg` gating — `RemoteServerManager` and `RemoteServerClient` compile on all targets including WASM. On WASM, `client_for_host` returns `None` (since `connect_session` is a no-op), so the local path is used automatically.
### 6. Update `apply_diff_model.rs` to use new proto
The `read_remote_file` adapter currently uses the old `ReadFile`/`ReadFileSuccess` proto. Update it to send a `ReadFileContextRequest` with a single file (no line ranges, no byte limits) and map the response back to `FileReadResult`.
### 7. Enable `ReadFiles` in `get_supported_tools` for remote sessions
In `get_supported_tools` (impl.rs:179), add `api::ToolType::ReadFiles` alongside `ApplyFileDiffs` for the `WarpifiedRemote { host_id: Some(_) }` arm.
Also in `get_supported_cli_agent_tools` (impl.rs:234), enable `ReadFiles` for remote sessions with a connected host.
## End-to-End Flow
```mermaid
sequenceDiagram
participant LLM as LLM Server
participant Executor as ReadFilesExecutor
participant Client as RemoteServerClient
participant Server as Remote Server (ServerModel)
participant Shared as read_single_file_context
LLM->>Executor: ReadFiles action (file locations)
Note over Executor: session_type is WarpifiedRemote with host_id
Executor->>Client: read_file_context(files, max_bytes)
Client->>Server: ReadFileContextRequest { files, max_file_bytes, max_batch_bytes }
loop For each file
Server->>Shared: read_single_file_context(path, line_ranges, max_bytes)
Note over Shared: metadata → binary detect → text/binary read → image process
Shared-->>Server: SingleFileReadResult
end
Server-->>Client: ReadFileContextResponse { file_contexts, failed_files }
Client-->>Executor: Ok(ReadFileContextResponse)
Note over Executor: Convert proto → ReadFileContextResult
Executor-->>LLM: ReadFilesResult::Success { files }
```
## Risks and Mitigations
**Network latency for large batches**: The batch API sends all files in one round-trip, but the server reads them sequentially. For requests with many files, this could be slow. Mitigation: follow-up to add concurrent reads on the server via `futures::join_all`.
**Large binary files over the wire**: Image files after processing can still be significant. Mitigation: the same `MAX_FILE_READ_BYTES` limit applies server-side, and `process_image_for_agent` already has its own size guard.
**Server crash on malformed request**: A malformed `ReadFileContextRequest` could panic. Mitigation: validate inputs before processing; the `spawn_request_handler` pattern already handles errors gracefully.
**`apply_diff_model.rs` migration**: Replacing the proto in-place means the apply-diff path must be updated atomically in the same PR. Mitigation: the change to `apply_diff_model.rs` is small — send a single-file `ReadFileContextRequest` and map the response.
## Testing and Validation
- **Existing `read_local_file_context` tests**: Cover the shared per-file reading logic (text files with/without line ranges, missing files, binary/image files, oversized files, byte-limit enforcement) that the server handler now reuses.
- **Server handler test**: Test `handle_read_file_context` end-to-end — verify it reads existing files, returns `failed_files` for non-existent/unreadable files, and respects byte limits.
- **Proto round-trip test**: Encode/decode `ReadFileContextRequest` / `ReadFileContextResponse`.
- **Client integration**: Unit test `read_file_context` with a mock server response.
- **Regression**: Existing `read_local_file_context` tests and `diff_application_tests` remain unchanged (local path untouched, apply-diff adapter updated).
- **Manual**: Connect to a remote SSH session, invoke agent mode, verify the LLM can read text and image files on the remote host with correct line ranges.
## Follow-ups
- **Concurrent reads on server**: The server handler reads files sequentially via `read_local_file_context`. Concurrency could be added either inside that function or by splitting files across multiple calls.
- **Backport `FailedFileRead` to local path**: Update `ReadFileContextResult::missing_files` from `Vec<String>` to include failure reasons, matching the richer remote proto.
+510
View File
@@ -0,0 +1,510 @@
# 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` — `InlineDiffView` 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.rs` — `StandardizedPath` for platform-aware remote paths
- `app/src/remote_server/manager.rs` — `RemoteServerManager` 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:
```rust
/// 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:
```rust
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**: `LocalFileModel` → `FileModel` (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`.
```protobuf
// ── 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:
```rust
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:
```rust
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 `FileModelEvent`s back to their originating request.
```rust
/// 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`:
```rust
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:
```rust
// 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:
```rust
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:
```rust
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:
```rust
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()`:
```rust
#[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 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)
```mermaid
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)
```mermaid
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
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`.