diff --git a/app/src/app_state_tests.rs b/app/src/app_state_tests.rs index da1c7217bd..184e55bb68 100644 --- a/app/src/app_state_tests.rs +++ b/app/src/app_state_tests.rs @@ -59,7 +59,9 @@ fn test_code_pane_snapshot_single_tab() { }], active_tab_index: 0, source: Some(CodeSource::FileTree { - path: PathBuf::from("/tmp/test.rs"), + location: crate::code::buffer_location::FileLocation::Local(PathBuf::from( + "/tmp/test.rs", + )), }), }; let CodePaneSnapShot::Local { diff --git a/app/src/code/buffer_location.rs b/app/src/code/buffer_location.rs index 742bcdde74..6bff008d3e 100644 --- a/app/src/code/buffer_location.rs +++ b/app/src/code/buffer_location.rs @@ -1,17 +1,55 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; use warp_util::content_version::ContentVersion; use warp_util::remote_path::RemotePath; -/// Uniquely identifies where a buffer's content lives. -#[derive(Clone, Debug, Eq, PartialEq, Hash)] -pub enum BufferLocation { +/// Uniquely identifies where a file lives — either on the local filesystem +/// or on a remote host. Used across both the buffer model and the +/// editor/view layers as the canonical file-identity type. +#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)] +pub enum FileLocation { /// File on the local filesystem. Local(PathBuf), /// File on a remote host, identified by host + path. Remote(RemotePath), } +impl FileLocation { + /// Returns the file name component for display (e.g. tab titles). + pub fn display_name(&self) -> &str { + match self { + FileLocation::Local(path) => path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(), + FileLocation::Remote(remote) => remote.path.file_name().unwrap_or_default(), + } + } + + /// Returns the local path if this is a `Local` location, `None` for `Remote`. + /// Callers that only work with local files (LSP, save-to-disk, reveal-in-finder) + /// should use this to gate their behavior. + pub fn to_local_path(&self) -> Option<&Path> { + match self { + FileLocation::Local(path) => Some(path.as_path()), + FileLocation::Remote(_) => None, + } + } +} + +impl From for FileLocation { + fn from(path: PathBuf) -> Self { + FileLocation::Local(path) + } +} + +impl From for FileLocation { + fn from(remote: RemotePath) -> Self { + FileLocation::Remote(remote) + } +} + /// Tracks sync state between client and server for a single remote buffer. /// /// Uses a version vector with two components: diff --git a/app/src/code/editor_management.rs b/app/src/code/editor_management.rs index e5441a941d..7e2c481caf 100644 --- a/app/src/code/editor_management.rs +++ b/app/src/code/editor_management.rs @@ -3,6 +3,7 @@ use std::{ path::{Path, PathBuf}, }; +use super::buffer_location::FileLocation; use crate::ai::skills::SkillOpenOrigin; use ai::skills::SkillReference; use serde::{Deserialize, Serialize}; @@ -118,8 +119,8 @@ pub enum CodeSource { AIAction { id: AIAgentActionId }, /// Opened from project rules (WARP.md) file. ProjectRules { path: PathBuf }, - /// Opened from file tree. - FileTree { path: PathBuf }, + /// Opened from file tree (local or remote). + FileTree { location: FileLocation }, /// Opened from macOS Finder via "Open With". Finder { path: PathBuf }, /// Opened from a skill. @@ -148,14 +149,41 @@ impl CodeSource { pub fn path(&self) -> Option { match self { Self::New { .. } | Self::AIAction { .. } => None, + Self::FileTree { location, .. } => match location { + FileLocation::Local(path) => Some(path.clone()), + FileLocation::Remote(_) => None, + }, Self::Link { path, .. } | Self::ProjectRules { path } - | Self::FileTree { path } | Self::Finder { path } | Self::Skill { path, .. } => Some(path.clone()), } } + /// Returns the `FileLocation` for file tree sources. + pub fn file_location(&self) -> Option<&FileLocation> { + match self { + Self::FileTree { location } => Some(location), + _ => None, + } + } + + /// Returns the `FileLocation` for any source that has a backing file. + /// + /// Unlike `path()` (which only returns local paths) and `file_location()` + /// (which only covers `FileTree`), this covers every variant that maps to + /// a file — local or remote. + pub fn location(&self) -> Option { + match self { + Self::New { .. } | Self::AIAction { .. } => None, + Self::FileTree { location } => Some(location.clone()), + Self::Link { path, .. } + | Self::ProjectRules { path } + | Self::Finder { path } + | Self::Skill { path, .. } => Some(FileLocation::Local(path.clone())), + } + } + /// Returns true if this is a bundled skill that should be read-only. pub fn is_bundled_skill(&self) -> bool { matches!( @@ -186,6 +214,9 @@ impl CodeSource { Self::Link { .. } => "link", Self::AIAction { .. } => "ai_action", Self::ProjectRules { .. } => "project_rules", + Self::FileTree { + location: FileLocation::Remote(_), + } => "remote_file_tree", Self::FileTree { .. } => "file_tree", Self::Finder { .. } => "finder", Self::Skill { .. } => "skill", @@ -197,7 +228,13 @@ impl CodeSource { /// `AIAction` is ephemeral (tied to a live conversation) and should not /// be restored. pub fn is_restorable(&self) -> bool { - !matches!(self, Self::AIAction { .. }) + !matches!( + self, + Self::AIAction { .. } + | Self::FileTree { + location: FileLocation::Remote(_), + } + ) } } diff --git a/app/src/code/file_tree/view.rs b/app/src/code/file_tree/view.rs index db2cc94cbf..28e83548f0 100644 --- a/app/src/code/file_tree/view.rs +++ b/app/src/code/file_tree/view.rs @@ -42,6 +42,7 @@ use warpui::{ use warpui::{BlurContext, ModelHandle}; use crate::code::active_file::{ActiveFileEvent, ActiveFileModel}; +use crate::code::buffer_location::FileLocation; use crate::coding_panel_enablement_state::CodingPanelEnablementState; use crate::editor::{EditorOptions, EditorView, TextOptions}; #[cfg(feature = "local_fs")] @@ -67,7 +68,6 @@ use crate::{ use warp_core::features::FeatureFlag; use warp_core::ui::theme::{color::internal_colors, Fill}; use warp_core::HostId; -use warpui::ui_components::components::UiComponent; mod editing; mod render; @@ -1978,7 +1978,6 @@ impl FileTreeView { let is_selected = self.selected_item.as_ref() == Some(id); let is_expanded = self.is_item_expanded(&id.root, item); let render_state = item.to_render_state(is_expanded, appearance); - let is_remote_file = root_dir.is_remote() && matches!(item, FileTreeItem::File { .. }); let item_display_name = render_state.display_name.clone(); let item_position_id = format!("file_tree_item:{item_display_name}"); @@ -1997,34 +1996,14 @@ impl FileTreeView { let id_for_context = id.clone(); let id_for_drop = id.clone(); let id_for_drag = id.clone(); - let ui_builder = appearance.ui_builder(); let hoverable = Hoverable::new(render_state.mouse_state.clone(), move |mouse_state| { let item_highlight_state = ItemHighlightState::new(is_selected, mouse_state); - let element = Self::render_item_with_hover( + Self::render_item_with_hover( render_state, appearance, item_highlight_state, editor_view, - ); - - if is_remote_file && mouse_state.is_hovered() { - let tooltip = ui_builder - .tool_tip("Opening files is unavailable for remote sessions".to_string()) - .build() - .finish(); - let offset = OffsetPositioning::offset_from_parent( - Vector2F::new(0., 4.), - ParentOffsetBounds::WindowByPosition, - ParentAnchor::BottomLeft, - ChildAnchor::TopLeft, - ); - Stack::new() - .with_child(element) - .with_positioned_overlay_child(tooltip, offset) - .finish() - } else { - element - } + ) }) .on_click( move |event_ctx: &mut EventContext, _app_ctx: &AppContext, _position| { @@ -2047,12 +2026,7 @@ impl FileTreeView { }); }, ) - // Remote files can't be opened in the editor, so use the default cursor. - .with_cursor(if is_remote_file { - Cursor::Arrow - } else { - Cursor::PointingHand - }) + .with_cursor(Cursor::PointingHand) .finish(); let draggable = Draggable::new(draggable_state, hoverable) @@ -2251,7 +2225,7 @@ impl FileTreeView { ); ctx.emit(FileTreeEvent::OpenFile { - path: path.to_path_buf(), + path: FileLocation::Local(path.to_path_buf()), target, line_col: None, }); @@ -2273,8 +2247,20 @@ impl FileTreeView { match item { FileTreeItem::File { metadata, .. } => { - // Remote file trees don't support opening files in the editor. - if !is_remote { + if is_remote { + // Emit a remote open event if we have a host ID. + if let Some(host_id) = &root_dir.remote_host_id { + let remote_path = warp_util::remote_path::RemotePath::new( + host_id.clone(), + (*metadata.path).clone(), + ); + ctx.emit(FileTreeEvent::OpenFile { + path: FileLocation::Remote(remote_path), + target: FileTarget::CodeEditor(EditorLayout::SplitPane), + line_col: None, + }); + } + } else { let path = metadata.path.to_local_path_lossy(); self.open_file(&path, None, ctx); } @@ -2898,7 +2884,7 @@ pub enum FileTreeEvent { AttachAsContext { path: PathBuf }, #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] OpenFile { - path: PathBuf, + path: FileLocation, target: FileTarget, line_col: Option, }, diff --git a/app/src/code/global_buffer_model.rs b/app/src/code/global_buffer_model.rs index e4787618e8..79c8e4640f 100644 --- a/app/src/code/global_buffer_model.rs +++ b/app/src/code/global_buffer_model.rs @@ -19,9 +19,12 @@ use warp_util::content_version::ContentVersion; use warp_util::file::{FileId, FileLoadError, FileSaveError}; use warp_util::host_id::HostId; use warp_util::remote_path::RemotePath; +use warp_util::standardized_path::StandardizedPath; use warpui::{Entity, ModelContext, ModelHandle, SingletonEntity, WeakModelHandle}; -use super::buffer_location::{BufferLocation, SyncClock}; +use remote_server::manager::RemoteServerManager; + +use super::buffer_location::{FileLocation, SyncClock}; cfg_if::cfg_if! { if #[cfg(feature = "local_fs")] { @@ -249,7 +252,7 @@ pub struct CharOffsetEdit { /// This allows multiple editors to share the same buffer when editing the same file, /// enabling consistent content synchronization and more efficient memory usage. pub struct GlobalBufferModel { - location_to_id: BiMap, + location_to_id: BiMap, buffers: HashMap, } @@ -269,15 +272,14 @@ impl GlobalBufferModel { if FeatureFlag::SshRemoteServer.is_enabled() { use remote_server::manager::{RemoteServerManager, RemoteServerManagerEvent}; let mgr = RemoteServerManager::handle(_ctx); - _ctx.subscribe_to_model(&mgr, |me, event, ctx| { - if let RemoteServerManagerEvent::BufferUpdated { + _ctx.subscribe_to_model(&mgr, |me, event, ctx| match event { + RemoteServerManagerEvent::BufferUpdated { host_id, path, new_server_version, expected_client_version, edits, - } = event - { + } => { let char_edits: Vec<_> = edits .iter() .map(|e| CharOffsetEdit { @@ -295,6 +297,10 @@ impl GlobalBufferModel { ctx, ); } + RemoteServerManagerEvent::BufferConflictDetected { host_id, path } => { + me.handle_buffer_conflict_detected(host_id, path, ctx); + } + _ => {} }); } @@ -326,8 +332,8 @@ impl GlobalBufferModel { let paths_to_close: Vec = ids_to_remove .iter() .filter_map(|id| match self.location_to_id.get_by_right(id) { - Some(BufferLocation::Local(path)) => Some(path.clone()), - Some(BufferLocation::Remote(_)) | None => None, + Some(FileLocation::Local(path)) => Some(path.clone()), + Some(FileLocation::Remote(_)) | None => None, }) .collect(); @@ -362,8 +368,7 @@ impl GlobalBufferModel { fn cleanup_file_id(&mut self, file_id: FileId, _ctx: &mut ModelContext) { // Send didClose before removing the entry. - if let Some((BufferLocation::Local(path), _)) = - self.location_to_id.remove_by_right(&file_id) + if let Some((FileLocation::Local(path), _)) = self.location_to_id.remove_by_right(&file_id) { self.close_document_with_lsp(&path, _ctx); } @@ -711,7 +716,10 @@ impl GlobalBufferModel { } } - /// Save the content of a tracked buffer to disk via FileModel. + /// Save the content of a tracked buffer. + /// + /// For local buffers, saves to disk via `FileModel`. + /// For remote buffers, sends a `SaveBuffer` RPC to the remote server. #[cfg(feature = "local_fs")] pub fn save( &self, @@ -720,6 +728,36 @@ impl GlobalBufferModel { version: ContentVersion, ctx: &mut ModelContext, ) -> Result<(), FileSaveError> { + // Check if this is a remote buffer — save via the remote server RPC. + if let Some(state) = self.buffers.get(&file_id) { + if let BufferSource::Remote { remote_path, .. } = &state.source { + let host_id = remote_path.host_id.clone(); + let path = remote_path.path.as_str().to_string(); + let manager = RemoteServerManager::handle(ctx); + let Some(client) = manager.as_ref(ctx).client_for_host(&host_id).cloned() else { + return Err(FileSaveError::RemoteError( + "No remote server client available".to_string(), + )); + }; + ctx.spawn( + async move { client.save_buffer(path).await.map_err(|e| format!("{e}")) }, + move |_me, result, ctx| match result { + Ok(()) => { + ctx.emit(GlobalBufferModelEvent::FileSaved { file_id }); + } + Err(error) => { + log::warn!("Remote save failed: {error}"); + ctx.emit(GlobalBufferModelEvent::FailedToSave { + file_id, + error: Rc::new(FileSaveError::RemoteError(error)), + }); + } + }, + ); + return Ok(()); + } + } + FileModel::handle(ctx).update(ctx, |file_model, ctx| { file_model.save(file_id, content, version, ctx) }) @@ -762,7 +800,7 @@ impl GlobalBufferModel { /// Look up the file path for a tracked buffer. pub fn file_path(&self, file_id: FileId) -> Option<&Path> { match self.location_to_id.get_by_right(&file_id) { - Some(BufferLocation::Local(path)) => Some(path.as_path()), + Some(FileLocation::Local(path)) => Some(path.as_path()), _ => None, } } @@ -779,7 +817,7 @@ impl GlobalBufferModel { pub fn discard_unsaved_changes(&mut self, path: &Path, ctx: &mut ModelContext) { if let Some(id) = self .location_to_id - .get_by_left(&BufferLocation::Local(path.to_path_buf())) + .get_by_left(&FileLocation::Local(path.to_path_buf())) .cloned() { let path_clone = path.to_path_buf(); @@ -838,7 +876,7 @@ impl GlobalBufferModel { // Internal state cleanup is synchronous; only the LSP didClose notification // is dispatched asynchronously (with a no-op callback), so there is no race // between state removal and the close completing. - if let Some((BufferLocation::Local(old_path), _)) = + if let Some((FileLocation::Local(old_path), _)) = self.location_to_id.remove_by_right(&old_file_id) { self.close_document_with_lsp(&old_path, ctx); @@ -894,7 +932,7 @@ impl GlobalBufferModel { // to avoid orphaning the previous FileId in `self.buffers`. if let Some(old_file_id) = self .location_to_id - .get_by_left(&BufferLocation::Local(path.clone())) + .get_by_left(&FileLocation::Local(path.clone())) .copied() { self.cleanup_file_id(old_file_id, ctx); @@ -908,7 +946,7 @@ impl GlobalBufferModel { }); self.location_to_id - .insert(BufferLocation::Local(path.clone()), file_id); + .insert(FileLocation::Local(path.clone()), file_id); self.buffers.insert( file_id, InternalBufferState { @@ -951,7 +989,7 @@ impl GlobalBufferModel { let version_matches_initial = buffer.as_ref(ctx).version_match(&initial_version); let fid = me .location_to_id - .get_by_left(&BufferLocation::Local(path_clone.clone())) + .get_by_left(&FileLocation::Local(path_clone.clone())) .cloned(); let previous_version = fid .and_then(|id| me.buffers.get(&id)) @@ -991,15 +1029,15 @@ impl GlobalBufferModel { /// Dispatches to the appropriate private opener based on the location variant. /// If a buffer already exists for this location and is loaded, returns the /// existing `BufferState`. - pub fn open(&mut self, location: BufferLocation, ctx: &mut ModelContext) -> BufferState { + pub fn open(&mut self, location: FileLocation, ctx: &mut ModelContext) -> BufferState { match location { #[cfg(feature = "local_fs")] - BufferLocation::Local(path) => self.open_local(path, false, ctx), + FileLocation::Local(path) => self.open_local(path, false, ctx), #[cfg(not(feature = "local_fs"))] - BufferLocation::Local(_) => { + FileLocation::Local(_) => { unimplemented!("Local buffers require the local_fs feature") } - BufferLocation::Remote(remote_path) => self.open_remote_buffer(remote_path, ctx), + FileLocation::Remote(remote_path) => self.open_remote_buffer(remote_path, ctx), } } @@ -1020,7 +1058,7 @@ impl GlobalBufferModel { ) -> BufferState { if let Some(id) = self .location_to_id - .get_by_left(&BufferLocation::Local(path.clone())) + .get_by_left(&FileLocation::Local(path.clone())) .cloned() { debug_assert!(self.buffers.contains_key(&id)); @@ -1115,7 +1153,7 @@ impl GlobalBufferModel { // This is needed to determine if we need a full sync later. let file_id = me .location_to_id - .get_by_left(&BufferLocation::Local(path_clone.clone())) + .get_by_left(&FileLocation::Local(path_clone.clone())) .cloned(); let previous_version = file_id .and_then(|id| me.buffers.get(&id)) @@ -1150,7 +1188,7 @@ impl GlobalBufferModel { }); self.location_to_id - .insert(BufferLocation::Local(path.to_path_buf()), file_id); + .insert(FileLocation::Local(path.to_path_buf()), file_id); let source = if is_server_local { BufferSource::ServerLocal { sync_clock: SyncClock::new(), @@ -1220,7 +1258,7 @@ impl GlobalBufferModel { let file_id = self .location_to_id - .get_by_left(&BufferLocation::Local(path.to_path_buf()))?; + .get_by_left(&FileLocation::Local(path.to_path_buf()))?; let buffer = self.buffer_handle_for_id(*file_id, ctx)?; let buffer_ref = buffer.as_ref(ctx); @@ -1370,7 +1408,7 @@ impl GlobalBufferModel { .location_to_id .iter() .filter_map(|(location, id)| { - let BufferLocation::Local(path) = location else { + let FileLocation::Local(path) = location else { return None; }; if !path.starts_with(workspace_path) { @@ -1487,6 +1525,16 @@ impl GlobalBufferModel { ctx.spawn(sync_future, |_, _, _| {}); } + /// Look up a remote buffer's `FileId` by host and path string. + /// + /// Uses the `location_to_id` BiMap for O(1) lookup instead of scanning + /// all buffer states. + fn find_remote_file_id(&self, host_id: &HostId, path: &str) -> Option { + let std_path = StandardizedPath::try_new(path).ok()?; + let location = FileLocation::Remote(RemotePath::new(host_id.clone(), std_path)); + self.location_to_id.get_by_left(&location).copied() + } + // ── Remote buffer operations ────────────────────────────────────── /// Open a remote buffer identified by a `RemotePath`. @@ -1500,7 +1548,7 @@ impl GlobalBufferModel { remote_path: RemotePath, ctx: &mut ModelContext, ) -> BufferState { - let location = BufferLocation::Remote(remote_path.clone()); + let location = FileLocation::Remote(remote_path.clone()); // Return existing buffer if already open. if let Some(id) = self.location_to_id.get_by_left(&location).cloned() { @@ -1526,9 +1574,13 @@ impl GlobalBufferModel { // Subscribe to buffer content changes so edits are sent back to the daemon. let client_for_sub = { - let manager = remote_server::manager::RemoteServerManager::handle(ctx); + let manager = RemoteServerManager::handle(ctx); manager.as_ref(ctx).client_for_host(&host_id).cloned() }; + log::debug!( + "[remote-buffer] Setting up edit subscription: path={path_str} has_client={}", + client_for_sub.is_some() + ); if let Some(client) = &client_for_sub { let client = client.clone(); let path_for_edit = path_str.clone(); @@ -1580,6 +1632,12 @@ impl GlobalBufferModel { } }) .collect(); + log::debug!( + "[remote-buffer] Sending BufferEdit: path={path_for_edit} \ + expected_sv={expected_sv} new_cv={} edit_count={}", + new_cv.as_u64(), + edits.len() + ); client.send_buffer_edit( path_for_edit.clone(), expected_sv, @@ -1606,9 +1664,8 @@ impl GlobalBufferModel { ); // Look up the client on the main thread, then send OpenBuffer asynchronously. - let manager = remote_server::manager::RemoteServerManager::handle(ctx); - let Some(client) = manager.as_ref(ctx).client_for_host(&host_id).cloned() else { - log::warn!("No remote server client for host {host_id:?}"); + let Some(client) = client_for_sub else { + log::warn!("[remote-buffer] No remote server client for host {host_id:?}"); ctx.emit(GlobalBufferModelEvent::FailedToLoad { file_id, error: Rc::new(FileLoadError::DoesNotExist), @@ -1616,47 +1673,83 @@ impl GlobalBufferModel { return BufferState::new(file_id, buffer); }; + log::debug!("[remote-buffer] Sending OpenBuffer for path={path_str} host={host_id:?}"); ctx.spawn( async move { client - .open_buffer(path_str) + .open_buffer(path_str, false) .await .map_err(|e| format!("{e}")) }, - move |me, result, ctx| match result { - Ok(response) => { - let Some(state) = me.buffers.get_mut(&file_id) else { - return; - }; - if let BufferSource::Remote { sync_clock, .. } = &mut state.source { - *sync_clock = Some(SyncClock::from_wire(response.server_version, 0)); - } - let Some(buffer) = state.buffer.upgrade(ctx) else { - return; - }; - let version = ContentVersion::new(); - buffer.update(ctx, |buffer, ctx| { - buffer.replace_all(&response.content, ctx); - buffer.set_version(version); - }); - ctx.emit(GlobalBufferModelEvent::BufferLoaded { - file_id, - content_version: version, - }); - } - Err(error) => { - log::warn!("Failed to open remote buffer: {error}"); - ctx.emit(GlobalBufferModelEvent::FailedToLoad { - file_id, - error: Rc::new(FileLoadError::DoesNotExist), - }); - } + move |me, result, ctx| { + me.apply_open_buffer_response(file_id, result, ctx); }, ); BufferState::new(file_id, buffer) } + /// Shared handler for `OpenBuffer` RPC responses. + /// + /// On success, replaces the buffer content with the server's latest + /// on-disk content, resets the `SyncClock`, and emits `BufferLoaded`. + /// On failure, emits `FailedToLoad`. + fn apply_open_buffer_response( + &mut self, + file_id: FileId, + result: Result, + ctx: &mut ModelContext, + ) { + let res = result.and_then(|res| { + res.result + .ok_or("No result in OpenBuffer response".to_string()) + }); + match res { + Ok(remote_server::proto::open_buffer_response::Result::Success( + remote_server::proto::OpenBufferSuccess { + content, + server_version, + }, + )) => { + log::debug!( + "[remote-buffer] OpenBuffer response: content_len={} server_version={}", + content.len(), + server_version, + ); + let Some(state) = self.buffers.get_mut(&file_id) else { + log::warn!("[remote-buffer] Buffer state missing for file_id={file_id:?}"); + return; + }; + if let BufferSource::Remote { sync_clock, .. } = &mut state.source { + *sync_clock = Some(SyncClock::from_wire(server_version, 0)); + } + let Some(buffer) = state.buffer.upgrade(ctx) else { + log::warn!("[remote-buffer] Buffer handle deallocated for file_id={file_id:?}"); + return; + }; + let version = ContentVersion::new(); + buffer.update(ctx, |buffer, ctx| { + buffer.replace_all(&content, ctx); + buffer.set_version(version); + }); + ctx.emit(GlobalBufferModelEvent::BufferLoaded { + file_id, + content_version: version, + }); + } + Ok(remote_server::proto::open_buffer_response::Result::Error( + remote_server::proto::FileOperationError { message: error }, + )) + | Err(error) => { + log::warn!("[remote-buffer] Failed to open remote buffer: {error}"); + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::DoesNotExist), + }); + } + } + } + // ── Server-local buffer operations (daemon side) ──────────────── /// Open a server-local buffer for the given file path on the daemon. @@ -1696,7 +1789,7 @@ impl GlobalBufferModel { }; if !sync_clock.client_edit_matches(expected_server_version) { - log::info!( + log::debug!( "Rejected client edit: expected S={:?}, actual S={:?}", expected_server_version, sync_clock.server_version @@ -1732,6 +1825,10 @@ impl GlobalBufferModel { } /// Save a server-local buffer to disk. + /// + /// Uses the buffer's current `ContentVersion` (not a fresh one) so that + /// `FileModel` can detect concurrent modifications between the save + /// request and the disk write completing. #[cfg(feature = "local_fs")] pub fn save_server_local( &mut self, @@ -1745,7 +1842,7 @@ impl GlobalBufferModel { return Err(FileSaveError::RemoteError("Buffer deallocated".to_string())); }; let content = buffer.as_ref(ctx).text().into_string(); - let version = ContentVersion::new(); + let version = buffer.as_ref(ctx).version(); FileModel::handle(ctx).update(ctx, |file_model, ctx| { file_model.save(file_id, content, version, ctx) }) @@ -1823,6 +1920,194 @@ impl GlobalBufferModel { .is_some_and(|state| matches!(state.source, BufferSource::ServerLocal { .. })) } + /// Force-reload a server-local buffer from disk, discarding any in-memory + /// edits. + /// + /// Reads the file, replaces the buffer content, bumps the server version + /// in the `SyncClock`, and emits both `BufferLoaded` (so the requesting + /// connection gets the new content) and `ServerLocalBufferUpdated` (so + /// other connections receive a `BufferUpdatedPush` with the fresh content). + #[cfg(feature = "local_fs")] + pub fn force_reload_server_local( + &mut self, + file_id: FileId, + ctx: &mut ModelContext, + ) -> Result<(), String> { + let Some(state) = self.buffers.get(&file_id) else { + return Err("force_reload: no local path for file_id={file_id:?}".to_string()); + }; + let Some(file_path) = + self.location_to_id + .get_by_right(&file_id) + .and_then(|loc| match loc { + FileLocation::Local(p) => Some(p.clone()), + FileLocation::Remote(_) => None, + }) + else { + return Err("force_reload: no local path for file_id={file_id:?}".to_string()); + }; + // Capture the current client version before the reload so we can + // include it in the ServerLocalBufferUpdated event. + let expected_client_version = match &state.source { + BufferSource::ServerLocal { sync_clock, .. } => sync_clock.client_version, + _ => { + return Err("force_reload called on non-ServerLocal buffer {file_id:?}".to_string()); + } + }; + + ctx.spawn( + async move { FileModel::read_content_for_file(&file_path).await }, + move |me, content, ctx| match content { + Ok(content) => { + let Some(state) = me.buffers.get_mut(&file_id) else { + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::DoesNotExist), + }); + return; + }; + let Some(buffer) = state.buffer.upgrade(ctx) else { + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::DoesNotExist), + }); + return; + }; + + // Capture the end of the old buffer for the replacement range + // BEFORE replacing content. + let old_end = buffer.as_ref(ctx).max_charoffset(); + + let new_version = ContentVersion::new(); + buffer.update(ctx, |buffer, ctx| { + buffer.replace_all(&content, ctx); + buffer.set_version(new_version); + }); + + state.set_base_content_version(new_version); + FileModel::handle(ctx).update(ctx, |file_model, _ctx| { + file_model.set_version(file_id, new_version); + }); + + // Bump the server version in the sync clock. + let new_server_version = + if let BufferSource::ServerLocal { sync_clock, .. } = &mut state.source { + let sv = sync_clock.bump_server(); + // Reset client version to 0 ("no client edits"). + // server_version tracks disk state; client_version + // tracks user edits. After a force-reload both sides + // agree on CV=0 (the client also resets via + // apply_open_buffer_response → SyncClock::from_wire). + sync_clock.client_version = ContentVersion::from_raw(0); + sv + } else { + return; + }; + + // Build a single full-replacement edit so other connections + // can apply it via BufferUpdatedPush. + let char_offset_edits = vec![CharOffsetEdit { + start: CharOffset::from(1usize), + end: old_end, + text: content, + }]; + + // Emit ServerLocalBufferUpdated BEFORE BufferLoaded so that + // the ServerModel's handler can peek at pending OpenBuffer + // requests to exclude the requesting connection from the + // broadcast. BufferLoaded consumes those pending requests. + ctx.emit(GlobalBufferModelEvent::ServerLocalBufferUpdated { + file_id, + edits: char_offset_edits, + new_server_version, + expected_client_version, + }); + ctx.emit(GlobalBufferModelEvent::BufferLoaded { + file_id, + content_version: new_version, + }); + } + Err(e) => { + log::warn!("[server-local] force_reload failed: {e}"); + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: e.into(), + }); + } + }, + ); + + Ok(()) + } + + /// Re-open an existing remote buffer by sending `OpenBuffer` with + /// `force_reload = true` to the server. + /// + /// The server re-reads the file from disk into the existing buffer and + /// broadcasts a `BufferUpdatedPush` to all other connections. The + /// requesting connection receives the fresh content via + /// `OpenBufferResponse`, which is applied by `apply_open_buffer_response`. + /// + /// On failure, emits `FailedToLoad` (the caller should keep the current + /// buffer state so the user can retry). + #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] + pub fn reopen_remote_buffer(&mut self, file_id: FileId, ctx: &mut ModelContext) { + let Some(state) = self.buffers.get(&file_id) else { + return; + }; + let BufferSource::Remote { remote_path, .. } = &state.source else { + return; + }; + + let path_str = remote_path.path.as_str().to_string(); + let host_id = remote_path.host_id.clone(); + + let manager = RemoteServerManager::handle(ctx); + let Some(client) = manager.as_ref(ctx).client_for_host(&host_id).cloned() else { + log::warn!("[remote-buffer] reopen: no client for host {host_id:?}"); + ctx.emit(GlobalBufferModelEvent::FailedToLoad { + file_id, + error: Rc::new(FileLoadError::DoesNotExist), + }); + return; + }; + + log::debug!("[remote-buffer] Re-opening buffer with force_reload: path={path_str}"); + ctx.spawn( + async move { + client + .open_buffer(path_str, true) + .await + .map_err(|e| format!("{e}")) + }, + move |me, result, ctx| { + me.apply_open_buffer_response(file_id, result, ctx); + }, + ); + } + + /// Handle an incoming `BufferConflictDetected` push from the remote server. + /// + /// The server detected that the file changed on disk while the client + /// had unsaved edits. Emits `RemoteBufferConflict` so the UI shows + /// the conflict resolution banner. + #[cfg_attr(not(feature = "local_tty"), allow(dead_code))] + fn handle_buffer_conflict_detected( + &mut self, + host_id: &HostId, + path: &str, + ctx: &mut ModelContext, + ) { + log::debug!("[remote-buffer] BufferConflictDetected: host={host_id} path={path}"); + + let Some(file_id) = self.find_remote_file_id(host_id, path) else { + log::warn!("[remote-buffer] BufferConflictDetected for unknown buffer: {path}"); + return; + }; + + ctx.emit(GlobalBufferModelEvent::RemoteBufferConflict { file_id }); + } + /// Handle an incoming `BufferUpdatedPush` from the remote server. /// /// Accepts incremental edits (1-indexed char offsets matching `CharOffset`) @@ -1838,18 +2123,14 @@ impl GlobalBufferModel { edits: &[CharOffsetEdit], ctx: &mut ModelContext, ) { - // Find the buffer by scanning for a Remote source with matching host+path. - let file_id = self.buffers.iter().find_map(|(id, state)| { - if let BufferSource::Remote { remote_path, .. } = &state.source { - if remote_path.host_id == *host_id && remote_path.path.as_str() == path { - return Some(*id); - } - } - None - }); + log::debug!( + "[remote-buffer] BufferUpdatedPush: path={path} new_sv={new_server_version} \ + expected_cv={expected_client_version} edit_count={}", + edits.len() + ); - let Some(file_id) = file_id else { - log::warn!("BufferUpdatedPush for unknown remote buffer: {path}"); + let Some(file_id) = self.find_remote_file_id(host_id, path) else { + log::warn!("[remote-buffer] BufferUpdatedPush for unknown remote buffer: {path}"); return; }; @@ -1864,9 +2145,19 @@ impl GlobalBufferModel { return; }; + log::debug!( + "[remote-buffer] SyncClock state: local_sv={:?} local_cv={:?}", + sync_clock.server_version, + sync_clock.client_version, + ); + let expected_cv = ContentVersion::from_raw(expected_client_version as usize); if sync_clock.server_push_matches(expected_cv) { // Accept the update — apply edits incrementally. + log::debug!( + "[remote-buffer] Accepting push: applying {} edits", + edits.len() + ); sync_clock.server_version = ContentVersion::from_raw(new_server_version as usize); let Some(buffer) = state.buffer.upgrade(ctx) else { @@ -1884,14 +2175,33 @@ impl GlobalBufferModel { (start..end, edit.text.clone()) }) .collect(); - buffer.insert_at_char_offset_ranges(char_edits, new_version, ctx); }); + + // Notify LocalCodeEditor so it updates base_content_version. + // Without this, has_unsaved_changes() would compare the stale + // initial-load version against the now-different buffer version + // and incorrectly report unsaved changes. + ctx.emit(GlobalBufferModelEvent::BufferUpdatedFromFileEvent { + file_id, + success: true, + content_version: new_version, + }); } else { + // Check if the push is stale — its server version is already + // consumed (e.g. via an OpenBufferResponse from a force-reload). + if new_server_version <= sync_clock.server_version.as_u64() { + log::info!( + "[remote-buffer] Dropping stale BufferUpdatedPush for {path}: \ + push_sv={new_server_version} <= local_sv={:?}", + sync_clock.server_version + ); + return; + } // Conflict — local edits diverged from server. log::info!( - "Remote buffer conflict for {path}: expected C={expected_client_version}, \ - local C={:?}", + "[remote-buffer] CONFLICT for {path}: push expected C={expected_client_version}, \ + but local C={:?}. Emitting RemoteBufferConflict.", sync_clock.client_version ); ctx.emit(GlobalBufferModelEvent::RemoteBufferConflict { file_id }); @@ -1918,7 +2228,7 @@ impl GlobalBufferModel { ctx: &mut ModelContext, ) -> BufferState { let remote_path = RemotePath::new(host_id, path); - let location = BufferLocation::Remote(remote_path.clone()); + let location = FileLocation::Remote(remote_path.clone()); let file_id = warp_util::file::FileId::new(); let buffer = ctx.add_model(|_| Buffer::default()); let version = ContentVersion::new(); diff --git a/app/src/code/local_code_editor.rs b/app/src/code/local_code_editor.rs index c845f44d3e..879c1aad84 100644 --- a/app/src/code/local_code_editor.rs +++ b/app/src/code/local_code_editor.rs @@ -53,7 +53,7 @@ use crate::menu::{Event, Menu, MenuItem, MenuItemFields}; use crate::{ code::{ - buffer_location::BufferLocation, + buffer_location::FileLocation as BufferFileLocation, editor::model::HoverableLink, footer::{CodeFooterView, CodeFooterViewEvent}, global_buffer_model::{BufferState, GlobalBufferModel}, @@ -169,9 +169,9 @@ pub enum LocalCodeEditorEvent { /// Metadata about a file that is opened in the code view. #[derive(Debug, Clone)] -enum LoadedFileMetadata { - /// Normal file with both FileId and path (for files that are actually opened) - LocalFile { id: FileId, path: PathBuf }, +struct LoadedFileMetadata { + id: FileId, + location: BufferFileLocation, } pub use super::diff_viewer::DisplayMode; @@ -280,6 +280,9 @@ pub struct LocalCodeEditorView { was_edited: bool, /// Content version of the base file state. base_content_version: Option, + /// Set to `true` when a `RemoteBufferConflict` event fires for this + /// editor's buffer. Cleared when the user discards or overwrites. + has_remote_conflict: bool, conflict_banner_mouse_states: ConflictResolutionBannerMouseStates, /// Default directory to use for save dialogs when creating new files default_directory: Option, @@ -484,6 +487,7 @@ impl LocalCodeEditorView { selection_as_context_tooltip: None, was_edited: false, base_content_version: None, + has_remote_conflict: false, conflict_banner_mouse_states: Default::default(), default_directory: None, lsp_server: None, @@ -1168,9 +1172,13 @@ impl LocalCodeEditorView { GlobalBufferModel::as_ref(ctx).buffer_loaded(file_id) } - /// Construct a new local editor view with a shared buffer. + /// Construct a new editor view with a shared buffer backed by the given location. + /// + /// For local files, sets the language from the file path and wires up LSP. + /// For remote files, sets the language from the extension and skips + /// local-only wiring (LSP, footer). pub fn new_with_global_buffer( - path: &Path, + location: BufferFileLocation, editor_constructor: T, enable_diff_nav_by_default: bool, display_mode: Option, @@ -1179,26 +1187,38 @@ impl LocalCodeEditorView { where T: FnOnce(BufferState, &mut ViewContext) -> ViewHandle, { - let buffer_state = GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| { - model.open(BufferLocation::Local(path.to_path_buf()), ctx) - }); + let buffer_state = GlobalBufferModel::handle(ctx) + .update(ctx, |model, ctx| model.open(location.clone(), ctx)); let file_id = buffer_state.file_id; let editor = editor_constructor(buffer_state, ctx); - editor.update(ctx, |editor, ctx| { - editor.set_language_with_path(path, ctx); - // Rebuild layout and bootstrap syntax highlighting for the editor with existing buffer content. - editor.model.update(ctx, |model, ctx| { - model.rebuild_layout_with_syntax_highlighting(ctx) - }); - }); + match &location { + BufferFileLocation::Local(path) => { + editor.update(ctx, |editor, ctx| { + editor.set_language_with_path(path, ctx); + editor.model.update(ctx, |model, ctx| { + model.rebuild_layout_with_syntax_highlighting(ctx) + }); + }); + } + BufferFileLocation::Remote(remote_path) => { + if let Some(ext) = remote_path.path.extension() { + editor.update(ctx, |editor, ctx| { + editor.set_language_with_name(ext, ctx); + editor.model.update(ctx, |model, ctx| { + model.rebuild_layout_with_syntax_highlighting(ctx) + }); + }); + } + } + } let mut local_editor = Self::new(editor, None, enable_diff_nav_by_default, display_mode, ctx); - local_editor.metadata = Some(LoadedFileMetadata::LocalFile { + local_editor.metadata = Some(LoadedFileMetadata { id: file_id, - path: path.to_path_buf(), + location, }); Self::subscribe_to_global_buffer_events(file_id, ctx); @@ -1498,7 +1518,13 @@ impl LocalCodeEditorView { GlobalBufferModelEvent::BufferLoaded { content_version, .. } => { + // For a reopen (discard), base_content_version is already + // set from the initial load. Accept the new version and + // clear any conflict flag. + me.has_remote_conflict = false; if me.base_content_version.is_some() { + me.base_content_version = Some(*content_version); + ctx.notify(); return; } me.base_content_version = Some(*content_version); @@ -1526,6 +1552,7 @@ impl LocalCodeEditorView { } } GlobalBufferModelEvent::FileSaved { .. } => { + me.has_remote_conflict = false; ctx.emit(LocalCodeEditorEvent::FileSaved); } GlobalBufferModelEvent::FailedToSave { error, .. } => { @@ -1534,8 +1561,11 @@ impl LocalCodeEditorView { error: error.clone(), }); } - GlobalBufferModelEvent::RemoteBufferConflict { .. } - | GlobalBufferModelEvent::ServerLocalBufferUpdated { .. } => { + GlobalBufferModelEvent::RemoteBufferConflict { .. } => { + me.has_remote_conflict = true; + ctx.notify(); + } + GlobalBufferModelEvent::ServerLocalBufferUpdated { .. } => { // Not relevant for local code editors. } } @@ -1543,13 +1573,18 @@ impl LocalCodeEditorView { } pub fn has_version_conflicts(&self, app: &AppContext) -> bool { + // Remote buffers use SyncClock for conflict detection. + // The flag is set by the RemoteBufferConflict event handler. + if matches!(self.file_location(), Some(BufferFileLocation::Remote(_))) { + return self.has_remote_conflict; + } let Some(file_id) = self.file_id() else { return false; }; self.has_unsaved_changes(app) && self.base_content_version != GlobalBufferModel::as_ref(app).base_version(file_id) } - /// Save the file to the local file system. + /// Save the file to the local file system (or remotely via the remote server). /// This will only return an error immediately if there is a failure in the sync part of the call. /// Other errors could be returned asynchronously via the FileModelEvent::FailedToSave event. pub fn save_local(&mut self, ctx: &mut ViewContext) -> Result<(), ImmediateSaveError> { @@ -1599,9 +1634,9 @@ impl LocalCodeEditorView { .update(ctx, |model, ctx| model.register(path.clone(), buffer, ctx)); let file_id = buffer_state.file_id; - me.metadata = Some(LoadedFileMetadata::LocalFile { + me.metadata = Some(LoadedFileMetadata { id: file_id, - path: path.clone(), + location: BufferFileLocation::Local(path.clone()), }); me.set_new_file(false); @@ -1666,15 +1701,18 @@ impl LocalCodeEditorView { } pub fn file_id(&self) -> Option { - self.metadata.as_ref().map(|metadata| match metadata { - LoadedFileMetadata::LocalFile { id, .. } => *id, - }) + self.metadata.as_ref().map(|m| m.id) + } + + /// Returns the unified file location (local or remote). + pub fn file_location(&self) -> Option<&BufferFileLocation> { + self.metadata.as_ref().map(|m| &m.location) } + /// Returns the local path if this editor is backed by a local file. + /// Returns `None` for remote files. Used by LSP and other local-only code paths. pub fn file_path(&self) -> Option<&Path> { - self.metadata.as_ref().map(|metadata| match metadata { - LoadedFileMetadata::LocalFile { path, .. } => path.as_path(), - }) + self.file_location().and_then(|loc| loc.to_local_path()) } /// Update this editor's file identity after a `GlobalBufferModel::rename`. @@ -1688,9 +1726,9 @@ impl LocalCodeEditorView { ctx: &mut ViewContext, ) { let file_id = buffer_state.file_id; - self.metadata = Some(LoadedFileMetadata::LocalFile { + self.metadata = Some(LoadedFileMetadata { id: file_id, - path: new_path.to_path_buf(), + location: BufferFileLocation::Local(new_path.to_path_buf()), }); self.editor.update(ctx, |editor, ctx| { @@ -2230,6 +2268,17 @@ impl TypedActionView for LocalCodeEditorView { if let Some(path) = self.file_path().map(Path::to_path_buf) { self.base_content_version = Some(self.editor().as_ref(ctx).version(ctx)); ctx.emit(LocalCodeEditorEvent::DiscardUnsavedChanges { path }); + } else if self.has_remote_conflict { + // Remote file: re-open the buffer from the server to get + // the latest on-disk content. The BufferLoaded event will + // clear has_remote_conflict and update base_content_version. + // If the re-open fails, has_remote_conflict stays true and + // the banner remains visible so the user can retry. + if let Some(file_id) = self.file_id() { + GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| { + model.reopen_remote_buffer(file_id, ctx); + }); + } } } LocalCodeEditorAction::NavigateToTarget(location) => { diff --git a/app/src/code/view.rs b/app/src/code/view.rs index 2af52f05b1..4517fd7660 100644 --- a/app/src/code/view.rs +++ b/app/src/code/view.rs @@ -73,6 +73,7 @@ use crate::pane_group::{ }; use super::{ + buffer_location::FileLocation, diff_viewer::DiffViewer, editor::view::{CodeEditorEvent, CodeEditorView}, editor_management::{CodeManager, CodeSource}, @@ -264,9 +265,9 @@ impl CodeView { line_col: Option, ctx: &mut ViewContext, ) -> Self { - let path = source.path(); + let location = source.location(); let mut view = Self::new_internal(source, ctx); - view.open_or_focus_existing(path, line_col, ctx); + view.open_or_focus_existing(location, line_col, ctx); #[cfg(feature = "local_fs")] { view.update_markdown_mode_segmented_control(ctx); @@ -322,7 +323,8 @@ impl CodeView { ) -> Self { let mut view = Self::new_internal(source, ctx); for tab_snapshot in tabs { - let tab_data = view.build_tab_data(tab_snapshot.path.clone(), false, ctx); + let location = tab_snapshot.path.clone().map(FileLocation::Local); + let tab_data = view.build_tab_data(location, false, ctx); view.tab_group.push(tab_data); } let clamped_index = if view.tab_group.is_empty() { @@ -366,14 +368,20 @@ impl CodeView { } } - fn construct_shared_buffer_editor_from_path( + /// Construct an editor backed by the global shared buffer for the given location. + /// + /// For local files, additional features are wired up (selection-as-context, + /// find-references, footer). Remote files skip these because LSP and + /// related tooling run on the local machine. + fn construct_editor_for_location( &mut self, - path: &Path, + location: FileLocation, ctx: &mut ViewContext, ) -> ViewHandle { + let is_local = matches!(location, FileLocation::Local(_)); ctx.add_typed_action_view(|ctx| { let mut editor = LocalCodeEditorView::new_with_global_buffer( - path, + location, |buffer_state, ctx| { ctx.add_typed_action_view(|ctx| { CodeEditorView::new( @@ -394,20 +402,23 @@ impl CodeView { None, ctx, ); - if FeatureFlag::HoaCodeReview.is_enabled() { - editor = - editor.with_selection_as_context(Box::new(get_context_target_terminal_view)); + if is_local { + if FeatureFlag::HoaCodeReview.is_enabled() { + editor = editor + .with_selection_as_context(Box::new(get_context_target_terminal_view)); + } + let mut editor = editor.with_find_references_provider( + ShowFindReferencesCard { + editor_window_id: ctx.window_id(), + parent_scrollable_position_id: None, + }, + ctx, + ); + editor.add_footer(ctx); + editor + } else { + editor } - let mut editor = editor.with_find_references_provider( - ShowFindReferencesCard { - editor_window_id: ctx.window_id(), - parent_scrollable_position_id: None, - }, - ctx, - ); - - editor.add_footer(ctx); - editor }) } @@ -449,16 +460,17 @@ impl CodeView { fn build_tab_data( &mut self, - path: Option, + location: Option, preview: bool, ctx: &mut ViewContext, ) -> TabData { - // Opt out of shared buffer if we are creating a new file. - // TODO(kevin): Once the file is saved, we should convert that into a shared buffer. - let code_editor = if let Some(path) = path.as_ref() { - self.construct_shared_buffer_editor_from_path(path, ctx) - } else { - self.construct_new_file_editor(ctx) + let (code_editor, tab_path) = match location { + Some(loc) => { + let path = loc.to_local_path().map(|p| p.to_path_buf()); + let editor = self.construct_editor_for_location(loc, ctx); + (editor, path) + } + None => (self.construct_new_file_editor(ctx), None), }; let editor = code_editor.as_ref(ctx).editor().clone(); @@ -475,7 +487,7 @@ impl CodeView { }); // For new files (CodeSource::New), mark the editor as a new file and set default directory - if path.is_none() && matches!(self.source, CodeSource::New { .. }) { + if tab_path.is_none() && matches!(self.source, CodeSource::New { .. }) { let default_directory = self.source.default_directory().cloned(); code_editor.update(ctx, |local_editor, _ctx| { local_editor.set_new_file(true); @@ -573,7 +585,11 @@ impl CodeView { column_num: Some(*column), }; - me.open_or_focus_existing(Some(path.to_path_buf()), Some(line_col), ctx); + me.open_or_focus_existing( + Some(FileLocation::Local(path.to_path_buf())), + Some(line_col), + ctx, + ); if let Some(editor) = me.tab_at(me.active_tab_index()).map(|tab| &tab.editor_view) { editor.update(ctx, |editor, ctx| { editor.cursor_at(Point::new(line_1based as u32, *column as u32), ctx); @@ -598,7 +614,7 @@ impl CodeView { }); TabData { - path, + path: tab_path, editor_view: code_editor, mouse_state_handles: Default::default(), preview, @@ -672,7 +688,7 @@ impl CodeView { // Find the existing preview tab (if any) and replace it with a new GlobalBuffer-backed editor if let Some((preview_index, _)) = self.preview_tab() { - let new_tab = self.build_tab_data(Some(path.clone()), true, ctx); + let new_tab = self.build_tab_data(Some(FileLocation::Local(path.clone())), true, ctx); self.tab_group[preview_index] = new_tab; GlobalBufferModel::handle(ctx).update(ctx, |model, ctx| { @@ -684,7 +700,7 @@ impl CodeView { } // Create a new preview tab - let new_tab = self.build_tab_data(Some(path.clone()), true, ctx); + let new_tab = self.build_tab_data(Some(FileLocation::Local(path.clone())), true, ctx); self.tab_group.push(new_tab); let active_tab_index = self.tab_group.len() - 1; @@ -710,30 +726,34 @@ impl CodeView { pub fn open_or_focus_existing( &mut self, - path: Option, + location: Option, line_col: Option, ctx: &mut ViewContext, ) { + let local_path = location + .as_ref() + .and_then(|loc| loc.to_local_path().map(|p| p.to_path_buf())); + // If the tab already exists, focus it (and optionally jump) without re-opening from disk. - if let Some(existing_index) = self.focus_existing_tab_if_present(&path, ctx) { + if let Some(existing_index) = self.focus_existing_tab_if_present(&local_path, ctx) { if let Some(line_col) = line_col { self.jump_to_line_col_in_tab(existing_index, line_col, ctx); } return; } - self.open_new_tab_for_path(path, line_col, ctx); + self.open_new_tab(location, local_path, line_col, ctx); } fn focus_existing_tab_if_present( &mut self, - path: &Option, + local_path: &Option, ctx: &mut ViewContext, ) -> Option { let existing_index = self .tab_group .iter() - .position(|tab| tab.path.as_ref() == path.as_ref())?; + .position(|tab| tab.path.as_ref() == local_path.as_ref())?; self.set_active_tab_index(existing_index, ctx); Some(existing_index) } @@ -762,17 +782,18 @@ impl CodeView { }); } - fn open_new_tab_for_path( + fn open_new_tab( &mut self, - path: Option, + location: Option, + local_path: Option, line_col: Option, ctx: &mut ViewContext, ) { - let new_tab = self.build_tab_data(path.clone(), false, ctx); + let new_tab = self.build_tab_data(location, false, ctx); self.tab_group.push(new_tab); let active_tab_index = self.tab_group.len() - 1; - if let (Some(file_path), Some(tab)) = (path, self.tab_group.get(active_tab_index)) { + if let (Some(file_path), Some(tab)) = (local_path, self.tab_group.get(active_tab_index)) { ctx.emit(CodeViewEvent::FileOpened { file_path: file_path.clone(), tab_index: active_tab_index, @@ -799,15 +820,17 @@ impl CodeView { /// Set the title of the pane, which is the file path. fn set_title(&self, _unsaved_changes: bool, ctx: &mut ViewContext) { - let file = self.local_path(ctx); + let file_location = self + .tab_at(self.active_tab_index) + .and_then(|t| t.editor_view.as_ref(ctx).file_location().cloned()); let is_new = self .tab_at(self.active_tab_index) .is_some_and(|t| t.editor_view.as_ref(ctx).is_new_file()); - let title = if let Some(file) = file { - file.display().to_string() - } else { - "Untitled".to_string() + let title = match &file_location { + Some(FileLocation::Local(path)) => path.display().to_string(), + Some(FileLocation::Remote(remote_path)) => remote_path.path.as_str().to_string(), + None => "Untitled".to_string(), }; self.pane_configuration.update(ctx, |pane_config, ctx| { @@ -1846,9 +1869,22 @@ impl CodeView { let title = self .tab_group .first() - .and_then(|tab| tab.path.as_ref()) - .and_then(|path| path.file_name()) - .map(|name| name.to_string_lossy().to_string()) + .and_then(|tab| { + // For remote files, tab.path is None — derive the name from + // the editor's FileLocation metadata instead. + tab.path + .as_ref() + .and_then(|p| p.file_name().map(|f| f.to_string_lossy().to_string())) + .or_else(|| { + let name = tab + .editor_view + .as_ref(app) + .file_location() + .map(|loc| loc.display_name().to_string()) + .filter(|n| !n.is_empty()); + name + }) + }) .unwrap_or_else(|| "Untitled".to_string()); let appearance = Appearance::as_ref(app); diff --git a/app/src/code/wasm.rs b/app/src/code/wasm.rs index a8a4b53295..d006cd8084 100644 --- a/app/src/code/wasm.rs +++ b/app/src/code/wasm.rs @@ -6,7 +6,10 @@ use warpui::{ AppContext, Element, Entity, ModelHandle, TypedActionView, View, ViewContext, ViewHandle, }; -use super::{editor_management::CodeSource, local_code_editor::LocalCodeEditorView}; +use super::{ + buffer_location::FileLocation, editor_management::CodeSource, + local_code_editor::LocalCodeEditorView, +}; use crate::pane_group::{ focus_state::PaneFocusHandle, pane::view::{HeaderContent, HeaderRenderContext}, @@ -131,11 +134,11 @@ impl CodeView { pub fn open_or_focus_existing( &mut self, - path: Option, + location: Option, line_col: Option, ctx: &mut ViewContext, ) { - if let Some(path) = path { + if let Some(path) = location.and_then(|loc| loc.to_local_path().map(|p| p.to_path_buf())) { self.open_local(None, path, line_col, ctx); } } diff --git a/app/src/code_review/code_review_view.rs b/app/src/code_review/code_review_view.rs index c2111bdc67..3c06e07426 100644 --- a/app/src/code_review/code_review_view.rs +++ b/app/src/code_review/code_review_view.rs @@ -183,6 +183,8 @@ use super::{ git_dialog::{GitDialog, GitDialogEvent, GitDialogKind}, GlobalCodeReviewEvent, GlobalCodeReviewModel, }; +#[cfg(not(target_family = "wasm"))] +use crate::code::buffer_location::FileLocation; use crate::code::ShowCommentEditorProvider; #[cfg(not(target_family = "wasm"))] use crate::code::ShowFindReferencesCard; @@ -2940,7 +2942,7 @@ impl CodeReviewView { let local_code_view = ctx.add_typed_action_view(|ctx| { let editor = LocalCodeEditorView::new_with_global_buffer( - &full_file_path, + FileLocation::Local(full_file_path.clone()), |buffer_state, ctx| { ctx.add_typed_action_view(|ctx| { let mut editor_view = CodeEditorView::new( diff --git a/app/src/code_review/diff_state/mod.rs b/app/src/code_review/diff_state/mod.rs index f5b11086b7..a190d8e268 100644 --- a/app/src/code_review/diff_state/mod.rs +++ b/app/src/code_review/diff_state/mod.rs @@ -5,7 +5,7 @@ //! operations to whichever is active. //! All consumers should use `DiffStateModel` rather than accessing sub-models directly. -use crate::code::buffer_location::BufferLocation; +use crate::code::buffer_location::FileLocation; use crate::util::git::{Commit, PrInfo}; use warpui::{AppContext, ModelContext, ModelHandle}; @@ -37,15 +37,15 @@ impl warpui::Entity for DiffStateModel { impl DiffStateModel { // ── Construction ───────────────────────────────────────────────── - pub fn new(key: BufferLocation, ctx: &mut ModelContext) -> Self { + pub fn new(key: FileLocation, ctx: &mut ModelContext) -> Self { match key { - BufferLocation::Local(path) => { + FileLocation::Local(path) => { let repo_path = Some(path.display().to_string()); let local = ctx.add_model(|ctx| LocalDiffStateModel::new(repo_path, ctx)); ctx.subscribe_to_model(&local, Self::forward_event); Self::Local(local) } - BufferLocation::Remote(_remote_id) => { + FileLocation::Remote(_remote_id) => { let remote = ctx.add_model(RemoteDiffStateModel::new); ctx.subscribe_to_model(&remote, Self::forward_event); Self::Remote(remote) diff --git a/app/src/pane_group/pane/code_pane.rs b/app/src/pane_group/pane/code_pane.rs index 0cb78da265..acc19b8bce 100644 --- a/app/src/pane_group/pane/code_pane.rs +++ b/app/src/pane_group/pane/code_pane.rs @@ -4,6 +4,7 @@ use warpui::{AppContext, ModelHandle, SingletonEntity, View, ViewContext, ViewHa use crate::{ app_state::{CodePaneSnapShot, CodePaneTabSnapshot, LeafContents}, code::{ + buffer_location::FileLocation, editor_management::{CodeEditorStatus, CodeManager, CodeSource}, view::{CodeView, CodeViewEvent}, }, @@ -83,7 +84,11 @@ impl PaneContent for CodePane { _ => None, }; code_pane.file_view(ctx).update(ctx, |code_view, ctx| { - code_view.open_or_focus_existing(Some(path.clone()), line_col, ctx); + code_view.open_or_focus_existing( + Some(FileLocation::Local(path.clone())), + line_col, + ctx, + ); }); } @@ -100,7 +105,7 @@ impl PaneContent for CodePane { CodeSource::Link { range_start, .. } => *range_start, _ => None, }; - code_view.open_or_focus_existing(Some(path), line_col, ctx); + code_view.open_or_focus_existing(Some(FileLocation::Local(path)), line_col, ctx); } }); diff --git a/app/src/pane_group/working_directories.rs b/app/src/pane_group/working_directories.rs index 971f056e9e..a89838a068 100644 --- a/app/src/pane_group/working_directories.rs +++ b/app/src/pane_group/working_directories.rs @@ -13,7 +13,7 @@ use warpui::{AppContext, SingletonEntity as _}; use warpui::{Entity, EntityId, ModelContext}; use warpui::{ModelHandle, ViewHandle}; -use crate::code::buffer_location::BufferLocation; +use crate::code::buffer_location::FileLocation; #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeView; use crate::code_review::comments::{ @@ -25,23 +25,23 @@ use crate::code_review::{ }; use crate::workspace::view::global_search::view::GlobalSearchView; -/// Type-safe wrapper around the map of `BufferLocation` → `DiffStateModel`. +/// Type-safe wrapper around the map of `FileLocation` → `DiffStateModel`. /// /// Enforces that local keys are always paired with local-backend models and /// remote keys with remote-backend models via dedicated insertion methods. #[cfg(feature = "local_fs")] #[derive(Default)] struct DiffStateModelMap { - models: HashMap>, + models: HashMap>, } #[cfg(feature = "local_fs")] impl DiffStateModelMap { - fn get(&self, key: &BufferLocation) -> Option<&ModelHandle> { + fn get(&self, key: &FileLocation) -> Option<&ModelHandle> { self.models.get(key) } - /// Insert a model that was created from a `BufferLocation::Local` key. + /// Insert a model that was created from a `FileLocation::Local` key. fn insert_local( &mut self, path: PathBuf, @@ -52,10 +52,10 @@ impl DiffStateModelMap { matches!(model.as_ref(ctx), DiffStateModel::Local(_)), "insert_local called with a remote-backend DiffStateModel", ); - self.models.insert(BufferLocation::Local(path), model); + self.models.insert(FileLocation::Local(path), model); } - /// Insert a model that was created from a `BufferLocation::Remote` key. + /// Insert a model that was created from a `FileLocation::Remote` key. fn insert_remote( &mut self, remote_id: RemotePath, @@ -66,10 +66,10 @@ impl DiffStateModelMap { matches!(model.as_ref(ctx), DiffStateModel::Remote(_)), "insert_remote called with a local-backend DiffStateModel", ); - self.models.insert(BufferLocation::Remote(remote_id), model); + self.models.insert(FileLocation::Remote(remote_id), model); } - fn remove(&mut self, key: &BufferLocation) -> Option> { + fn remove(&mut self, key: &FileLocation) -> Option> { self.models.remove(key) } } @@ -228,7 +228,7 @@ impl WorkingDirectoriesModel { /// If the model doesn't exist, it will be created. pub fn get_or_create_diff_state_model( &mut self, - key: BufferLocation, + key: FileLocation, ctx: &mut ModelContext, ) -> Option> { if let Some(model) = self.diff_state_models.get(&key) { @@ -238,11 +238,11 @@ impl WorkingDirectoriesModel { let diff_state_model = ctx.add_model(|ctx| DiffStateModel::new(key.clone(), ctx)); match key { - BufferLocation::Local(path) => { + FileLocation::Local(path) => { self.diff_state_models .insert_local(path, diff_state_model.clone(), ctx); } - BufferLocation::Remote(remote_id) => { + FileLocation::Remote(remote_id) => { self.diff_state_models .insert_remote(remote_id, diff_state_model.clone(), ctx); } @@ -264,7 +264,7 @@ impl WorkingDirectoriesModel { .values() .all(|tab| !tab.contains(&repo_path)) { - let key = BufferLocation::Local(repo_path); + let key = FileLocation::Local(repo_path); if let Some(model) = self.diff_state_models.remove(&key) { model.update(ctx, |model, ctx| { model.stop_active_watcher(ctx); @@ -702,7 +702,7 @@ impl WorkingDirectoriesModel { pub fn get_or_create_diff_state_model( &mut self, - _key: BufferLocation, + _key: FileLocation, _ctx: &mut ModelContext, ) -> Option> { None diff --git a/app/src/persistence/sqlite_tests.rs b/app/src/persistence/sqlite_tests.rs index d2d94097e6..e021432ef8 100644 --- a/app/src/persistence/sqlite_tests.rs +++ b/app/src/persistence/sqlite_tests.rs @@ -322,7 +322,9 @@ fn test_sqlite_round_trips_code_pane_with_multiple_tabs() { ], active_tab_index: 1, source: Some(CodeSource::FileTree { - path: PathBuf::from("/tmp/main.rs"), + location: crate::code::buffer_location::FileLocation::Local( + PathBuf::from("/tmp/main.rs"), + ), }), }), }), diff --git a/app/src/remote_server/server_buffer_tracker.rs b/app/src/remote_server/server_buffer_tracker.rs index 5c61d58bf2..f753b876af 100644 --- a/app/src/remote_server/server_buffer_tracker.rs +++ b/app/src/remote_server/server_buffer_tracker.rs @@ -1,7 +1,8 @@ use std::collections::{HashMap, HashSet}; +use warp_editor::content::buffer::Buffer; use warp_util::file::FileId; -use warpui::{ModelContext, SingletonEntity}; +use warpui::{ModelContext, ModelHandle, SingletonEntity}; use super::server_model::{ConnectionId, ServerModel}; use crate::code::global_buffer_model::GlobalBufferModel; @@ -16,6 +17,15 @@ pub enum PendingBufferRequestKind { ResolveConflict, } +/// An in-flight buffer request awaiting a `GlobalBufferModelEvent` to +/// correlate it back to the originating connection. +#[derive(Clone, Debug)] +pub struct PendingBufferRequest { + pub request_id: RequestId, + pub connection_id: ConnectionId, + pub kind: PendingBufferRequestKind, +} + /// Bridges the ServerModel's per-connection state with the GlobalBufferModel's /// tracked buffers. Manages: /// - Wire path → FileId mappings for open server-local buffers @@ -24,6 +34,11 @@ pub enum PendingBufferRequestKind { pub struct ServerBufferTracker { /// Maps wire path strings to `FileId` for open server-local buffers. open_buffers: HashMap, + /// Strong references to buffer models, keyed by `FileId`. + /// Prevents the `Buffer` model from being deallocated while the + /// server is tracking it (the `GlobalBufferModel` only holds a + /// `WeakModelHandle`). + buffer_handles: HashMap>, /// Tracks which connections have each buffer open. /// File-watcher pushes go to all connections in the set. buffer_connections: HashMap>, @@ -31,13 +46,14 @@ pub struct ServerBufferTracker { /// `GlobalBufferModelEvent`s can be correlated back to the originating /// request and connection. Uses a `Vec` to support concurrent requests /// for the same buffer from different connections. - pending_requests: HashMap>, + pending_requests: HashMap>, } impl ServerBufferTracker { pub fn new() -> Self { Self { open_buffers: HashMap::new(), + buffer_handles: HashMap::new(), buffer_connections: HashMap::new(), pending_requests: HashMap::new(), } @@ -45,9 +61,16 @@ impl ServerBufferTracker { // ── Path ↔ FileId mapping ───────────────────────────────────── - /// Register a wire path → FileId mapping. - pub fn track_open_buffer(&mut self, path: String, file_id: FileId) { + /// Register a wire path → FileId mapping and retain a strong handle + /// to the buffer model so it stays alive while tracked. + pub fn track_open_buffer( + &mut self, + path: String, + file_id: FileId, + buffer: ModelHandle, + ) { self.open_buffers.insert(path, file_id); + self.buffer_handles.insert(file_id, buffer); } /// Look up a FileId by its wire path. @@ -104,6 +127,7 @@ impl ServerBufferTracker { for &file_id in &orphaned { self.buffer_connections.remove(&file_id); + self.buffer_handles.remove(&file_id); self.open_buffers.retain(|_, id| *id != file_id); GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| gbm.remove(file_id, ctx)); } @@ -132,6 +156,7 @@ impl ServerBufferTracker { // No connections remain — deallocate. self.buffer_connections.remove(&file_id); + self.buffer_handles.remove(&file_id); self.open_buffers.remove(path); GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| gbm.remove(file_id, ctx)); } @@ -149,7 +174,28 @@ impl ServerBufferTracker { self.pending_requests .entry(file_id) .or_default() - .push((request_id, conn_id, kind)); + .push(PendingBufferRequest { + request_id, + connection_id: conn_id, + kind, + }); + } + + /// Returns the connection IDs that have pending `OpenBuffer` requests + /// for the given FileId, without consuming them. Used by the + /// `ServerLocalBufferUpdated` handler to exclude connections that will + /// receive content via `OpenBufferResponse` instead of the broadcast push. + pub fn pending_connections_for_open_buffer(&self, file_id: &FileId) -> HashSet { + self.pending_requests + .get(file_id) + .map(|entries| { + entries + .iter() + .filter(|req| matches!(req.kind, PendingBufferRequestKind::OpenBuffer)) + .map(|req| req.connection_id) + .collect() + }) + .unwrap_or_default() } /// Retrieve and remove pending requests that match `kind` for the given @@ -158,14 +204,14 @@ impl ServerBufferTracker { &mut self, file_id: &FileId, kind: PendingBufferRequestKind, - ) -> Vec<(RequestId, ConnectionId)> { + ) -> Vec { let Some(entries) = self.pending_requests.get_mut(file_id) else { return Vec::new(); }; let mut matched = Vec::new(); - entries.retain(|(req, conn, k)| { - if std::mem::discriminant(k) == std::mem::discriminant(&kind) { - matched.push((req.clone(), conn.to_owned())); + entries.retain(|req| { + if std::mem::discriminant(&req.kind) == std::mem::discriminant(&kind) { + matched.push(req.clone()); false // remove from the vec } else { true // keep diff --git a/app/src/remote_server/server_model.rs b/app/src/remote_server/server_model.rs index 022a6bc227..02e056631b 100644 --- a/app/src/remote_server/server_model.rs +++ b/app/src/remote_server/server_model.rs @@ -1,4 +1,5 @@ use crate::terminal::shell::ShellType; +use remote_server::proto::OpenBufferSuccess; use repo_metadata::repositories::{DetectedRepositories, RepoDetectionSource}; use repo_metadata::{RepoMetadataEvent, RepoMetadataModel, RepositoryIdentifier}; use std::collections::{HashMap, HashSet}; @@ -323,15 +324,15 @@ impl ServerModel { .sync_clock_for_server_local(*file_id) .map(|c| c.server_version.as_u64()); - for (request_id, conn_id) in pending { + for req in pending { let message = match (&content, server_version) { (Some(content), Some(sv)) => { - server_message::Message::OpenBufferResponse( - OpenBufferResponse { - content: content.clone(), + server_message::Message::OpenBufferResponse(OpenBufferResponse{ + result: Some(remote_server::proto::open_buffer_response::Result::Success(OpenBufferSuccess { + content: content.clone(), server_version: sv, - }, - ) + })) + }) } _ => server_message::Message::Error(ErrorResponse { code: ErrorCode::Internal.into(), @@ -341,8 +342,8 @@ impl ServerModel { }), }; me.send_server_message( - Some(conn_id), - Some(&request_id), + Some(req.connection_id), + Some(&req.request_id), message, ); } @@ -354,10 +355,14 @@ impl ServerModel { new_server_version, expected_client_version, } => { - // Push incremental edits to all connections that have this buffer open. + // Push incremental edits to all connections that have this buffer open, + // except connections with a pending OpenBuffer request (they will + // receive the content via OpenBufferResponse instead). let Some(conns) = me.buffers.connections_for_buffer(file_id) else { return; }; + let excluded = + me.buffers.pending_connections_for_open_buffer(file_id); // Find the path for this file_id. let path = me.buffers.path_for_file_id(*file_id).unwrap_or_default(); @@ -371,6 +376,9 @@ impl ServerModel { .collect(); for &conn_id in conns { + if excluded.contains(&conn_id) { + continue; + } me.send_server_message( Some(conn_id), None, @@ -384,13 +392,13 @@ impl ServerModel { } } GlobalBufferModelEvent::FileSaved { file_id } => { - for (request_id, conn_id) in me.buffers.take_pending_by_kind( + for req in me.buffers.take_pending_by_kind( file_id, PendingBufferRequestKind::SaveBuffer, ) { me.send_server_message( - Some(conn_id), - Some(&request_id), + Some(req.connection_id), + Some(&req.request_id), server_message::Message::SaveBufferResponse(SaveBufferResponse { result: Some(save_buffer_response::Result::Success( SaveBufferSuccess {}, @@ -398,13 +406,13 @@ impl ServerModel { }), ); } - for (request_id, conn_id) in me.buffers.take_pending_by_kind( + for req in me.buffers.take_pending_by_kind( file_id, PendingBufferRequestKind::ResolveConflict, ) { me.send_server_message( - Some(conn_id), - Some(&request_id), + Some(req.connection_id), + Some(&req.request_id), server_message::Message::ResolveConflictResponse( ResolveConflictResponse { result: Some( @@ -418,13 +426,13 @@ impl ServerModel { } } GlobalBufferModelEvent::FailedToSave { file_id, error } => { - for (request_id, conn_id) in me.buffers.take_pending_by_kind( + for req in me.buffers.take_pending_by_kind( file_id, PendingBufferRequestKind::SaveBuffer, ) { me.send_server_message( - Some(conn_id), - Some(&request_id), + Some(req.connection_id), + Some(&req.request_id), server_message::Message::SaveBufferResponse(SaveBufferResponse { result: Some(save_buffer_response::Result::Error( FileOperationError { @@ -434,13 +442,13 @@ impl ServerModel { }), ); } - for (request_id, conn_id) in me.buffers.take_pending_by_kind( + for req in me.buffers.take_pending_by_kind( file_id, PendingBufferRequestKind::ResolveConflict, ) { me.send_server_message( - Some(conn_id), - Some(&request_id), + Some(req.connection_id), + Some(&req.request_id), server_message::Message::ResolveConflictResponse( ResolveConflictResponse { result: Some(resolve_conflict_response::Result::Error( @@ -454,22 +462,47 @@ impl ServerModel { } } GlobalBufferModelEvent::FailedToLoad { file_id, error } => { - for (request_id, conn_id) in me.buffers.take_pending_by_kind( + for req in me.buffers.take_pending_by_kind( file_id, PendingBufferRequestKind::OpenBuffer, ) { me.send_server_message( - Some(conn_id), - Some(&request_id), - server_message::Message::Error(ErrorResponse { - code: ErrorCode::Internal.into(), - message: format!("Failed to load buffer: {error}"), - }), + Some(req.connection_id), + Some(&req.request_id), + server_message::Message::OpenBufferResponse(OpenBufferResponse{ + result: Some(remote_server::proto::open_buffer_response::Result::Error(FileOperationError { + message: format!("Failed to load buffer: {error}"), + })) + }), ); } } - GlobalBufferModelEvent::BufferUpdatedFromFileEvent { .. } - | GlobalBufferModelEvent::RemoteBufferConflict { .. } => { + GlobalBufferModelEvent::BufferUpdatedFromFileEvent { + file_id, + success, + .. + } => { + // When a file-watcher update couldn't be applied because + // the buffer has unsaved client edits, forward the conflict + // to connected clients so they can show a resolution banner. + if !success { + if let Some(conns) = me.buffers.connections_for_buffer(file_id) { + let path = me.buffers.path_for_file_id(*file_id).unwrap_or_default(); + for &conn_id in conns { + me.send_server_message( + Some(conn_id), + None, + server_message::Message::BufferConflictDetected( + super::proto::BufferConflictDetected { + path: path.clone(), + }, + ), + ); + } + } + } + } + GlobalBufferModelEvent::RemoteBufferConflict { .. } => { // Not relevant for server-local buffers. } }); @@ -1356,6 +1389,11 @@ impl ServerModel { /// Handles `OpenBuffer` by opening the file via `GlobalBufferModel`. /// The response is sent asynchronously when `BufferLoaded` fires. + /// + /// When `force_reload` is set, the server re-reads the file from disk + /// even if the buffer is already loaded. This broadcasts a + /// `BufferUpdatedPush` to other connections and responds with the + /// fresh content via `OpenBufferResponse`. fn handle_open_buffer( &mut self, msg: OpenBuffer, @@ -1364,34 +1402,92 @@ impl ServerModel { ctx: &mut ModelContext, ) -> HandlerOutcome { log::info!( - "Handling OpenBuffer path={} (request_id={request_id})", - msg.path + "Handling OpenBuffer path={} force_reload={} (request_id={request_id})", + msg.path, + msg.force_reload, ); + // For force_reload on an already-tracked buffer, skip open_server_local + // to avoid a spurious BufferLoaded event that would consume the pending + // request before ServerLocalBufferUpdated can use it for exclusion. + if msg.force_reload { + if let Some(file_id) = self.buffers.file_id_for_path(&msg.path) { + self.buffers.add_connection(file_id, conn_id); + let gbm = GlobalBufferModel::handle(ctx); + + self.buffers.insert_pending( + file_id, + request_id.clone(), + conn_id, + PendingBufferRequestKind::OpenBuffer, + ); + if let Err(e) = + gbm.update(ctx, |gbm, ctx| gbm.force_reload_server_local(file_id, ctx)) + { + self.buffers + .take_pending_by_kind(&file_id, PendingBufferRequestKind::OpenBuffer); + return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse( + OpenBufferResponse { + result: Some( + remote_server::proto::open_buffer_response::Result::Error( + FileOperationError { message: e }, + ), + ), + }, + )); + } + return HandlerOutcome::Async(None); + } + // Buffer not yet tracked — fall through to open_server_local below. + } + let path = PathBuf::from(&msg.path); let gbm = GlobalBufferModel::handle(ctx); let buffer_state = gbm.update(ctx, |gbm, ctx| gbm.open_server_local(path, ctx)); let file_id = buffer_state.file_id; // Track path → FileId mapping and connection. - self.buffers.track_open_buffer(msg.path.clone(), file_id); + // Retain the strong buffer handle so the model stays alive until + // all connections close the buffer. + self.buffers + .track_open_buffer(msg.path.clone(), file_id, buffer_state.buffer); self.buffers.add_connection(file_id, conn_id); - // If already loaded, respond immediately. if gbm.as_ref(ctx).buffer_loaded(file_id) { - let content = gbm - .as_ref(ctx) - .content_for_file(file_id, ctx) - .unwrap_or_default(); - let server_version = gbm + let Some(content) = gbm.as_ref(ctx).content_for_file(file_id, ctx) else { + return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse( + OpenBufferResponse { + result: Some(remote_server::proto::open_buffer_response::Result::Error( + FileOperationError { + message: "Buffer loaded but has no file content".to_string(), + }, + )), + }, + )); + }; + let Some(server_version) = gbm .as_ref(ctx) .sync_clock_for_server_local(file_id) .map(|c| c.server_version.as_u64()) - .unwrap_or(1); + else { + return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse( + OpenBufferResponse { + result: Some(remote_server::proto::open_buffer_response::Result::Error( + FileOperationError { + message: "Buffer loaded but has no sync clock".to_string(), + }, + )), + }, + )); + }; return HandlerOutcome::Sync(server_message::Message::OpenBufferResponse( OpenBufferResponse { - content, - server_version, + result: Some(remote_server::proto::open_buffer_response::Result::Success( + OpenBufferSuccess { + content, + server_version, + }, + )), }, )); } @@ -1411,6 +1507,13 @@ impl ServerModel { /// Delegates to `GlobalBufferModel::apply_client_edit`. On rejection /// (stale server version), the edit is silently dropped. fn handle_buffer_edit(&mut self, msg: BufferEdit, ctx: &mut ModelContext) { + log::info!( + "Handling BufferEdit path={} expected_sv={} new_cv={} edit_count={}", + msg.path, + msg.expected_server_version, + msg.new_client_version, + msg.edits.len() + ); let Some(file_id) = self.buffers.file_id_for_path(&msg.path) else { log::warn!("BufferEdit for unknown buffer: {}", msg.path); return; @@ -1421,9 +1524,10 @@ impl ServerModel { // Per spec: if the edit is rejected (stale server version), // the server silently drops it. - GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| { - gbm.apply_client_edit(file_id, &msg.edits, expected_sv, new_cv, ctx); + let accepted = GlobalBufferModel::handle(ctx).update(ctx, |gbm, ctx| { + gbm.apply_client_edit(file_id, &msg.edits, expected_sv, new_cv, ctx) }); + log::info!("BufferEdit result: path={} accepted={accepted}", msg.path); } /// Handles `SaveBuffer` by persisting the buffer to disk. diff --git a/app/src/terminal/model/session.rs b/app/src/terminal/model/session.rs index b5384de8ca..df275107b6 100644 --- a/app/src/terminal/model/session.rs +++ b/app/src/terminal/model/session.rs @@ -165,7 +165,8 @@ impl Sessions { sessions.set_remote_server_setup_state(*session_id, state.clone()); ctx.notify(); } - RemoteServerManagerEvent::BufferUpdated { .. } => { + RemoteServerManagerEvent::BufferUpdated { .. } + | RemoteServerManagerEvent::BufferConflictDetected { .. } => { // Handled directly by GlobalBufferModel's subscription. } RemoteServerManagerEvent::SessionConnecting { .. } diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index 33e08105fd..732944cff3 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -4529,7 +4529,8 @@ impl TerminalView { | RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. } | RemoteServerManagerEvent::CodebaseIndexStatusesSnapshot { .. } | RemoteServerManagerEvent::CodebaseIndexStatusUpdated { .. } - | RemoteServerManagerEvent::BufferUpdated { .. } => {} + | RemoteServerManagerEvent::BufferUpdated { .. } + | RemoteServerManagerEvent::BufferConflictDetected { .. } => {} } }); } diff --git a/app/src/terminal/writeable_pty/remote_server_controller.rs b/app/src/terminal/writeable_pty/remote_server_controller.rs index 709ffda9e9..3a32030db3 100644 --- a/app/src/terminal/writeable_pty/remote_server_controller.rs +++ b/app/src/terminal/writeable_pty/remote_server_controller.rs @@ -149,7 +149,8 @@ impl RemoteServerController { | RemoteServerManagerEvent::SetupStateChanged { .. } | RemoteServerManagerEvent::ClientRequestFailed { .. } | RemoteServerManagerEvent::ServerMessageDecodingError { .. } - | RemoteServerManagerEvent::BufferUpdated { .. } => {} + | RemoteServerManagerEvent::BufferUpdated { .. } + | RemoteServerManagerEvent::BufferConflictDetected { .. } => {} }); Self { diff --git a/app/src/workspace/view.rs b/app/src/workspace/view.rs index 840f72b313..fe037f62a2 100644 --- a/app/src/workspace/view.rs +++ b/app/src/workspace/view.rs @@ -86,7 +86,7 @@ use crate::app_state::{ PaneNodeSnapshot, PaneUuid, RightPanelSnapshot, SettingsPaneSnapshot, TabSnapshot, TerminalPaneSnapshot, WindowSnapshot, WorkflowPaneSnapshot, }; -use crate::code::buffer_location::BufferLocation; +use crate::code::buffer_location::FileLocation; use crate::code_review::diff_state::DiffStateModel; #[cfg(feature = "local_fs")] use crate::code_review::CodeReviewTelemetryEvent; @@ -5924,17 +5924,35 @@ impl Workspace { self.handle_warp_drive_event(drive_event, ctx); } LeftPanelEvent::OpenFileWithTarget { - path, + location, target, line_col, } => { - self.open_file_with_target( - path.clone(), - target.clone(), - *line_col, - CodeSource::FileTree { path: path.clone() }, - ctx, - ); + let code_source = CodeSource::FileTree { + location: location.clone(), + }; + match location { + FileLocation::Local(path) => { + self.open_file_with_target( + path.clone(), + target.clone(), + *line_col, + code_source, + ctx, + ); + } + FileLocation::Remote(_) => { + #[cfg(feature = "local_fs")] + self.open_code( + code_source, + crate::util::openable_file_type::EditorLayout::SplitPane, + None, + false, + &[], + ctx, + ); + } + } } LeftPanelEvent::NewConversationInNewTab => { self.add_terminal_tab_with_new_agent_view(ctx); @@ -7424,10 +7442,18 @@ impl Workspace { if preview { code_view.open_in_preview_or_promote_and_jump(path, line_col, ctx); } else { - code_view.open_or_focus_existing(Some(path), line_col, ctx); + code_view.open_or_focus_existing( + Some(FileLocation::Local(path)), + line_col, + ctx, + ); } for extra in additional_paths { - code_view.open_or_focus_existing(Some(extra.clone()), None, ctx); + code_view.open_or_focus_existing( + Some(FileLocation::Local(extra.clone())), + None, + ctx, + ); } }); // Only focus the pane for non-preview opens @@ -7463,7 +7489,7 @@ impl Workspace { ); } else { code_view.open_or_focus_existing( - Some(path.clone()), + Some(FileLocation::Local(path.clone())), line_col, ctx, ); @@ -7471,7 +7497,7 @@ impl Workspace { for extra in additional_paths { code_view.open_or_focus_existing( - Some(extra.clone()), + Some(FileLocation::Local(extra.clone())), None, ctx, ); @@ -7529,7 +7555,11 @@ impl Workspace { if let Some(code_view) = code_view_handle { code_view.update(ctx, |code_view, ctx| { for path in additional_paths { - code_view.open_or_focus_existing(Some(path.clone()), None, ctx); + code_view.open_or_focus_existing( + Some(FileLocation::Local(path.clone())), + None, + ctx, + ); } }); } @@ -8105,7 +8135,7 @@ impl Workspace { let diff_state_model = repo_path.as_ref().and_then(|rp: &PathBuf| { self.working_directories_model.update(ctx, |model, ctx| { model.get_or_create_diff_state_model( - BufferLocation::Local(rp.clone()), + FileLocation::Local(rp.clone()), ctx, ) }) @@ -8152,7 +8182,7 @@ impl Workspace { let repo_path = panel_context.repo_path.clone(); let diff_state_model = repo_path.as_ref().and_then(|rp| { self.working_directories_model.update(ctx, |model, ctx| { - model.get_or_create_diff_state_model(BufferLocation::Local(rp.clone()), ctx) + model.get_or_create_diff_state_model(FileLocation::Local(rp.clone()), ctx) }) }); let Some(diff_state_model) = diff_state_model else { @@ -8269,7 +8299,7 @@ impl Workspace { |(repo_path, terminal_view): (Option, WeakViewHandle)| { let diff_state_model = repo_path.as_ref().and_then(|rp: &PathBuf| { self.working_directories_model.update(ctx, |model, ctx| { - model.get_or_create_diff_state_model(BufferLocation::Local(rp.clone()), ctx) + model.get_or_create_diff_state_model(FileLocation::Local(rp.clone()), ctx) }) })?; Some(CodeReviewPaneContext { @@ -14112,7 +14142,11 @@ impl Workspace { // After removing the file from the origin's editor, we want to open it in the target's editor. if let Some(path) = moved_file_path { target_code_view.update(ctx, |view, ctx| { - view.open_or_focus_existing(Some(path), None, ctx); + view.open_or_focus_existing( + Some(FileLocation::Local(path)), + None, + ctx, + ); }); } return; @@ -21042,7 +21076,7 @@ impl TypedActionView for Workspace { let diff_state_model = repo_path.as_ref().and_then(|rp| { self.working_directories_model.update(ctx, |model, ctx| { model.get_or_create_diff_state_model( - BufferLocation::Local(rp.clone()), + FileLocation::Local(rp.clone()), ctx, ) }) diff --git a/app/src/workspace/view/left_panel.rs b/app/src/workspace/view/left_panel.rs index 038ee5fafa..de1401f43d 100644 --- a/app/src/workspace/view/left_panel.rs +++ b/app/src/workspace/view/left_panel.rs @@ -18,6 +18,7 @@ use warpui::{ use crate::ai::agent::conversation::AIConversationId; use crate::ai::agent_conversations_model::AgentConversationsModel; +use crate::code::buffer_location::FileLocation; #[cfg(feature = "local_fs")] use crate::code::file_tree::FileTreeEvent; use crate::coding_panel_enablement_state::CodingPanelEnablementState; @@ -83,7 +84,7 @@ pub enum LeftPanelEvent { WarpDrive(DrivePanelEvent), #[cfg_attr(not(feature = "local_fs"), allow(dead_code))] OpenFileWithTarget { - path: PathBuf, + location: FileLocation, target: FileTarget, line_col: Option, }, @@ -728,7 +729,7 @@ impl LeftPanelView { ); ctx.emit(LeftPanelEvent::OpenFileWithTarget { - path: path.clone(), + location: FileLocation::Local(path.clone()), target, line_col: Some(line_col), }); @@ -761,7 +762,7 @@ impl LeftPanelView { line_col, } => { ctx.emit(LeftPanelEvent::OpenFileWithTarget { - path: path.clone(), + location: path.clone(), target: target.clone(), line_col: *line_col, }); diff --git a/app/src/workspace/view/right_panel.rs b/app/src/workspace/view/right_panel.rs index 1e0702710e..09256d7af6 100644 --- a/app/src/workspace/view/right_panel.rs +++ b/app/src/workspace/view/right_panel.rs @@ -30,7 +30,7 @@ use crate::{ terminal::resizable_data::{ModalType, ResizableData}, }; use crate::{ - code::buffer_location::BufferLocation, code_review::diff_state::DiffStateModel, + code::buffer_location::FileLocation, code_review::diff_state::DiffStateModel, terminal::view::TerminalView, }; use dunce::canonicalize; @@ -1623,7 +1623,7 @@ impl RightPanelView { } else { let diff_state_model = self.working_directories_model.update(ctx, |model, ctx| { model.get_or_create_diff_state_model( - BufferLocation::Local(repo_path.to_path_buf()), + FileLocation::Local(repo_path.to_path_buf()), ctx, ) }); diff --git a/crates/remote_server/proto/remote_server.proto b/crates/remote_server/proto/remote_server.proto index aadc917fbe..3152bb0e70 100644 --- a/crates/remote_server/proto/remote_server.proto +++ b/crates/remote_server/proto/remote_server.proto @@ -63,6 +63,7 @@ message ServerMessage { DiffStateMetadataUpdate diff_state_metadata_update = 20; DiffStateFileDelta diff_state_file_delta = 21; DiscardFilesResponse discard_files_response = 22; + BufferConflictDetected buffer_conflict_detected = 23; } } @@ -371,10 +372,22 @@ message RepoMetadataUpdatePush { // The server reads the file, starts watching it, and returns the content. message OpenBuffer { string path = 1; + // When true, the server discards any in-memory buffer state and re-reads + // the file from disk. Used by the client to resolve conflicts ("accept + // server" / discard local edits). Other connections that have the buffer + // open receive a BufferUpdatedPush with the fresh content. + bool force_reload = 2; } // Server → client: response to OpenBuffer with the initial file content. message OpenBufferResponse { + oneof result { + OpenBufferSuccess success = 1; + FileOperationError error = 2; + } +} + +message OpenBufferSuccess { string content = 1; uint64 server_version = 2; } @@ -450,3 +463,10 @@ message ResolveConflictResponse { } message ResolveConflictSuccess {} + +// Server → client push: the file changed on disk while the client had +// unsaved edits. The server does NOT apply the disk change to its buffer; +// the client should show a conflict resolution banner. +message BufferConflictDetected { + string path = 1; +} diff --git a/crates/remote_server/src/client/mod.rs b/crates/remote_server/src/client/mod.rs index 9ace281797..fc65260ff8 100644 --- a/crates/remote_server/src/client/mod.rs +++ b/crates/remote_server/src/client/mod.rs @@ -17,7 +17,7 @@ use crate::proto::{ client_message, server_message, Abort, Authenticate, BufferEdit, ClientMessage, CloseBuffer, DeleteFile, ErrorCode, Initialize, InitializeResponse, LoadRepoMetadataDirectoryResponse, NavigatedToDirectoryResponse, OpenBuffer, OpenBufferResponse, ReadFileContextRequest, - ReadFileContextResponse, RunCommandRequest, RunCommandResponse, ServerMessage, + ReadFileContextResponse, RunCommandRequest, RunCommandResponse, SaveBuffer, ServerMessage, SessionBootstrapped, TextEdit, WriteFile, }; @@ -87,6 +87,10 @@ pub enum ClientEvent { expected_client_version: u64, edits: Vec, }, + /// The file changed on disk while the client had unsaved edits. + /// The server did NOT apply the change; the client should show a + /// conflict resolution banner. + BufferConflictDetected { path: String }, } /// Parameters for the `Initialize` handshake, sent to the daemon at /// connection time. @@ -401,11 +405,21 @@ impl RemoteServerClient { } /// Opens a buffer on the remote host for bidirectional syncing. - pub async fn open_buffer(&self, path: String) -> Result { + /// + /// When `force_reload` is true, the server discards any in-memory buffer + /// state and re-reads the file from disk. Used to resolve conflicts. + pub async fn open_buffer( + &self, + path: String, + force_reload: bool, + ) -> Result { let request_id = RequestId::new(); let msg = ClientMessage { request_id: request_id.to_string(), - message: Some(client_message::Message::OpenBuffer(OpenBuffer { path })), + message: Some(client_message::Message::OpenBuffer(OpenBuffer { + path, + force_reload, + })), }; let response = self.send_request(request_id, msg).await?; match response.message { @@ -437,6 +451,28 @@ impl RemoteServerClient { self.send_notification(msg); } + /// Saves a buffer on the remote host to disk. + pub async fn save_buffer(&self, path: String) -> Result<(), ClientError> { + let request_id = RequestId::new(); + let msg = ClientMessage { + request_id: request_id.to_string(), + message: Some(client_message::Message::SaveBuffer(SaveBuffer { path })), + }; + let response = self.send_request(request_id, msg).await?; + match response.message { + Some(server_message::Message::SaveBufferResponse(resp)) => match resp.result { + Some(crate::proto::save_buffer_response::Result::Success(_)) | None => Ok(()), + Some(crate::proto::save_buffer_response::Result::Error(e)) => { + Err(ClientError::FileOperationFailed(e.message)) + } + }, + other => { + log::error!("Unexpected response variant for SaveBuffer: {other:?}"); + Err(ClientError::UnexpectedResponse) + } + } + } + /// Tells the remote host to close a buffer (stop watching). pub fn close_buffer(&self, path: String) { let msg = ClientMessage { @@ -497,6 +533,9 @@ impl RemoteServerClient { expected_client_version: push.expected_client_version, edits: push.edits, }), + server_message::Message::BufferConflictDetected(push) => { + Some(ClientEvent::BufferConflictDetected { path: push.path }) + } other => { safe_warn!( safe: ("Unhandled push message variant"), diff --git a/crates/remote_server/src/manager.rs b/crates/remote_server/src/manager.rs index 4918b9f756..d0bc121a95 100644 --- a/crates/remote_server/src/manager.rs +++ b/crates/remote_server/src/manager.rs @@ -173,6 +173,7 @@ fn client_event_kind(event: &ClientEvent) -> &'static str { } ClientEvent::CodebaseIndexStatusUpdated { .. } => "codebase_index_status_updated", ClientEvent::BufferUpdated { .. } => "buffer_updated", + ClientEvent::BufferConflictDetected { .. } => "buffer_conflict_detected", ClientEvent::MessageDecodingError => "message_decoding_error", } } @@ -363,6 +364,10 @@ pub enum RemoteServerManagerEvent { expected_client_version: u64, edits: Vec, }, + /// The file changed on disk while the client had unsaved edits. + /// The server did NOT apply the change; the client should show a + /// conflict resolution banner. + BufferConflictDetected { host_id: HostId, path: String }, // --- Setup events --- /// Intermediate state change during the binary check/install flow. @@ -443,7 +448,8 @@ impl RemoteServerManagerEvent { | RemoteServerManagerEvent::RepoMetadataDirectoryLoaded { .. } | RemoteServerManagerEvent::CodebaseIndexStatusesSnapshot { .. } | RemoteServerManagerEvent::CodebaseIndexStatusUpdated { .. } - | RemoteServerManagerEvent::BufferUpdated { .. } => None, + | RemoteServerManagerEvent::BufferUpdated { .. } + | RemoteServerManagerEvent::BufferConflictDetected { .. } => None, } } } @@ -1376,13 +1382,16 @@ impl RemoteServerManager { edits, } => { ctx.emit(RemoteServerManagerEvent::BufferUpdated { - host_id, + host_id: host_id.clone(), path, new_server_version, expected_client_version, edits, }); } + ClientEvent::BufferConflictDetected { path } => { + ctx.emit(RemoteServerManagerEvent::BufferConflictDetected { host_id, path }); + } ClientEvent::Disconnected => { // Handled by the drain loop's completion callback. } diff --git a/crates/warp_files/src/lib.rs b/crates/warp_files/src/lib.rs index 18a7b7fba6..c2c0363a28 100644 --- a/crates/warp_files/src/lib.rs +++ b/crates/warp_files/src/lib.rs @@ -429,13 +429,22 @@ impl FileModel { let version = ContentVersion::new(); me.set_version(file_id, version); - // Only register individual watcher if not using repo subscription + // Only register individual watcher if not using repo subscription. + // Watch the parent directory (NonRecursive) instead of the file + // itself so the watch survives editors that use a + // delete+create/rename pattern (vim, sed -i, etc.). Watching + // the file directly would lose the inotify watch when the + // original inode is deleted. if use_individual_watcher { + let watch_path = file_path_clone + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| file_path_clone.clone()); me.watcher.update(ctx, |watcher, _ctx| { std::mem::drop(watcher.register_path( - &file_path_clone, + &watch_path, WatchFilter::accept_all(), - RecursiveMode::Recursive, + RecursiveMode::NonRecursive, )); }); } @@ -638,9 +647,28 @@ impl FileModel { if !path_still_used { match watcher_type { WatcherType::Individual => { - self.watcher.update(ctx, |watcher, _ctx| { - std::mem::drop(watcher.unregister_path(path.as_path())); - }); + // Unwatch the parent directory (matching the register + // in open() which watches the parent, not the file). + // Only unregister if no other individually-watched + // files share the same parent directory. + let watch_path = path + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| path.clone()); + let other_files_share_parent = + self.file_state.local_values().any(|f| { + f.watcher_type == WatcherType::Individual + && f.path + .as_deref() + .and_then(|p| p.parent()) + .map(|p| p == watch_path) + .unwrap_or(false) + }); + if !other_files_share_parent { + self.watcher.update(ctx, |watcher, _ctx| { + std::mem::drop(watcher.unregister_path(&watch_path)); + }); + } } WatcherType::Repository => { if let Some((repo_root, unused_repo)) = diff --git a/crates/warp_util/src/host_id.rs b/crates/warp_util/src/host_id.rs index 56989849e6..05a256a156 100644 --- a/crates/warp_util/src/host_id.rs +++ b/crates/warp_util/src/host_id.rs @@ -1,11 +1,13 @@ use std::fmt; +use serde::{Deserialize, Serialize}; + /// Opaque identifier for a remote host. /// /// Returned by the server in `InitializeResponse`. Used by /// `RemoteServerManager` and downstream features to deduplicate /// host-scoped models (e.g. `RepoMetadataModel`) across sessions. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct HostId(String); impl HostId { diff --git a/crates/warp_util/src/remote_path.rs b/crates/warp_util/src/remote_path.rs index 4bbc03dbdd..288802dee3 100644 --- a/crates/warp_util/src/remote_path.rs +++ b/crates/warp_util/src/remote_path.rs @@ -1,3 +1,5 @@ +use serde::{Deserialize, Serialize}; + use crate::host_id::HostId; use crate::standardized_path::StandardizedPath; @@ -7,7 +9,7 @@ use crate::standardized_path::StandardizedPath; /// same host) with the server-side [`StandardizedPath`]. This type is the /// canonical representation for remote file locations and is shared across /// buffer tracking, repository identification, and other host-scoped features. -#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct RemotePath { pub host_id: HostId, pub path: StandardizedPath,