Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
Reference in New Issue
Block a user