From d3f93bc74d0d8610663015caaa1db48f04b46b95 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 24 Mar 2026 12:23:19 +0800 Subject: [PATCH 1/2] feat(core,desktop): remote SSH workspace, paths, and session APIs - Extend path_manager and remote_ssh workspace_state for remote roots and caps - Wire desktop agentic/session APIs and app state for workspace + SSH flows - Adjust agentic coordinator, persistence, and workspace service integration --- src/apps/desktop/src/api/agentic_api.rs | 72 +++- src/apps/desktop/src/api/app_state.rs | 63 ++-- src/apps/desktop/src/api/commands.rs | 190 +++++++---- src/apps/desktop/src/api/dto.rs | 10 +- src/apps/desktop/src/api/session_api.rs | 32 +- src/apps/desktop/src/api/ssh_api.rs | 2 + src/apps/desktop/src/api/terminal_api.rs | 2 +- src/apps/desktop/src/lib.rs | 1 + .../src/agentic/coordination/coordinator.rs | 7 +- src/crates/core/src/agentic/core/session.rs | 4 + .../core/src/agentic/persistence/manager.rs | 3 - .../src/agentic/session/session_manager.rs | 18 +- .../infrastructure/filesystem/path_manager.rs | 33 ++ .../core/src/service/remote_ssh/manager.rs | 82 +++-- src/crates/core/src/service/remote_ssh/mod.rs | 5 +- .../core/src/service/remote_ssh/types.rs | 9 +- .../src/service/remote_ssh/workspace_state.rs | 313 ++++++++++++++---- .../core/src/service/workspace/manager.rs | 44 ++- .../core/src/service/workspace/service.rs | 7 + 19 files changed, 698 insertions(+), 199 deletions(-) diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index 75538d0c..199cc290 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -22,6 +22,8 @@ pub struct CreateSessionRequest { pub session_name: String, pub agent_type: String, pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, pub config: Option, } @@ -36,6 +38,8 @@ pub struct SessionConfigDTO { pub enable_context_compression: Option, pub compression_threshold: Option, pub model_name: Option, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Serialize)] @@ -73,6 +77,15 @@ pub struct StartDialogTurnResponse { pub message: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnsureCoordinatorSessionRequest { + pub session_id: String, + pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct EnsureAssistantBootstrapRequest { @@ -142,6 +155,8 @@ pub struct CancelToolRequest { pub struct DeleteSessionRequest { pub session_id: String, pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -149,12 +164,16 @@ pub struct DeleteSessionRequest { pub struct RestoreSessionRequest { pub session_id: String, pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListSessionsRequest { pub workspace_path: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -186,6 +205,16 @@ pub async fn create_session( coordinator: State<'_, Arc>, request: CreateSessionRequest, ) -> Result { + fn norm_conn(s: Option) -> Option { + s.map(|x| x.trim().to_string()).filter(|x| !x.is_empty()) + } + let remote_conn = norm_conn(request.remote_connection_id.clone()).or_else(|| { + request + .config + .as_ref() + .and_then(|c| norm_conn(c.remote_connection_id.clone())) + }); + let config = request .config .map(|c| SessionConfig { @@ -197,10 +226,12 @@ pub async fn create_session( enable_context_compression: c.enable_context_compression.unwrap_or(true), compression_threshold: c.compression_threshold.unwrap_or(0.8), workspace_path: Some(request.workspace_path.clone()), + remote_connection_id: remote_conn.clone(), model_id: c.model_name, }) .unwrap_or(SessionConfig { workspace_path: Some(request.workspace_path.clone()), + remote_connection_id: remote_conn.clone(), ..Default::default() }); @@ -233,6 +264,38 @@ pub async fn update_session_model( .map_err(|e| format!("Failed to update session model: {}", e)) } +/// Load the session into the coordinator process when it exists on disk but is not in memory. +/// Uses the same remote→local session path mapping as `restore_session`. +#[tauri::command] +pub async fn ensure_coordinator_session( + coordinator: State<'_, Arc>, + request: EnsureCoordinatorSessionRequest, +) -> Result<(), String> { + let session_id = request.session_id.trim(); + if session_id.is_empty() { + return Err("session_id is required".to_string()); + } + if coordinator + .get_session_manager() + .get_session(session_id) + .is_some() + { + return Ok(()); + } + + let wp = request.workspace_path.trim(); + if wp.is_empty() { + return Err("workspace_path is required when the session is not loaded".to_string()); + } + + let effective = get_effective_session_path(wp, request.remote_connection_id.as_deref()).await; + coordinator + .restore_session(&effective, session_id) + .await + .map(|_| ()) + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn start_dialog_turn( _app: AppHandle, @@ -426,7 +489,8 @@ pub async fn delete_session( coordinator: State<'_, Arc>, request: DeleteSessionRequest, ) -> Result<(), String> { - let effective_path = get_effective_session_path(&request.workspace_path).await; + let effective_path = + get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; coordinator .delete_session(&effective_path, &request.session_id) .await @@ -438,7 +502,8 @@ pub async fn restore_session( coordinator: State<'_, Arc>, request: RestoreSessionRequest, ) -> Result { - let effective_path = get_effective_session_path(&request.workspace_path).await; + let effective_path = + get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let session = coordinator .restore_session(&effective_path, &request.session_id) .await @@ -453,7 +518,8 @@ pub async fn list_sessions( request: ListSessionsRequest, ) -> Result, String> { // Map remote workspace path to local session storage path - let effective_path = get_effective_session_path(&request.workspace_path).await; + let effective_path = + get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let summaries = coordinator .list_sessions(&effective_path) .await diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index cf6b3574..1d2f46b8 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -371,6 +371,9 @@ impl AppState { workspace.connection_id.clone(), workspace.connection_name.clone(), ).await; + state_manager + .set_active_connection_hint(Some(workspace.connection_id.clone())) + .await; log::info!("Remote workspace registered: {} on {}", workspace.remote_path, workspace.connection_name); Ok(()) @@ -381,32 +384,48 @@ impl AppState { self.remote_workspace.read().await.clone() } - /// Clear current remote workspace - pub async fn clear_remote_workspace(&self) { - // Get the remote_path before clearing so we can unregister the specific workspace - let remote_path = { - let guard = self.remote_workspace.read().await; - guard.as_ref().map(|w| w.remote_path.clone()) - }; - - // Clear local state - *self.remote_workspace.write().await = None; - - // Remove this specific workspace from persistence (not all of them) - if let Some(path) = &remote_path { - if let Ok(manager) = self.get_ssh_manager_async().await { - if let Err(e) = manager.remove_remote_workspace(path).await { - log::warn!("Failed to remove persisted remote workspace: {}", e); - } + /// Remove one remote workspace from persistence + registry (`connection_id` + `remote_path`). + pub async fn unregister_remote_workspace_entry(&self, connection_id: &str, remote_path: &str) { + let rp = bitfun_core::service::remote_ssh::normalize_remote_workspace_path(remote_path); + if let Ok(manager) = self.get_ssh_manager_async().await { + if let Err(e) = manager.remove_remote_workspace(connection_id, &rp).await { + log::warn!("Failed to remove persisted remote workspace: {}", e); } - - // Unregister from the global registry - if let Some(state_manager) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() { - state_manager.unregister_remote_workspace(path).await; + } + if let Some(state_manager) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() { + state_manager + .unregister_remote_workspace(connection_id, &rp) + .await; + } + let mut slot = self.remote_workspace.write().await; + let clear_slot = slot + .as_ref() + .map(|w| { + w.connection_id == connection_id + && bitfun_core::service::remote_ssh::normalize_remote_workspace_path(&w.remote_path) + == rp + }) + .unwrap_or(false); + if clear_slot { + *slot = None; + if let Some(m) = bitfun_core::service::remote_ssh::get_remote_workspace_manager() { + m.set_active_connection_hint(None).await; } } + log::info!( + "Remote workspace entry removed: connection_id={}, remote_path={}", + connection_id, + rp + ); + } - log::info!("Remote workspace unregistered: {:?}", remote_path); + /// Clear current remote pointer and remove its persisted/registry entry (legacy SSH "close"). + pub async fn clear_remote_workspace(&self) { + let snap = { self.remote_workspace.read().await.clone() }; + if let Some(w) = snap { + self.unregister_remote_workspace_entry(&w.connection_id, &w.remote_path) + .await; + } } /// Check if currently in a remote workspace diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 8bdfa142..1140b4a7 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -3,6 +3,8 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; use bitfun_core::infrastructure::{file_watcher, FileOperationOptions, SearchMatchType}; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; +use bitfun_core::service::remote_ssh::{get_remote_workspace_manager, RemoteWorkspaceEntry}; use bitfun_core::service::workspace::{ ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions, }; @@ -11,6 +13,39 @@ use serde::Deserialize; use std::path::Path; use tauri::{AppHandle, State}; +fn remote_workspace_from_info(info: &WorkspaceInfo) -> Option { + if info.workspace_kind != WorkspaceKind::Remote { + return None; + } + let cid = info.metadata.get("connectionId")?.as_str()?.to_string(); + let name = info + .metadata + .get("connectionName") + .and_then(|v| v.as_str()) + .unwrap_or(&cid) + .to_string(); + let rp = bitfun_core::service::remote_ssh::normalize_remote_workspace_path( + &info.root_path.to_string_lossy(), + ); + Some(crate::api::RemoteWorkspace { + connection_id: cid, + remote_path: rp, + connection_name: name, + }) +} + +async fn lookup_remote_entry_for_path( + state: &State<'_, AppState>, + path: &str, +) -> Option { + let hint = state + .get_remote_workspace_async() + .await + .map(|w| w.connection_id); + let manager = get_remote_workspace_manager()?; + manager.lookup_connection(path, hint.as_deref()).await +} + #[derive(Debug, Deserialize)] pub struct OpenWorkspaceRequest { pub path: String, @@ -233,16 +268,29 @@ async fn apply_active_workspace_context( *state.workspace_path.write().await = Some(workspace_info.root_path.clone()); - if let Err(e) = bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - workspace_info.root_path.clone(), - None, - ) - .await - { - warn!( - "Failed to initialize snapshot system: path={}, error={}", - workspace_info.root_path.display(), - e + // Remote workspace roots are POSIX paths on the SSH host — not writable local directories on + // Windows. Snapshot hooks already skip file tracking for registered remote paths; avoid + // creating `/.bitfun` (or drive root) here which fails with access denied. + let root_str = workspace_info.root_path.to_string_lossy().to_string(); + let skip_local_snapshot = workspace_info.workspace_kind == WorkspaceKind::Remote + || is_remote_path(root_str.trim()).await; + if !skip_local_snapshot { + if let Err(e) = bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( + workspace_info.root_path.clone(), + None, + ) + .await + { + warn!( + "Failed to initialize snapshot system: path={}, error={}", + workspace_info.root_path.display(), + e + ); + } + } else { + debug!( + "Skipping local snapshot manager init for remote/non-local workspace root_path={}", + workspace_info.root_path.display() ); } @@ -278,6 +326,21 @@ async fn apply_active_workspace_context( edit_mode, ); } + + // Keep global SSH registry + active connection hint aligned with the **foreground** workspace + // so two servers opened at the same remote path (e.g. `/`) stay distinct. + if workspace_info.workspace_kind == WorkspaceKind::Remote { + if let Some(rw) = remote_workspace_from_info(workspace_info) { + if let Err(e) = state.set_remote_workspace(rw).await { + warn!("Failed to sync remote workspace registry for active workspace: {}", e); + } + } + } else { + *state.remote_workspace.write().await = None; + if let Some(m) = get_remote_workspace_manager() { + m.set_active_connection_hint(None).await; + } + } } #[tauri::command] @@ -673,14 +736,16 @@ pub async fn open_remote_workspace( app: tauri::AppHandle, request: OpenRemoteWorkspaceRequest, ) -> Result { + use bitfun_core::service::remote_ssh::normalize_remote_workspace_path; use bitfun_core::service::workspace::WorkspaceCreateOptions; - let display_name = request - .remote_path + let remote_path = normalize_remote_workspace_path(&request.remote_path); + + let display_name = remote_path .split('/') .filter(|s| !s.is_empty()) .last() - .unwrap_or(&request.remote_path) + .unwrap_or(remote_path.as_str()) .to_string(); let options = WorkspaceCreateOptions { @@ -695,11 +760,12 @@ pub async fn open_remote_workspace( display_name: Some(display_name), description: None, tags: Vec::new(), + remote_connection_id: Some(request.connection_id.clone()), }; match state .workspace_service - .open_workspace_with_options(request.remote_path.clone().into(), options) + .open_workspace_with_options(remote_path.clone().into(), options) .await { Ok(mut workspace_info) => { @@ -723,18 +789,19 @@ pub async fn open_remote_workspace( warn!("Failed to save workspace data after opening remote workspace: {}", e); } - apply_active_workspace_context(&state, &app, &workspace_info).await; - - // Also update the RemoteWorkspaceStateManager so tools can use this connection + // Register the remote mapping before applying workspace context so session storage path + // resolution (`get_effective_session_path`) and related setup see this connection. let remote_workspace = crate::api::RemoteWorkspace { connection_id: request.connection_id.clone(), connection_name: request.connection_name.clone(), - remote_path: request.remote_path.clone(), + remote_path: remote_path.clone(), }; if let Err(e) = state.set_remote_workspace(remote_workspace).await { warn!("Failed to set remote workspace state: {}", e); } + apply_active_workspace_context(&state, &app, &workspace_info).await; + info!( "Remote workspace opened: name={}, remote_path={}, connection_id={}", workspace_info.name, @@ -1001,13 +1068,10 @@ pub async fn close_workspace( app: tauri::AppHandle, request: CloseWorkspaceRequest, ) -> Result<(), String> { - // Check if the workspace being closed is a remote workspace before closing it - let is_remote = state + let closing = state .workspace_service .get_workspace(&request.workspace_id) - .await - .map(|w| w.workspace_kind == WorkspaceKind::Remote) - .unwrap_or(false); + .await; match state .workspace_service @@ -1015,10 +1079,14 @@ pub async fn close_workspace( .await { Ok(_) => { - // If it was a remote workspace, also clear the persisted remote workspace data - // so it doesn't get re-opened on next restart - if is_remote { - state.clear_remote_workspace().await; + if let Some(ref ws) = closing { + if ws.workspace_kind == WorkspaceKind::Remote { + if let Some(rw) = remote_workspace_from_info(ws) { + state + .unregister_remote_workspace_entry(&rw.connection_id, &rw.remote_path) + .await; + } + } } if let Some(workspace_info) = state.workspace_service.get_current_workspace().await { @@ -1199,6 +1267,7 @@ pub async fn scan_workspace_info( workspace_kind: WorkspaceKind::Normal, assistant_id: None, display_name: None, + remote_connection_id: None, }, ) .await @@ -1386,9 +1455,7 @@ pub async fn read_file_content( state: State<'_, AppState>, request: ReadFileContentRequest, ) -> Result { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.file_path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.file_path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; let bytes = remote_fs.read_file(&entry.connection_id, &request.file_path).await @@ -1414,9 +1481,7 @@ pub async fn write_file_content( state: State<'_, AppState>, request: WriteFileContentRequest, ) -> Result<(), String> { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.file_path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.file_path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.write_file(&entry.connection_id, &request.file_path, request.content.as_bytes()).await @@ -1481,9 +1546,7 @@ pub async fn check_path_exists( state: State<'_, AppState>, request: CheckPathExistsRequest, ) -> Result { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; return remote_fs.exists(&entry.connection_id, &request.path).await @@ -1500,26 +1563,32 @@ pub async fn get_file_metadata( request: GetFileMetadataRequest, ) -> Result { use std::time::SystemTime; - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; - let is_file = remote_fs.is_file(&entry.connection_id, &request.path).await - .unwrap_or(false); - let is_dir = remote_fs.is_dir(&entry.connection_id, &request.path).await - .unwrap_or(false); - - let now_ms = SystemTime::now() - .duration_since(SystemTime::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; + // Use SFTP stat for stable mtime/size. Returning `SystemTime::now()` as `modified` caused + // the editor's 5s poll to always see a "newer" file and spam the external-change dialog. + let stat_entry = remote_fs + .stat(&entry.connection_id, &request.path) + .await + .map_err(|e| format!("Failed to stat remote file: {}", e))?; + + let (is_file, is_dir, size, modified) = match stat_entry { + Some(e) => ( + e.is_file, + e.is_dir, + e.size.unwrap_or(0), + e.modified.unwrap_or(0), + ), + None => (false, false, 0, 0), + }; return Ok(serde_json::json!({ "path": request.path, - "modified": now_ms, - "size": 0, + "modified": modified, + "size": size, "is_file": is_file, "is_dir": is_dir, "is_remote": true @@ -1563,9 +1632,7 @@ pub async fn rename_file( state: State<'_, AppState>, request: RenameFileRequest, ) -> Result<(), String> { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.old_path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.old_path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.rename(&entry.connection_id, &request.old_path, &request.new_path).await @@ -1606,9 +1673,7 @@ pub async fn delete_file( state: State<'_, AppState>, request: DeleteFileRequest, ) -> Result<(), String> { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.remove_file(&entry.connection_id, &request.path).await @@ -1630,11 +1695,9 @@ pub async fn delete_directory( state: State<'_, AppState>, request: DeleteDirectoryRequest, ) -> Result<(), String> { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - let recursive = request.recursive.unwrap_or(false); - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; if recursive { @@ -1661,9 +1724,7 @@ pub async fn create_file( state: State<'_, AppState>, request: CreateFileRequest, ) -> Result<(), String> { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.write_file(&entry.connection_id, &request.path, b"").await @@ -1686,9 +1747,7 @@ pub async fn create_directory( state: State<'_, AppState>, request: CreateDirectoryRequest, ) -> Result<(), String> { - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; remote_fs.create_dir_all(&entry.connection_id, &request.path).await @@ -1717,9 +1776,8 @@ pub async fn list_directory_files( request: ListDirectoryFilesRequest, ) -> Result, String> { use std::path::Path; - use bitfun_core::service::remote_ssh::workspace_state::lookup_remote_connection; - if let Some(entry) = lookup_remote_connection(&request.path).await { + if let Some(entry) = lookup_remote_entry_for_path(&state, &request.path).await { let remote_fs = state.get_remote_file_service_async().await .map_err(|e| format!("Remote file service not available: {}", e))?; let entries = remote_fs.read_dir(&entry.connection_id, &request.path).await diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index 7639b8ca..0ee04a31 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -1,5 +1,7 @@ //! DTO Module +use bitfun_core::service::remote_ssh::normalize_remote_workspace_path; +use bitfun_core::service::workspace::manager::WorkspaceKind; use serde::{Deserialize, Serialize}; use std::collections::HashMap; @@ -88,10 +90,16 @@ impl WorkspaceInfoDto { .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let root_path = if matches!(info.workspace_kind, WorkspaceKind::Remote) { + normalize_remote_workspace_path(&info.root_path.to_string_lossy()) + } else { + info.root_path.to_string_lossy().to_string() + }; + Self { id: info.id.clone(), name: info.name.clone(), - root_path: info.root_path.to_string_lossy().to_string(), + root_path, workspace_type: WorkspaceTypeDto::from_workspace_type(&info.workspace_type), workspace_kind: WorkspaceKindDto::from_workspace_kind(&info.workspace_kind), assistant_id: info.assistant_id.clone(), diff --git a/src/apps/desktop/src/api/session_api.rs b/src/apps/desktop/src/api/session_api.rs index 9971a975..eaae3e91 100644 --- a/src/apps/desktop/src/api/session_api.rs +++ b/src/apps/desktop/src/api/session_api.rs @@ -13,6 +13,8 @@ use tauri::State; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ListPersistedSessionsRequest { pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -20,6 +22,8 @@ pub struct LoadSessionTurnsRequest { pub session_id: String, pub workspace_path: String, #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub limit: Option, } @@ -27,18 +31,24 @@ pub struct LoadSessionTurnsRequest { pub struct SaveSessionTurnRequest { pub turn_data: DialogTurnData, pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SaveSessionMetadataRequest { pub metadata: SessionMetadata, pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExportSessionTranscriptRequest { pub session_id: String, pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, #[serde(default = "default_tools")] pub tools: bool, #[serde(default)] @@ -57,18 +67,24 @@ fn default_tools() -> bool { pub struct DeletePersistedSessionRequest { pub session_id: String, pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TouchSessionActivityRequest { pub session_id: String, pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LoadPersistedSessionMetadataRequest { pub session_id: String, pub workspace_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, } #[tauri::command] @@ -76,7 +92,7 @@ pub async fn list_persisted_sessions( request: ListPersistedSessionsRequest, path_manager: State<'_, Arc>, ) -> Result, String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -91,7 +107,7 @@ pub async fn load_session_turns( request: LoadSessionTurnsRequest, path_manager: State<'_, Arc>, ) -> Result, String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -113,7 +129,7 @@ pub async fn save_session_turn( request: SaveSessionTurnRequest, path_manager: State<'_, Arc>, ) -> Result<(), String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -128,7 +144,7 @@ pub async fn save_session_metadata( request: SaveSessionMetadataRequest, path_manager: State<'_, Arc>, ) -> Result<(), String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -143,7 +159,7 @@ pub async fn export_session_transcript( request: ExportSessionTranscriptRequest, path_manager: State<'_, Arc>, ) -> Result { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -167,7 +183,7 @@ pub async fn delete_persisted_session( request: DeletePersistedSessionRequest, path_manager: State<'_, Arc>, ) -> Result<(), String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -182,7 +198,7 @@ pub async fn touch_session_activity( request: TouchSessionActivityRequest, path_manager: State<'_, Arc>, ) -> Result<(), String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; @@ -197,7 +213,7 @@ pub async fn load_persisted_session_metadata( request: LoadPersistedSessionMetadataRequest, path_manager: State<'_, Arc>, ) -> Result, String> { - let workspace_path = get_effective_session_path(&request.workspace_path).await; + let workspace_path = get_effective_session_path(&request.workspace_path, request.remote_connection_id.as_deref()).await; let manager = PersistenceManager::new(path_manager.inner().clone()) .map_err(|e| format!("Failed to create persistence manager: {}", e))?; diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index 33d32ec9..ecbca5c0 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -319,6 +319,8 @@ pub async fn remote_open_workspace( connection_id: String, remote_path: String, ) -> Result<(), String> { + let remote_path = + bitfun_core::service::remote_ssh::normalize_remote_workspace_path(&remote_path); let manager = state.get_ssh_manager_async().await?; // Verify connection exists diff --git a/src/apps/desktop/src/api/terminal_api.rs b/src/apps/desktop/src/api/terminal_api.rs index 1e19d067..bc60a781 100644 --- a/src/apps/desktop/src/api/terminal_api.rs +++ b/src/apps/desktop/src/api/terminal_api.rs @@ -310,7 +310,7 @@ pub async fn terminal_get_shells( async fn lookup_remote_for_terminal(working_directory: Option<&str>) -> Option<(String, String)> { let wd = working_directory?; let manager = get_remote_workspace_manager()?; - let entry = manager.lookup_connection(wd).await?; + let entry = manager.lookup_connection(wd, None).await?; Some((entry.connection_id, wd.to_string())) } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 6f7d3754..954efe84 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -293,6 +293,7 @@ pub async fn run() { theme::show_main_window, api::agentic_api::create_session, api::agentic_api::update_session_model, + api::agentic_api::ensure_coordinator_session, api::agentic_api::start_dialog_turn, api::agentic_api::ensure_assistant_bootstrap, api::agentic_api::cancel_dialog_turn, diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 758d8efb..0d0096fb 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -122,7 +122,12 @@ impl ConversationCoordinator { let path_buf = PathBuf::from(workspace_path); // Check if this path belongs to any registered remote workspace - if let Some(entry) = crate::service::remote_ssh::workspace_state::lookup_remote_connection(workspace_path).await { + if let Some(entry) = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( + workspace_path, + config.remote_connection_id.as_deref(), + ) + .await + { if let Some(manager) = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() { let local_session_path = manager.get_local_session_path(&entry.connection_id); return Some(WorkspaceBinding::new_remote( diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index 22a0a045..0e2af72d 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -128,6 +128,9 @@ pub struct SessionConfig { /// without changing the desktop's foreground workspace. #[serde(skip_serializing_if = "Option::is_none")] pub workspace_path: Option, + /// SSH workspace: disambiguates the same `workspace_path` on different hosts (e.g. two `/` roots). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote_connection_id: Option, /// Model config ID used by this session (for token usage tracking) #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, @@ -144,6 +147,7 @@ impl Default for SessionConfig { enable_context_compression: true, compression_threshold: 0.8, // 80% workspace_path: None, + remote_connection_id: None, model_id: None, } } diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 32edcdb7..6b5d4e65 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -1468,9 +1468,6 @@ impl PersistenceManager { /// Save session pub async fn save_session(&self, workspace_path: &Path, session: &Session) -> BitFunResult<()> { - if !workspace_path.exists() { - return Ok(()); - } self.ensure_session_dir(workspace_path, &session.session_id) .await?; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 86e1ddd6..ff78f99a 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -70,7 +70,12 @@ impl SessionManager { let path_buf = PathBuf::from(workspace_path); // Check if this path belongs to any registered remote workspace - if let Some(entry) = crate::service::remote_ssh::workspace_state::lookup_remote_connection(workspace_path).await { + if let Some(entry) = crate::service::remote_ssh::workspace_state::lookup_remote_connection_with_hint( + workspace_path, + config.remote_connection_id.as_deref(), + ) + .await + { if let Some(manager) = crate::service::remote_ssh::workspace_state::get_remote_workspace_manager() { return Some(manager.get_local_session_path(&entry.connection_id)); } @@ -258,7 +263,9 @@ impl SessionManager { self.sessions.insert(session_id.clone(), session.clone()); // 2. Initialize message history - self.history_manager.create_session(&session_id).await?; + self.history_manager + .create_session(&session_id) + .await?; // 3. Initialize compression manager self.compression_manager.create_session(&session_id); @@ -1280,8 +1287,9 @@ impl SessionManager { for entry in sessions.iter() { let session = entry.value(); - let workspace_path = session.config.workspace_path.clone().map(PathBuf::from); - if let Some(workspace_path) = workspace_path { + if let Some(workspace_path) = + Self::effective_workspace_path_from_config(&session.config).await + { if let Err(e) = persistence.save_session(&workspace_path, session).await { error!( "Failed to auto-save session: session_id={}, error={}", @@ -1328,7 +1336,7 @@ impl SessionManager { if enable_persistence { if let Some(session) = sessions.get(&session_id) { if let Some(workspace_path) = - session.config.workspace_path.clone().map(PathBuf::from) + Self::effective_workspace_path_from_config(&session.config).await { let _ = persistence.save_session(&workspace_path, &session).await; } diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/filesystem/path_manager.rs index a687d434..592add56 100644 --- a/src/crates/core/src/infrastructure/filesystem/path_manager.rs +++ b/src/crates/core/src/infrastructure/filesystem/path_manager.rs @@ -214,6 +214,39 @@ impl PathManager { self.user_root.join("data") } + /// Root directory for **local** persistence of SSH remote workspace sessions (chat history, + /// session metadata, etc.). This is always on the client machine — never the remote POSIX path. + /// + /// **Canonical (all platforms):** [`Self::user_data_dir`]`/remote-workspaces/` — same tree as + /// other BitFun app data (`PathManager::user_root` / `config_dir`/`bitfun` on each OS). + /// + /// **Legacy:** Older builds used `{data_local_dir}/BitFun/remote-workspaces/`. If that folder + /// exists and the canonical path does not, this returns the legacy path so existing installs + /// keep working. On Windows this avoided splitting data between `AppData\Local\BitFun` and + /// `AppData\Roaming\bitfun`; new installs use the canonical Roaming `bitfun\data` tree only. + pub fn remote_ssh_sessions_root() -> PathBuf { + let legacy = dirs::data_local_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("BitFun") + .join("remote-workspaces"); + + let canonical = match Self::new() { + Ok(pm) => pm.user_data_dir().join("remote-workspaces"), + Err(_) => legacy.clone(), + }; + + let canonical_exists = canonical.exists(); + let legacy_exists = legacy.exists(); + let chosen = if canonical_exists { + canonical.clone() + } else if legacy_exists { + legacy.clone() + } else { + canonical.clone() + }; + chosen + } + /// Get scheduled jobs directory: ~/.config/bitfun/data/cron/ pub fn user_cron_dir(&self) -> PathBuf { self.user_data_dir().join("cron") diff --git a/src/crates/core/src/service/remote_ssh/manager.rs b/src/crates/core/src/service/remote_ssh/manager.rs index a1f1a9c3..27920b66 100644 --- a/src/crates/core/src/service/remote_ssh/manager.rs +++ b/src/crates/core/src/service/remote_ssh/manager.rs @@ -19,6 +19,26 @@ use async_trait::async_trait; #[cfg(feature = "ssh_config")] use ssh_config::SSHConfig; +/// OpenSSH keyword matching is case-insensitive, but `ssh_config` stores keys as written in the file +/// (e.g. `HostName` vs `Hostname`). Resolve by ASCII case-insensitive compare. +#[cfg(feature = "ssh_config")] +fn ssh_cfg_get<'a>( + settings: &std::collections::HashMap<&'a str, &'a str>, + canonical_key: &str, +) -> Option<&'a str> { + settings + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(canonical_key)) + .map(|(_, v)| *v) +} + +#[cfg(feature = "ssh_config")] +fn ssh_cfg_has(settings: &std::collections::HashMap<&str, &str>, canonical_key: &str) -> bool { + settings + .keys() + .any(|k| k.eq_ignore_ascii_case(canonical_key)) +} + /// Known hosts entry #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct KnownHostEntry { @@ -361,7 +381,7 @@ impl SSHConnectionManager { let content = tokio::fs::read_to_string(&self.remote_workspace_path).await?; // Try array format first, fall back to single-object for backward compat - let workspaces: Vec = + let mut workspaces: Vec = serde_json::from_str(&content) .or_else(|_| { // Legacy: single workspace object @@ -370,6 +390,15 @@ impl SSHConnectionManager { }) .context("Failed to parse remote workspace(s)")?; + let before = workspaces.len(); + workspaces.retain(|w| !w.connection_id.is_empty() && !w.remote_path.is_empty()); + if workspaces.len() < before { + log::warn!( + "Dropped {} persisted remote workspace(s) with empty connectionId or remotePath", + before - workspaces.len() + ); + } + let mut guard = self.remote_workspaces.write().await; *guard = workspaces; @@ -389,12 +418,22 @@ impl SSHConnectionManager { Ok(()) } - /// Add/update a persisted remote workspace - pub async fn set_remote_workspace(&self, workspace: crate::service::remote_ssh::types::RemoteWorkspace) -> anyhow::Result<()> { + /// Add/update a persisted remote workspace (key = `connection_id` + `remote_path`). + pub async fn set_remote_workspace(&self, mut workspace: crate::service::remote_ssh::types::RemoteWorkspace) -> anyhow::Result<()> { + workspace.remote_path = + crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( + &workspace.remote_path, + ); { let mut guard = self.remote_workspaces.write().await; - // Replace existing entry with same remote_path, or append - guard.retain(|w| w.remote_path != workspace.remote_path); + let rp = workspace.remote_path.clone(); + let cid = workspace.connection_id.clone(); + guard.retain(|w| { + !(w.connection_id == cid + && crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( + &w.remote_path, + ) == rp) + }); guard.push(workspace); } self.save_remote_workspaces().await @@ -410,11 +449,17 @@ impl SSHConnectionManager { self.remote_workspaces.read().await.first().cloned() } - /// Remove a specific remote workspace by path - pub async fn remove_remote_workspace(&self, remote_path: &str) -> anyhow::Result<()> { + /// Remove a specific remote workspace by **connection** + **remote path** (not path alone). + pub async fn remove_remote_workspace(&self, connection_id: &str, remote_path: &str) -> anyhow::Result<()> { + let rp = crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path(remote_path); { let mut guard = self.remote_workspaces.write().await; - guard.retain(|w| w.remote_path != remote_path); + guard.retain(|w| { + !(w.connection_id == connection_id + && crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( + &w.remote_path, + ) == rp) + }); } self.save_remote_workspaces().await } @@ -472,16 +517,15 @@ impl SSHConnectionManager { log::debug!("Found SSH config for host: {} with {} settings", host, host_settings.len()); - // Extract fields from the HashMap - keys are case-insensitive - let hostname = host_settings.get("Hostname").map(|s| s.to_string()); - let user = host_settings.get("User").map(|s| s.to_string()); - let port = host_settings.get("Port") + // Canonical OpenSSH names; lookup is case-insensitive (see ssh_cfg_get). + let hostname = ssh_cfg_get(&host_settings, "HostName").map(|s| s.to_string()); + let user = ssh_cfg_get(&host_settings, "User").map(|s| s.to_string()); + let port = ssh_cfg_get(&host_settings, "Port") .and_then(|s| s.parse::().ok()); - let identity_file = host_settings.get("IdentityFile") + let identity_file = ssh_cfg_get(&host_settings, "IdentityFile") .map(|f| shellexpand::tilde(f).to_string()); - // Check if proxy command is set (agent forwarding vs proxy command) - let has_proxy_command = host_settings.contains_key("ProxyCommand"); + let has_proxy_command = ssh_cfg_has(&host_settings, "ProxyCommand"); return SSHConfigLookupResult { found: true, @@ -552,12 +596,12 @@ impl SSHConnectionManager { // Query config for this host to get details let settings = config.query(alias); - let identity_file = settings.get("IdentityFile") + let identity_file = ssh_cfg_get(&settings, "IdentityFile") .map(|f| shellexpand::tilde(f).to_string()); - let hostname = settings.get("Hostname").map(|s| s.to_string()); - let user = settings.get("User").map(|s| s.to_string()); - let port = settings.get("Port") + let hostname = ssh_cfg_get(&settings, "HostName").map(|s| s.to_string()); + let user = ssh_cfg_get(&settings, "User").map(|s| s.to_string()); + let port = ssh_cfg_get(&settings, "Port") .and_then(|s| s.parse::().ok()); hosts.push(SSHConfigEntry { diff --git a/src/crates/core/src/service/remote_ssh/mod.rs b/src/crates/core/src/service/remote_ssh/mod.rs index c100848c..aa60b5e6 100644 --- a/src/crates/core/src/service/remote_ssh/mod.rs +++ b/src/crates/core/src/service/remote_ssh/mod.rs @@ -19,6 +19,7 @@ pub use remote_terminal::{RemoteTerminalManager, RemoteTerminalSession, SessionS pub use types::*; pub use workspace_state::{ get_remote_workspace_manager, init_remote_workspace_manager, is_remote_workspace_active, - is_remote_path, lookup_remote_connection, - RemoteWorkspaceEntry, RemoteWorkspaceState, RemoteWorkspaceStateManager, + is_remote_path, lookup_remote_connection, lookup_remote_connection_with_hint, + normalize_remote_workspace_path, RemoteWorkspaceEntry, RemoteWorkspaceState, + RemoteWorkspaceStateManager, }; diff --git a/src/crates/core/src/service/remote_ssh/types.rs b/src/crates/core/src/service/remote_ssh/types.rs index 89f0c351..134adbe2 100644 --- a/src/crates/core/src/service/remote_ssh/types.rs +++ b/src/crates/core/src/service/remote_ssh/types.rs @@ -201,15 +201,16 @@ pub struct RemoteWorkspaceRequest { pub remote_path: String, } -/// Remote workspace info +/// Remote workspace info (persisted in `remote_workspace.json`). +/// `#[serde(default)]` keeps older files loadable if a field was absent. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RemoteWorkspace { - #[serde(rename = "connectionId")] + #[serde(default)] pub connection_id: String, - #[serde(rename = "remotePath")] + #[serde(default)] pub remote_path: String, - #[serde(rename = "connectionName")] + #[serde(default)] pub connection_name: String, } diff --git a/src/crates/core/src/service/remote_ssh/workspace_state.rs b/src/crates/core/src/service/remote_ssh/workspace_state.rs index a68dc0cb..01ca3572 100644 --- a/src/crates/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/core/src/service/remote_ssh/workspace_state.rs @@ -1,15 +1,64 @@ //! Remote Workspace Global State //! //! Provides a **registry** of remote SSH workspaces so that multiple remote -//! workspaces can be open simultaneously. Each workspace is keyed by its -//! remote path and maps to the SSH connection that serves it. +//! workspaces can coexist. Each registration is uniquely identified by +//! **`(connection_id, remote_root_path)`** — *not* by remote path alone, so two +//! different servers opened at the same path (e.g. `/`) do not overwrite each other. +use crate::infrastructure::PathManager; use crate::service::remote_ssh::{RemoteFileService, RemoteTerminalManager, SSHConnectionManager}; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; +/// Normalize a remote (POSIX) workspace path for registry lookup on any client OS. +/// Converts backslashes to slashes, collapses duplicate slashes, and trims trailing slashes +/// except for the filesystem root `/`. +pub fn normalize_remote_workspace_path(path: &str) -> String { + let mut s = path.replace('\\', "/"); + while s.contains("//") { + s = s.replace("//", "/"); + } + if s == "/" { + return s; + } + s.trim_end_matches('/').to_string() +} + +/// Characters invalid in a single Windows path component (e.g. `user@host:port` breaks on `:`). +/// On Unix, `:` is allowed in file names; we only rewrite on Windows. +pub fn sanitize_ssh_connection_id_for_local_dir(connection_id: &str) -> String { + #[cfg(windows)] + { + connection_id + .chars() + .map(|c| match c { + '<' | '>' | '"' | ':' | '/' | '\\' | '|' | '?' | '*' => '-', + c if c.is_control() => '-', + _ => c, + }) + .collect() + } + #[cfg(not(windows))] + { + connection_id.to_string() + } +} + +fn remote_path_is_under_root(path: &str, root: &str) -> bool { + if path == root { + return true; + } + if root == "/" { + return path.starts_with('/') && path != "/"; + } + path.starts_with(&format!("{}/", root)) +} + +fn registration_matches_path(reg: &RegisteredRemoteWorkspace, path_norm: &str) -> bool { + path_norm == reg.remote_root || remote_path_is_under_root(path_norm, ®.remote_root) +} + /// A single registered remote workspace entry. #[derive(Debug, Clone)] pub struct RemoteWorkspaceEntry { @@ -28,14 +77,22 @@ pub struct RemoteWorkspaceState { pub connection_name: Option, } +#[derive(Debug, Clone)] +struct RegisteredRemoteWorkspace { + connection_id: String, + remote_root: String, + connection_name: String, +} + /// Global remote workspace state manager. /// -/// Instead of storing a **single** active workspace it now maintains a -/// `HashMap` so that several remote -/// workspaces can coexist. +/// Registrations are keyed logically by **`(connection_id, remote_root)`** so the same +/// POSIX path on different SSH hosts never collides. pub struct RemoteWorkspaceStateManager { - /// Key = remote_path (e.g. "/root/project"), Value = connection info. - workspaces: Arc>>, + registrations: Arc>>, + /// Disambiguates file APIs when multiple registrations share the same remote root + /// (e.g. two servers at `/`). Updated when the user focuses a remote workspace tab. + active_connection_hint: Arc>>, /// SSH connection manager (shared across all workspaces). ssh_manager: Arc>>, /// Remote file service (shared). @@ -48,13 +105,11 @@ pub struct RemoteWorkspaceStateManager { impl RemoteWorkspaceStateManager { pub fn new() -> Self { - let local_session_base = dirs::data_local_dir() - .unwrap_or_else(|| PathBuf::from(".")) - .join("BitFun") - .join("remote-workspaces"); + let local_session_base = PathManager::remote_ssh_sessions_root(); Self { - workspaces: Arc::new(RwLock::new(HashMap::new())), + registrations: Arc::new(RwLock::new(Vec::new())), + active_connection_hint: Arc::new(RwLock::new(None)), ssh_manager: Arc::new(RwLock::new(None)), file_service: Arc::new(RwLock::new(None)), terminal_manager: Arc::new(RwLock::new(None)), @@ -76,59 +131,100 @@ impl RemoteWorkspaceStateManager { *self.terminal_manager.write().await = Some(manager); } + /// Prefer this SSH `connection_id` when resolving an ambiguous remote path. + pub async fn set_active_connection_hint(&self, connection_id: Option) { + *self.active_connection_hint.write().await = connection_id; + } + // ── Registry API ─────────────────────────────────────────────── - /// Register (or update) a remote workspace. + /// Register (or replace) a remote workspace for **`(connection_id, remote_path)`**. pub async fn register_remote_workspace( &self, remote_path: String, connection_id: String, connection_name: String, ) { - let mut guard = self.workspaces.write().await; - guard.insert( - remote_path, - RemoteWorkspaceEntry { - connection_id, - connection_name, - }, - ); + let remote_root = normalize_remote_workspace_path(&remote_path); + let mut guard = self.registrations.write().await; + guard.retain(|r| { + !(r.connection_id == connection_id && r.remote_root == remote_root) + }); + guard.push(RegisteredRemoteWorkspace { + connection_id, + remote_root, + connection_name, + }); } - /// Unregister a remote workspace by its path. - pub async fn unregister_remote_workspace(&self, remote_path: &str) { - let mut guard = self.workspaces.write().await; - guard.remove(remote_path); + /// Remove the registration for this **exact** SSH connection + remote root. + pub async fn unregister_remote_workspace(&self, connection_id: &str, remote_path: &str) { + let remote_root = normalize_remote_workspace_path(remote_path); + let mut guard = self.registrations.write().await; + guard.retain(|r| !(r.connection_id == connection_id && r.remote_root == remote_root)); } - /// Look up the connection info for a given path. + /// Look up the connection info for a given remote path. /// - /// Returns `Some(entry)` if `path` equals a registered remote root **or** - /// is a sub-path of one (e.g. `/root/project/src/main.rs` matches - /// `/root/project`). - pub async fn lookup_connection(&self, path: &str) -> Option { - let guard = self.workspaces.read().await; - // Exact match first (most common). - if let Some(entry) = guard.get(path) { - return Some(entry.clone()); + /// `preferred_connection_id` should be supplied when known (e.g. from session metadata). + /// If omitted and multiple registrations share the same longest matching root, + /// [`Self::active_connection_hint`] is used when it matches one of them. + pub async fn lookup_connection( + &self, + path: &str, + preferred_connection_id: Option<&str>, + ) -> Option { + let path_norm = normalize_remote_workspace_path(path); + let hint = self.active_connection_hint.read().await.clone(); + let guard = self.registrations.read().await; + + let mut candidates: Vec<&RegisteredRemoteWorkspace> = guard + .iter() + .filter(|r| registration_matches_path(r, &path_norm)) + .collect(); + + if let Some(pref) = preferred_connection_id { + candidates.retain(|r| r.connection_id == pref); + } + + let best_len = candidates.iter().map(|r| r.remote_root.len()).max()?; + candidates.retain(|r| r.remote_root.len() == best_len); + + if candidates.is_empty() { + return None; + } + if candidates.len() == 1 { + let r = candidates[0]; + return Some(RemoteWorkspaceEntry { + connection_id: r.connection_id.clone(), + connection_name: r.connection_name.clone(), + }); } - // Sub-path match. - for (root, entry) in guard.iter() { - if path.starts_with(&format!("{}/", root)) { - return Some(entry.clone()); + + if let Some(ref h) = hint { + if let Some(r) = candidates.iter().find(|r| r.connection_id == *h) { + return Some(RemoteWorkspaceEntry { + connection_id: r.connection_id.clone(), + connection_name: r.connection_name.clone(), + }); } } + None } - /// Quick boolean check: is `path` inside any registered remote workspace? + /// True if `path` could belong to **any** registered remote root (before disambiguation). pub async fn is_remote_path(&self, path: &str) -> bool { - self.lookup_connection(path).await.is_some() + let path_norm = normalize_remote_workspace_path(path); + let guard = self.registrations.read().await; + guard + .iter() + .any(|r| registration_matches_path(r, &path_norm)) } /// Returns `true` if at least one remote workspace is registered. pub async fn has_any(&self) -> bool { - !self.workspaces.read().await.is_empty() + !self.registrations.read().await.is_empty() } // ── Legacy compat ────────────────────────────────────────────── @@ -146,22 +242,22 @@ impl RemoteWorkspaceStateManager { } /// **Compat** — old code calls `deactivate_remote_workspace`. - /// Now unregisters ALL workspaces. Callers that need to remove a - /// specific workspace should use `unregister_remote_workspace`. + /// Clears all registrations and the active hint (use sparingly). pub async fn deactivate_remote_workspace(&self) { - self.workspaces.write().await.clear(); + self.registrations.write().await.clear(); + *self.active_connection_hint.write().await = None; } /// **Compat** — returns a snapshot shaped like the old single-workspace /// state. Picks the *first* registered workspace. pub async fn get_state(&self) -> RemoteWorkspaceState { - let guard = self.workspaces.read().await; - if let Some((path, entry)) = guard.iter().next() { + let guard = self.registrations.read().await; + if let Some(r) = guard.first() { RemoteWorkspaceState { is_active: true, - connection_id: Some(entry.connection_id.clone()), - remote_path: Some(path.clone()), - connection_name: Some(entry.connection_name.clone()), + connection_id: Some(r.connection_id.clone()), + remote_path: Some(r.remote_root.clone()), + connection_name: Some(r.connection_name.clone()), } } else { RemoteWorkspaceState { @@ -195,13 +291,21 @@ impl RemoteWorkspaceStateManager { // ── Session storage ──────────────────────────────────────────── pub fn get_local_session_path(&self, connection_id: &str) -> PathBuf { - self.local_session_base.join(connection_id).join("sessions") + let dir_name = sanitize_ssh_connection_id_for_local_dir(connection_id); + self.local_session_base.join(dir_name).join("sessions") } /// Map a workspace path to the effective session storage path. /// Remote paths → local session dir. Local paths → returned as-is. - pub async fn get_effective_session_path(&self, workspace_path: &str) -> PathBuf { - if let Some(entry) = self.lookup_connection(workspace_path).await { + pub async fn get_effective_session_path( + &self, + workspace_path: &str, + remote_connection_id: Option<&str>, + ) -> PathBuf { + if let Some(entry) = self + .lookup_connection(workspace_path, remote_connection_id) + .await + { return self.get_local_session_path(&entry.connection_id); } PathBuf::from(workspace_path) @@ -230,10 +334,15 @@ pub fn get_remote_workspace_manager() -> Option // ── Free-standing helpers (convenience) ───────────────────────────── -/// Get the effective session path for a workspace. -pub async fn get_effective_session_path(workspace_path: &str) -> std::path::PathBuf { +/// Resolve persisted session directory for a workspace path. +pub async fn get_effective_session_path( + workspace_path: &str, + remote_connection_id: Option<&str>, +) -> std::path::PathBuf { if let Some(manager) = get_remote_workspace_manager() { - manager.get_effective_session_path(workspace_path).await + manager + .get_effective_session_path(workspace_path, remote_connection_id) + .await } else { std::path::PathBuf::from(workspace_path) } @@ -248,10 +357,20 @@ pub async fn is_remote_path(path: &str) -> bool { } } -/// Look up the connection entry for a given path. -pub async fn lookup_remote_connection(path: &str) -> Option { +/// Look up the connection entry for a given path (optional explicit `connection_id`). +pub async fn lookup_remote_connection_with_hint( + path: &str, + preferred_connection_id: Option<&str>, +) -> Option { let manager = get_remote_workspace_manager()?; - manager.lookup_connection(path).await + manager + .lookup_connection(path, preferred_connection_id) + .await +} + +/// Look up using path only (uses active hint when ambiguous). +pub async fn lookup_remote_connection(path: &str) -> Option { + lookup_remote_connection_with_hint(path, None).await } /// **Compat** — old boolean check. Now returns true if ANY remote workspace @@ -263,3 +382,79 @@ pub async fn is_remote_workspace_active() -> bool { false } } + +#[cfg(test)] +mod tests { + use super::{normalize_remote_workspace_path, sanitize_ssh_connection_id_for_local_dir}; + + #[tokio::test] + async fn two_servers_same_root_both_registered() { + let m = super::RemoteWorkspaceStateManager::new(); + m.register_remote_workspace( + "/".to_string(), + "conn-a".to_string(), + "Server A".to_string(), + ) + .await; + m.register_remote_workspace( + "/".to_string(), + "conn-b".to_string(), + "Server B".to_string(), + ) + .await; + m.set_active_connection_hint(Some("conn-a".to_string())).await; + let a = m.lookup_connection("/tmp", None).await.unwrap(); + assert_eq!(a.connection_id, "conn-a"); + m.set_active_connection_hint(Some("conn-b".to_string())).await; + let b = m.lookup_connection("/tmp", None).await.unwrap(); + assert_eq!(b.connection_id, "conn-b"); + } + + #[tokio::test] + async fn preferred_connection_wins_over_hint() { + let m = super::RemoteWorkspaceStateManager::new(); + m.register_remote_workspace("/".to_string(), "c1".to_string(), "A".to_string()) + .await; + m.register_remote_workspace("/".to_string(), "c2".to_string(), "B".to_string()) + .await; + m.set_active_connection_hint(Some("c1".to_string())).await; + let x = m.lookup_connection("/x", Some("c2")).await.unwrap(); + assert_eq!(x.connection_id, "c2"); + } + + #[test] + fn sanitize_connection_id_port_colon_on_windows_only() { + #[cfg(windows)] + assert_eq!( + sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), + "ssh-root@1.95.50.146-22" + ); + #[cfg(not(windows))] + assert_eq!( + sanitize_ssh_connection_id_for_local_dir("ssh-root@1.95.50.146:22"), + "ssh-root@1.95.50.146:22" + ); + } + + #[test] + fn normalize_remote_collapses_slashes_and_backslashes() { + assert_eq!( + normalize_remote_workspace_path(r"\\home\\user\\repo//src"), + "/home/user/repo/src" + ); + } + + #[test] + fn normalize_remote_root_unchanged() { + assert_eq!(normalize_remote_workspace_path("/"), "/"); + assert_eq!(normalize_remote_workspace_path("///"), "/"); + } + + #[test] + fn normalize_remote_trims_trailing_slash() { + assert_eq!( + normalize_remote_workspace_path("/home/user/repo/"), + "/home/user/repo" + ); + } +} diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index fead7a3c..24658068 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -257,6 +257,9 @@ pub struct WorkspaceOpenOptions { pub workspace_kind: WorkspaceKind, pub assistant_id: Option, pub display_name: Option, + /// For [`WorkspaceKind::Remote`], must match persisted `metadata["connectionId"]` so two + /// servers opened at the same path (e.g. `/`) are separate workspace tabs. + pub remote_connection_id: Option, } impl Default for WorkspaceOpenOptions { @@ -268,11 +271,20 @@ impl Default for WorkspaceOpenOptions { workspace_kind: WorkspaceKind::Normal, assistant_id: None, display_name: None, + remote_connection_id: None, } } } impl WorkspaceInfo { + /// SSH connection id persisted in [`WorkspaceInfo::metadata`] for remote workspaces. + pub fn remote_ssh_connection_id(&self) -> Option<&str> { + self.metadata + .get("connectionId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + } + /// Creates a new workspace record. pub async fn new(root_path: PathBuf, options: WorkspaceOpenOptions) -> BitFunResult { let default_name = root_path @@ -723,11 +735,33 @@ impl WorkspaceManager { } } - let existing_workspace_id = self - .workspaces - .values() - .find(|w| w.root_path == path) - .map(|w| w.id.clone()); + let existing_workspace_id = if is_remote { + let desired = options + .remote_connection_id + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()); + self.workspaces + .values() + .find(|w| { + if w.workspace_kind != WorkspaceKind::Remote || w.root_path != path { + return false; + } + let existing = w.remote_ssh_connection_id(); + match desired { + Some(d) => existing == Some(d), + None => existing.is_none(), + } + }) + .map(|w| w.id.clone()) + } else { + self.workspaces + .values() + .find(|w| { + w.root_path == path && w.workspace_kind != WorkspaceKind::Remote + }) + .map(|w| w.id.clone()) + }; if let Some(workspace_id) = existing_workspace_id { if let Some(workspace) = self.workspaces.get_mut(&workspace_id) { diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index 3e1be696..43d61ba3 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -40,6 +40,8 @@ pub struct WorkspaceCreateOptions { pub display_name: Option, pub description: Option, pub tags: Vec, + /// See [`crate::service::workspace::manager::WorkspaceOpenOptions::remote_connection_id`]. + pub remote_connection_id: Option, } impl Default for WorkspaceCreateOptions { @@ -53,6 +55,7 @@ impl Default for WorkspaceCreateOptions { display_name: None, description: None, tags: Vec::new(), + remote_connection_id: None, } } } @@ -538,6 +541,9 @@ impl WorkspaceService { workspace_kind: existing_workspace.workspace_kind.clone(), assistant_id: existing_workspace.assistant_id.clone(), display_name: Some(existing_workspace.name.clone()), + remote_connection_id: existing_workspace + .remote_ssh_connection_id() + .map(str::to_string), }, ) .await?; @@ -1003,6 +1009,7 @@ impl WorkspaceService { workspace_kind: options.workspace_kind.clone(), assistant_id: options.assistant_id.clone(), display_name: options.display_name.clone(), + remote_connection_id: options.remote_connection_id.clone(), } } From 032c7cc4a8ec4a9eabb15e29a9ab70eef3f58f63 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 24 Mar 2026 12:23:25 +0800 Subject: [PATCH 2/2] feat(web-ui): SSH auth dialog, flow chat sessions, and workspace nav - Replace password-only dialog with SSHAuthPromptDialog; path utils and file-system hooks - Flow chat session/persistence modules, Agent/Session API, nav and theme tweaks - README: clarify Windows OpenSSL bootstrap for pnpm vs plain cargo --- README.md | 2 +- README.zh-CN.md | 2 +- .../sections/sessions/SessionsSection.tsx | 15 +- .../sections/workspaces/WorkspaceItem.tsx | 14 +- .../components/panels/base/FlexiblePanel.tsx | 3 + src/web-ui/src/app/layout/AppLayout.tsx | 16 +- .../components/Input/Input.scss | 8 +- .../preview/flowchat-cards-preview.css | 2 +- .../ssh-remote/PasswordInputDialog.tsx | 119 ---------- ...utDialog.scss => SSHAuthPromptDialog.scss} | 28 ++- .../ssh-remote/SSHAuthPromptDialog.tsx | 222 ++++++++++++++++++ .../ssh-remote/SSHConnectionDialog.tsx | 155 +++++++----- .../features/ssh-remote/SSHRemoteProvider.tsx | 28 ++- src/web-ui/src/features/ssh-remote/index.ts | 2 +- .../flow_chat/services/BtwThreadService.ts | 35 ++- .../src/flow_chat/services/FlowChatManager.ts | 64 ++++- .../flow-chat-manager/PersistenceModule.ts | 22 +- .../flow-chat-manager/SessionModule.ts | 139 ++++++++--- .../src/flow_chat/store/FlowChatStore.ts | 48 +++- .../tool-cards/ModelThinkingDisplay.scss | 14 +- .../tool-cards/_tool-card-common.scss | 6 +- src/web-ui/src/flow_chat/types/flow-chat.ts | 5 + .../api/service-api/AgentAPI.ts | 34 ++- .../api/service-api/SessionAPI.ts | 45 ++-- .../services/business/workspaceManager.ts | 5 +- .../infrastructure/theme/core/ThemeService.ts | 5 +- src/web-ui/src/locales/en-US/common.json | 2 + src/web-ui/src/locales/zh-CN/common.json | 2 + src/web-ui/src/shared/utils/pathUtils.ts | 14 ++ .../tools/editor/components/CodeEditor.tsx | 40 +++- .../editor/components/MarkdownEditor.tsx | 3 + .../tools/editor/components/PlanViewer.tsx | 3 + .../tools/file-system/hooks/useFileSystem.ts | 46 ++++ .../file-system/services/FileSystemService.ts | 18 +- .../GitDiffEditor/GitDiffEditor.tsx | 2 + 35 files changed, 851 insertions(+), 317 deletions(-) delete mode 100644 src/web-ui/src/features/ssh-remote/PasswordInputDialog.tsx rename src/web-ui/src/features/ssh-remote/{PasswordInputDialog.scss => SSHAuthPromptDialog.scss} (66%) create mode 100644 src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx diff --git a/README.md b/README.md index e0903237..ad97aff9 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ Make sure you have the following prerequisites installed: - Rust toolchain (install via [rustup](https://rustup.rs/)) - [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for desktop development -**Windows only**: The desktop build links against a **prebuilt** OpenSSL (no OpenSSL source compile). `desktop:dev` and all `desktop:build*` scripts use `ensure-openssl-windows.mjs` (via `desktop-tauri-build.mjs` for builds): the first time OpenSSL is needed, it downloads [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) into `.bitfun/cache/`; later runs reuse that cache. Override with `OPENSSL_DIR` pointing at the **`x64`** folder from the ZIP, or `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and your own `OPENSSL_*`. +**Windows only**: The desktop build links against a **prebuilt** OpenSSL (no OpenSSL source compile). You do **not** need to download the ZIP by hand: the first time OpenSSL is required, tooling fetches [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) into **`.bitfun/cache/`** and later runs reuse that cache. **`pnpm run desktop:dev`** and all **`pnpm run desktop:build*`** scripts run `ensure-openssl-windows.mjs` (builds use `desktop-tauri-build.mjs`). **If you compile with plain `cargo`** (without those pnpm entrypoints), run **`node scripts/ensure-openssl-windows.mjs`** once from the repo root first — it performs the same download and prints **`OPENSSL_*`** lines for PowerShell. Override with `OPENSSL_DIR` pointing at the **`x64`** folder from the ZIP, or `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` and your own `OPENSSL_*`. ```bash # Install dependencies diff --git a/README.zh-CN.md b/README.zh-CN.md index ccc47cff..efadea76 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -114,7 +114,7 @@ Mini Apps 从对话中涌现,Skills 在社区里更新,Agent 在协作中进 - [Rust 工具链](https://rustup.rs/) - [Tauri 前置依赖](https://v2.tauri.app/start/prerequisites/)(桌面端开发需要) -**Windows 特别说明**:桌面使用**预编译 OpenSSL**(不编译 OpenSSL 源码)。`desktop:dev` 与全部 `desktop:build*` 会通过 `ensure-openssl-windows.mjs`(构建走 `desktop-tauri-build.mjs`)自动准备:首次需要时下载 [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) 到 `.bitfun/cache/`,之后复用缓存。可自行设置 `OPENSSL_DIR` 为 ZIP 内 **`x64`** 目录,或 `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_*`。 +**Windows 特别说明**:桌面使用**预编译 OpenSSL**(不编译 OpenSSL 源码)。**无需手动下载 ZIP**:首次需要时会自动拉取 [FireDaemon OpenSSL 3.5.5](https://download.firedaemon.com/FireDaemon-OpenSSL/openssl-3.5.5.zip) 到 **`.bitfun/cache/`**,之后复用缓存。`pnpm run desktop:dev` 与全部 `desktop:build*` 会调用 `ensure-openssl-windows.mjs`(构建经 `desktop-tauri-build.mjs`)。**若只用 `cargo` 手动编译**(不经过上述 pnpm 入口),请先在仓库根目录执行一次 **`node scripts/ensure-openssl-windows.mjs`**,脚本会完成相同下载并打印可在 PowerShell 中粘贴的 **`OPENSSL_*`** 环境变量。也可自行将 `OPENSSL_DIR` 设为 ZIP 内 **`x64`** 目录,或设 `BITFUN_SKIP_OPENSSL_BOOTSTRAP=1` 并自行配置 `OPENSSL_*`。 ```bash # 安装依赖 diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index d0c2168a..5919fa0f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -50,6 +50,8 @@ const getTitle = (session: Session): string => interface SessionsSectionProps { workspaceId?: string; workspacePath?: string; + /** Remote SSH: same `workspacePath` on different hosts must filter by this (see Session.remoteConnectionId). */ + remoteConnectionId?: string | null; isActiveWorkspace?: boolean; showCreateActions?: boolean; } @@ -57,6 +59,7 @@ interface SessionsSectionProps { const SessionsSection: React.FC = ({ workspaceId, workspacePath, + remoteConnectionId = null, isActiveWorkspace = true, }) => { const { t } = useI18n('common'); @@ -112,7 +115,7 @@ const SessionsSection: React.FC = ({ useEffect(() => { setExpandLevel(0); - }, [workspaceId, workspacePath]); + }, [workspaceId, workspacePath, remoteConnectionId]); useEffect(() => { if (!openMenuSessionId) return; @@ -131,12 +134,18 @@ const SessionsSection: React.FC = ({ Array.from(flowChatState.sessions.values()) .filter((s: Session) => { if (workspacePath) { - return s.workspacePath === workspacePath; + if (s.workspacePath !== workspacePath) return false; + const wsConn = remoteConnectionId?.trim() ?? ''; + const sessConn = s.remoteConnectionId?.trim() ?? ''; + if (wsConn.length > 0 || sessConn.length > 0) { + return sessConn === wsConn; + } + return true; } return !s.workspacePath; }) .sort(compareSessionsForDisplay), - [flowChatState.sessions, workspacePath] + [flowChatState.sessions, workspacePath, remoteConnectionId] ); const { topLevelSessions, childrenByParent } = useMemo(() => { diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 45580ce3..2d696d91 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -247,6 +247,9 @@ const WorkspaceItem: React.FC = ({ await flowChatManager.createChatSession( { workspacePath: workspace.rootPath, + ...(isRemoteWorkspace(workspace) && workspace.connectionId + ? { remoteConnectionId: workspace.connectionId } + : {}), }, mode ?? (workspace.workspaceKind === WorkspaceKind.Assistant ? 'Claw' : undefined) ); @@ -257,7 +260,14 @@ const WorkspaceItem: React.FC = ({ { duration: 4000 } ); } - }, [setActiveWorkspace, t, workspace.id, workspace.rootPath, workspace.workspaceKind]); + }, [ + setActiveWorkspace, + t, + workspace.id, + workspace.rootPath, + workspace.workspaceKind, + workspace.connectionId, + ]); const handleCreateCodeSession = useCallback(() => { void handleCreateSession('agentic'); @@ -467,6 +477,7 @@ const WorkspaceItem: React.FC = ({ @@ -644,6 +655,7 @@ const WorkspaceItem: React.FC = ({ diff --git a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx index 91c0cc6f..fcb07050 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -4,6 +4,7 @@ import { MarkdownRenderer, IconButton } from '@/component-library'; import { CodeEditor, MarkdownEditor, ImageViewer, DiffEditor } from '@/tools/editor'; import { useI18n } from '@/infrastructure/i18n'; import { createLogger } from '@/shared/utils/logger'; +import { globalEventBus } from '@/infrastructure/event-bus'; const log = createLogger('FlexiblePanel'); @@ -561,6 +562,8 @@ const FlexiblePanel: React.FC = memo(({ const { workspaceAPI } = await import('@/infrastructure/api'); await workspaceAPI.writeFileContent(targetWorkspacePath, diffFilePath, content); + globalEventBus.emit('file-tree:refresh'); + if (onDirtyStateChange) { onDirtyStateChange(false); } diff --git a/src/web-ui/src/app/layout/AppLayout.tsx b/src/web-ui/src/app/layout/AppLayout.tsx index 5b28c7b7..68df4ed0 100644 --- a/src/web-ui/src/app/layout/AppLayout.tsx +++ b/src/web-ui/src/app/layout/AppLayout.tsx @@ -8,7 +8,7 @@ * TitleBar removed; window controls moved to NavBar, dialogs managed here. */ -import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useCallback, useEffect, useMemo, useRef, useContext } from 'react'; import { open } from '@tauri-apps/plugin-dialog'; import { useWorkspaceContext } from '../../infrastructure/contexts/WorkspaceContext'; import { useWindowControls } from '../hooks/useWindowControls'; @@ -28,6 +28,7 @@ import { workspaceAPI } from '@/infrastructure/api'; import { createLogger } from '@/shared/utils/logger'; import { useI18n } from '@/infrastructure/i18n'; import { WorkspaceKind } from '@/shared/types'; +import { SSHContext } from '@/features/ssh-remote/SSHRemoteProvider'; import { shortcutManager } from '@/infrastructure/services/ShortcutManager'; import './AppLayout.scss'; @@ -40,6 +41,12 @@ interface AppLayoutProps { const AppLayout: React.FC = ({ className = '' }) => { const { t } = useI18n('components'); const { currentWorkspace, hasWorkspace, openWorkspace, recentWorkspaces, loading } = useWorkspaceContext(); + const sshContext = useContext(SSHContext); + /** When SSH finishes connecting, re-run FlowChat init (first run may have skipped while disconnected). */ + const remoteSshFlowChatKey = + currentWorkspace?.workspaceKind === WorkspaceKind.Remote && currentWorkspace?.connectionId + ? sshContext?.workspaceStatuses[currentWorkspace.connectionId] ?? 'unknown' + : 'local'; const { isToolbarMode } = useToolbarModeContext(); const { ensureForWorkspace: ensureAssistantBootstrapForWorkspace } = useAssistantBootstrap(); @@ -178,7 +185,10 @@ const AppLayout: React.FC = ({ className = '' }) => { const flowChatManager = FlowChatManager.getInstance(); const hasHistoricalSessions = await flowChatManager.initialize( currentWorkspace.rootPath, - initializationPreferredMode + initializationPreferredMode, + currentWorkspace.workspaceKind === WorkspaceKind.Remote + ? currentWorkspace.connectionId + : undefined ); let sessionId: string | undefined; @@ -249,6 +259,8 @@ const AppLayout: React.FC = ({ className = '' }) => { currentWorkspace?.id, currentWorkspace?.rootPath, currentWorkspace?.workspaceKind, + currentWorkspace?.connectionId, + remoteSshFlowChatKey, ensureAssistantBootstrapForWorkspace, t, ]); diff --git a/src/web-ui/src/component-library/components/Input/Input.scss b/src/web-ui/src/component-library/components/Input/Input.scss index e352d976..e9e28b8f 100644 --- a/src/web-ui/src/component-library/components/Input/Input.scss +++ b/src/web-ui/src/component-library/components/Input/Input.scss @@ -136,8 +136,14 @@ position: relative; z-index: 1; + // Blend muted text toward page background so placeholders read as hints, not as user input + // (same token pair is set for light/dark in ThemeService). &::placeholder { - color: var(--color-text-muted, #a0a0a0); + color: color-mix( + in srgb, + var(--color-text-muted, #a0a0a0) 40%, + var(--color-bg-primary, #121214) + ); } &::selection { diff --git a/src/web-ui/src/component-library/preview/flowchat-cards-preview.css b/src/web-ui/src/component-library/preview/flowchat-cards-preview.css index 2c80b3a6..d2750c65 100644 --- a/src/web-ui/src/component-library/preview/flowchat-cards-preview.css +++ b/src/web-ui/src/component-library/preview/flowchat-cards-preview.css @@ -16,7 +16,7 @@ --tool-card-text-secondary: #a0a0a0; --tool-card-text-muted: #6b7280; - --tool-card-font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; + --tool-card-font-mono: 'SF Mono', 'Monaco', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', 'Lucida Console', 'Courier New', monospace; } .column-item-preview { diff --git a/src/web-ui/src/features/ssh-remote/PasswordInputDialog.tsx b/src/web-ui/src/features/ssh-remote/PasswordInputDialog.tsx deleted file mode 100644 index f52edd35..00000000 --- a/src/web-ui/src/features/ssh-remote/PasswordInputDialog.tsx +++ /dev/null @@ -1,119 +0,0 @@ -/** - * Password Input Dialog Component - * Custom modal for secure password/key passphrase input - */ - -import React, { useState, useRef, useEffect } from 'react'; -import { useI18n } from '@/infrastructure/i18n'; -import { Modal } from '@/component-library'; -import { Button } from '@/component-library'; -import { Input } from '@/component-library'; -import { Lock, Key, Loader2 } from 'lucide-react'; -import './PasswordInputDialog.scss'; - -interface PasswordInputDialogProps { - open: boolean; - title: string; - description?: string; - placeholder?: string; - isKeyPath?: boolean; - isConnecting?: boolean; - onSubmit: (value: string) => void; - onCancel: () => void; -} - -export const PasswordInputDialog: React.FC = ({ - open, - title, - description, - placeholder = '', - isKeyPath = false, - isConnecting = false, - onSubmit, - onCancel, -}) => { - const { t } = useI18n('common'); - const [value, setValue] = useState(''); - const inputRef = useRef(null); - - // Focus input when dialog opens - useEffect(() => { - if (open) { - setValue(''); - setTimeout(() => { - inputRef.current?.focus(); - }, 100); - } - }, [open]); - - const handleSubmit = () => { - if (value.trim()) { - onSubmit(value.trim()); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !isConnecting) { - e.preventDefault(); - handleSubmit(); - } - if (e.key === 'Escape') { - onCancel(); - } - }; - - return ( - -
- {description && ( -
-
- {isKeyPath ? : } -
- {description} -
- )} -
- setValue(e.target.value)} - onKeyDown={handleKeyDown} - placeholder={placeholder} - prefix={isKeyPath ? : } - size="medium" - disabled={isConnecting} - /> -
-
- - -
-
-
- ); -}; - -export default PasswordInputDialog; diff --git a/src/web-ui/src/features/ssh-remote/PasswordInputDialog.scss b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss similarity index 66% rename from src/web-ui/src/features/ssh-remote/PasswordInputDialog.scss rename to src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss index e8e32cce..67c7ffad 100644 --- a/src/web-ui/src/features/ssh-remote/PasswordInputDialog.scss +++ b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.scss @@ -1,8 +1,8 @@ /** - * Password Input Dialog Styles + * Unified SSH auth prompt dialog */ -.password-input-dialog { +.ssh-auth-prompt-dialog { padding: 8px 0; &__description { @@ -35,23 +35,39 @@ } } - &__input { - margin-bottom: 16px; + &__label { + display: block; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + margin-bottom: 6px; + } + + &__field { + margin-bottom: 14px; + } + + &__hint { + font-size: 13px; + color: var(--color-text-secondary); + margin: 0 0 16px; + line-height: 1.45; } &__actions { display: flex; justify-content: flex-end; gap: 12px; + margin-top: 8px; } &__spinner { - animation: spin 1s linear infinite; + animation: ssh-auth-spin 1s linear infinite; margin-right: 6px; } } -@keyframes spin { +@keyframes ssh-auth-spin { from { transform: rotate(0deg); } diff --git a/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx new file mode 100644 index 00000000..e6880210 --- /dev/null +++ b/src/web-ui/src/features/ssh-remote/SSHAuthPromptDialog.tsx @@ -0,0 +1,222 @@ +/** + * Unified SSH authentication prompt: password, private key, or SSH agent. + */ + +import React, { useState, useEffect, useRef } from 'react'; +import { useI18n } from '@/infrastructure/i18n'; +import { Modal } from '@/component-library'; +import { Button } from '@/component-library'; +import { Input } from '@/component-library'; +import { Select } from '@/component-library'; +import { Key, Loader2, Lock, Server, Terminal, User } from 'lucide-react'; +import type { SSHAuthMethod } from './types'; +import './SSHAuthPromptDialog.scss'; + +export interface SSHAuthPromptSubmitPayload { + auth: SSHAuthMethod; + /** When username is editable, the value from the dialog */ + username: string; +} + +interface SSHAuthPromptDialogProps { + open: boolean; + /** Shown in the header area (e.g. user@host:port or alias) */ + targetDescription: string; + defaultAuthMethod: 'password' | 'privateKey' | 'agent'; + defaultKeyPath?: string; + initialUsername: string; + /** If false, user can edit username (e.g. SSH config without User) */ + lockUsername: boolean; + isConnecting?: boolean; + onSubmit: (payload: SSHAuthPromptSubmitPayload) => void; + onCancel: () => void; +} + +export const SSHAuthPromptDialog: React.FC = ({ + open, + targetDescription, + defaultAuthMethod, + defaultKeyPath = '~/.ssh/id_rsa', + initialUsername, + lockUsername, + isConnecting = false, + onSubmit, + onCancel, +}) => { + const { t } = useI18n('common'); + const [authMethod, setAuthMethod] = useState<'password' | 'privateKey' | 'agent'>(defaultAuthMethod); + const [username, setUsername] = useState(initialUsername); + const [password, setPassword] = useState(''); + const [keyPath, setKeyPath] = useState(defaultKeyPath); + const [passphrase, setPassphrase] = useState(''); + const passwordRef = useRef(null); + + useEffect(() => { + if (!open) return; + setAuthMethod(defaultAuthMethod); + setUsername(initialUsername); + setPassword(''); + setKeyPath(defaultKeyPath); + setPassphrase(''); + const focusMs = window.setTimeout(() => { + if (defaultAuthMethod === 'password') { + passwordRef.current?.focus(); + } + }, 100); + return () => window.clearTimeout(focusMs); + }, [open, defaultAuthMethod, defaultKeyPath, initialUsername]); + + const authOptions = [ + { label: t('ssh.remote.password') || 'Password', value: 'password', icon: }, + { label: t('ssh.remote.privateKey') || 'Private Key', value: 'privateKey', icon: }, + { label: t('ssh.remote.sshAgent') || 'SSH Agent', value: 'agent', icon: }, + ]; + + const canSubmit = (): boolean => { + const u = username.trim(); + if (!u) return false; + if (authMethod === 'password') return password.length > 0; + if (authMethod === 'privateKey') return keyPath.trim().length > 0; + return true; + }; + + const handleSubmit = () => { + if (!canSubmit() || isConnecting) return; + const u = username.trim(); + let auth: SSHAuthMethod; + if (authMethod === 'password') { + auth = { type: 'Password', password }; + } else if (authMethod === 'privateKey') { + auth = { + type: 'PrivateKey', + keyPath: keyPath.trim(), + passphrase: passphrase.trim() || undefined, + }; + } else { + auth = { type: 'Agent' }; + } + onSubmit({ auth, username: u }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && canSubmit() && !isConnecting) { + e.preventDefault(); + handleSubmit(); + } + if (e.key === 'Escape') { + onCancel(); + } + }; + + return ( + +
+
+
+ +
+ {targetDescription} +
+ + {!lockUsername && ( +
+ setUsername(e.target.value)} + placeholder="root" + prefix={} + size="medium" + disabled={isConnecting} + /> +
+ )} + +
+ + setPassword(e.target.value)} + prefix={} + size="medium" + disabled={isConnecting} + /> +
+ )} + + {authMethod === 'privateKey' && ( + <> +
+ setKeyPath(e.target.value)} + placeholder="~/.ssh/id_rsa" + prefix={} + size="medium" + disabled={isConnecting} + /> +
+
+ setPassphrase(e.target.value)} + placeholder={t('ssh.remote.passphraseOptional')} + size="medium" + disabled={isConnecting} + /> +
+ + )} + + {authMethod === 'agent' && ( +

{t('ssh.remote.authPromptAgentHint')}

+ )} + +
+ + +
+
+
+ ); +}; + +export default SSHAuthPromptDialog; diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx index 6c4629d1..16070d18 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.tsx @@ -6,7 +6,7 @@ import React, { useState, useEffect } from 'react'; import { useI18n } from '@/infrastructure/i18n'; import { useSSHRemoteContext } from './SSHRemoteProvider'; -import { PasswordInputDialog } from './PasswordInputDialog'; +import { SSHAuthPromptDialog, type SSHAuthPromptSubmitPayload } from './SSHAuthPromptDialog'; import { Modal } from '@/component-library'; import { Button } from '@/component-library'; import { Input } from '@/component-library'; @@ -22,10 +22,14 @@ import type { import { sshApi } from './sshApi'; import './SSHConnectionDialog.scss'; -interface PasswordPromptState { - type: 'password' | 'keyPath'; - savedConnection: SavedConnection; -} +type CredentialsPromptState = + | { kind: 'saved'; connection: SavedConnection } + | { + kind: 'sshConfig'; + entry: SSHConfigEntry; + connectHost: string; + port: number; + }; interface SSHConnectionDialogProps { open: boolean; @@ -42,7 +46,7 @@ export const SSHConnectionDialog: React.FC = ({ const [sshConfigHosts, setSSHConfigHosts] = useState([]); const [localError, setLocalError] = useState(null); const [isConnecting, setIsConnecting] = useState(false); - const [passwordPrompt, setPasswordPrompt] = useState(null); + const [credentialsPrompt, setCredentialsPrompt] = useState(null); const error = localError || connectionError; @@ -164,10 +168,22 @@ export const SSHConnectionDialog: React.FC = ({ return; } + const hostInput = formData.host.trim(); + let connectHost = hostInput; + try { + const lookup = await sshApi.getSSHConfig(hostInput); + const resolved = lookup.found && lookup.config?.hostname?.trim(); + if (resolved) { + connectHost = resolved; + } + } catch { + // Use hostInput if ~/.ssh/config cannot be read + } + const config: SSHConnectionConfig = { - id: generateConnectionId(formData.host.trim(), port, formData.username.trim()), - name: formData.name || `${formData.username}@${formData.host}`, - host: formData.host.trim(), + id: generateConnectionId(connectHost, port, formData.username.trim()), + name: formData.name || `${formData.username}@${hostInput}`, + host: connectHost, port, username: formData.username.trim(), auth: buildAuthMethod(), @@ -190,7 +206,7 @@ export const SSHConnectionDialog: React.FC = ({ setLocalError(null); if (conn.authType.type === 'Password') { - setPasswordPrompt({ type: 'password', savedConnection: conn }); + setCredentialsPrompt({ kind: 'saved', connection: conn }); } else { const auth: SSHAuthMethod = conn.authType.type === 'PrivateKey' ? { type: 'PrivateKey', keyPath: conn.authType.keyPath } @@ -223,14 +239,20 @@ export const SSHConnectionDialog: React.FC = ({ const username = configHost.user || ''; const port = configHost.port || 22; - // Deterministic ID based on host:port:username - const connectionId = generateConnectionId(hostname, port, username); + const hasValidIdentityFile = configHost.identityFile && configHost.identityFile.trim() !== ''; - // Build connection name - use alias as name - const name = configHost.host; + if (!hasValidIdentityFile) { + setCredentialsPrompt({ + kind: 'sshConfig', + entry: configHost, + connectHost: hostname, + port, + }); + return; + } - // Determine auth method - only use private key if identityFile is valid - const hasValidIdentityFile = configHost.identityFile && configHost.identityFile.trim() !== ''; + const connectionId = generateConnectionId(hostname, port, username); + const name = configHost.host; const authConfig: SSHConnectionConfig = { id: connectionId, @@ -238,9 +260,7 @@ export const SSHConnectionDialog: React.FC = ({ host: hostname, port, username, - auth: hasValidIdentityFile - ? { type: 'PrivateKey', keyPath: configHost.identityFile! } - : { type: 'Agent' }, + auth: { type: 'PrivateKey', keyPath: configHost.identityFile! }, }; setIsConnecting(true); @@ -253,49 +273,65 @@ export const SSHConnectionDialog: React.FC = ({ } }; - const handlePasswordPromptSubmit = async (value: string) => { - if (!passwordPrompt) return; + const credentialsTargetDescription = (state: CredentialsPromptState): string => { + if (state.kind === 'saved') { + const c = state.connection; + return `${c.username}@${c.host}:${c.port}`; + } + const { entry, connectHost, port } = state; + const u = entry.user?.trim(); + const base = u ? `${u}@${connectHost}:${port}` : `${connectHost}:${port}`; + if (entry.host && entry.host !== connectHost) { + return `${base} (${entry.host})`; + } + return base; + }; - const conn = passwordPrompt.savedConnection; - // Don't clear passwordPrompt yet - keep dialog open during connection + const handleCredentialsPromptSubmit = async (payload: SSHAuthPromptSubmitPayload) => { + if (!credentialsPrompt) return; + const { auth, username: resolvedUsername } = payload; setIsConnecting(true); setLocalError(null); try { - if (passwordPrompt.type === 'password') { - await connect(conn.id, { + if (credentialsPrompt.kind === 'saved') { + const conn = credentialsPrompt.connection; + const full: SSHConnectionConfig = { id: conn.id, name: conn.name, host: conn.host, port: conn.port, - username: conn.username, - auth: { type: 'Password', password: value }, - }); + username: resolvedUsername, + auth, + }; + await connect(conn.id, full); + await sshApi.saveConnection(full); } else { - await connect(conn.id, { - id: conn.id, - name: conn.name, - host: conn.host, - port: conn.port, - username: conn.username, - auth: { type: 'PrivateKey', keyPath: value }, - }); + const { entry, connectHost, port } = credentialsPrompt; + const connectionId = generateConnectionId(connectHost, port, resolvedUsername); + const full: SSHConnectionConfig = { + id: connectionId, + name: entry.host, + host: connectHost, + port, + username: resolvedUsername, + auth, + }; + await connect(connectionId, full); + await sshApi.saveConnection(full); + await loadSavedConnections(); } - // Success - clear password prompt - setPasswordPrompt(null); - // Close the main dialog - connect() sets showConnectionDialog(false) internally - // but for reconnection we need to also close via onClose + setCredentialsPrompt(null); onClose(); } catch (e) { - // Keep password prompt visible so user can retry setLocalError(e instanceof Error ? e.message : 'Connection failed'); } finally { setIsConnecting(false); } }; - const handlePasswordPromptCancel = () => { - setPasswordPrompt(null); + const handleCredentialsPromptCancel = () => { + setCredentialsPrompt(null); setLocalError(null); }; @@ -622,19 +658,28 @@ export const SSHConnectionDialog: React.FC = ({ - {/* Password/Key Path Input Dialog */} - {passwordPrompt && ( - )} diff --git a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx index 11e0f720..61eb4276 100644 --- a/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHRemoteProvider.tsx @@ -9,6 +9,7 @@ import { WorkspaceKind } from '@/shared/types/global-state'; import type { SSHConnectionConfig, RemoteWorkspace } from './types'; import { sshApi } from './sshApi'; import { flowChatStore } from '@/flow_chat/store/FlowChatStore'; +import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; const log = createLogger('SSHRemoteProvider'); @@ -210,21 +211,27 @@ export const SSHRemoteProvider: React.FC = ({ children } // Ignore } - // Build a deduplicated list keyed by remotePath + // Key by connection + path so two servers at the same remote path stay distinct. + const remoteWorkspaceDedupKey = (cid: string, rp: string) => `${cid}\n${rp}`; const toReconnect = new Map(); for (const ws of openedRemote) { if (!ws.connectionId) continue; - toReconnect.set(ws.rootPath, { + const rp = normalizeRemoteWorkspacePath(ws.rootPath); + toReconnect.set(remoteWorkspaceDedupKey(ws.connectionId, rp), { connectionId: ws.connectionId, connectionName: ws.connectionName || 'Remote', - remotePath: ws.rootPath, + remotePath: rp, }); } // Add legacy workspace if it isn't already covered - if (legacyWorkspace && !toReconnect.has(legacyWorkspace.remotePath)) { - toReconnect.set(legacyWorkspace.remotePath, legacyWorkspace); + if (legacyWorkspace?.connectionId) { + const leg = normalizeRemoteWorkspacePath(legacyWorkspace.remotePath); + const k = remoteWorkspaceDedupKey(legacyWorkspace.connectionId, leg); + if (!toReconnect.has(k)) { + toReconnect.set(k, { ...legacyWorkspace, remotePath: leg }); + } } if (toReconnect.size === 0) { @@ -243,7 +250,11 @@ export const SSHRemoteProvider: React.FC = ({ children } // ── Process each workspace ────────────────────────────────────────── for (const [, workspace] of toReconnect) { - const isAlreadyOpened = openedRemote.some(ws => ws.rootPath === workspace.remotePath); + const isAlreadyOpened = openedRemote.some( + ws => + normalizeRemoteWorkspacePath(ws.rootPath) === + normalizeRemoteWorkspacePath(workspace.remotePath) + ); // Check if SSH is already live const alreadyConnected = await sshApi.isConnected(workspace.connectionId).catch(() => false); @@ -417,11 +428,12 @@ export const SSHRemoteProvider: React.FC = ({ children } throw new Error('Not connected'); } const connName = connectionConfig?.name || 'Remote'; - await sshApi.openWorkspace(connectionId, pingPath); + const remotePath = normalizeRemoteWorkspacePath(pingPath); + await sshApi.openWorkspace(connectionId, remotePath); const remoteWs = { connectionId, connectionName: connName, - remotePath: pingPath, + remotePath, }; setRemoteWorkspace(remoteWs); setShowFileBrowser(false); diff --git a/src/web-ui/src/features/ssh-remote/index.ts b/src/web-ui/src/features/ssh-remote/index.ts index 72e6aa65..7f98024b 100644 --- a/src/web-ui/src/features/ssh-remote/index.ts +++ b/src/web-ui/src/features/ssh-remote/index.ts @@ -6,6 +6,6 @@ export * from './types'; export * from './sshApi'; export { SSHConnectionDialog } from './SSHConnectionDialog'; export { RemoteFileBrowser } from './RemoteFileBrowser'; -export { PasswordInputDialog } from './PasswordInputDialog'; +export { SSHAuthPromptDialog } from './SSHAuthPromptDialog'; export { ConfirmDialog } from './ConfirmDialog'; export { SSHRemoteProvider, useSSHRemoteContext } from './SSHRemoteProvider'; diff --git a/src/web-ui/src/flow_chat/services/BtwThreadService.ts b/src/web-ui/src/flow_chat/services/BtwThreadService.ts index 1fe6a270..5e85b1f7 100644 --- a/src/web-ui/src/flow_chat/services/BtwThreadService.ts +++ b/src/web-ui/src/flow_chat/services/BtwThreadService.ts @@ -36,14 +36,15 @@ function buildChildSessionName(question: string): string { async function loadSessionMetadataWithRetry( sessionId: string, workspacePath: string, - opts?: { retries?: number; delayMs?: number } + opts?: { retries?: number; delayMs?: number }, + remoteConnectionId?: string ): Promise { const retries = opts?.retries ?? 10; const delayMs = opts?.delayMs ?? 60; for (let i = 0; i < retries; i++) { try { - const meta = await sessionAPI.loadSessionMetadata(sessionId, workspacePath); + const meta = await sessionAPI.loadSessionMetadata(sessionId, workspacePath, remoteConnectionId); if (meta) return meta; } catch (e) { // Ignore and retry; persistence write can lag behind create_session event. @@ -93,11 +94,13 @@ export async function startBtwThread(params: { const agentType = parentSession?.mode || 'agentic'; const modelName = parentSession?.config?.modelName || 'default'; const childSessionName = buildChildSessionName(question); + const remoteConnectionId = parentSession?.remoteConnectionId; const created = await agentAPI.createSession({ sessionName: childSessionName, agentType, workspacePath, + remoteConnectionId, config: { modelName, enableTools: false, @@ -109,16 +112,23 @@ export async function startBtwThread(params: { const childSessionId = created.sessionId; // Ensure the child session exists in the store even if the backend SessionCreated event is delayed. - flowChatStore.addExternalSession(childSessionId, childSessionName, agentType, workspacePath, { - parentSessionId, - sessionKind: 'btw', - btwOrigin: { - requestId, + flowChatStore.addExternalSession( + childSessionId, + childSessionName, + agentType, + workspacePath, + { parentSessionId, - parentDialogTurnId, - parentTurnIndex, + sessionKind: 'btw', + btwOrigin: { + requestId, + parentSessionId, + parentDialogTurnId, + parentTurnIndex, + }, }, - }); + remoteConnectionId + ); flowChatStore.updateSessionRelationship(childSessionId, { parentSessionId, sessionKind: 'btw' }); flowChatStore.updateSessionBtwOrigin(childSessionId, { requestId, @@ -195,14 +205,15 @@ export async function startBtwThread(params: { flowChatStore.addDialogTurn(childSessionId, childTurn); // Persist child session metadata (parent linkage) to disk. - const meta = await loadSessionMetadataWithRetry(childSessionId, workspacePath); + const meta = await loadSessionMetadataWithRetry(childSessionId, workspacePath, undefined, remoteConnectionId); if (meta) { const childSession = flowChatStore.getState().sessions.get(childSessionId); if (childSession) { await sessionAPI.saveSessionMetadata( buildSessionMetadata(childSession, meta), - workspacePath + workspacePath, + remoteConnectionId ); } } diff --git a/src/web-ui/src/flow_chat/services/FlowChatManager.ts b/src/web-ui/src/flow_chat/services/FlowChatManager.ts index a7d44d65..dc7d1c94 100644 --- a/src/web-ui/src/flow_chat/services/FlowChatManager.ts +++ b/src/web-ui/src/flow_chat/services/FlowChatManager.ts @@ -70,20 +70,33 @@ export class FlowChatManager { return FlowChatManager.instance; } - async initialize(workspacePath: string, preferredMode?: string): Promise { + async initialize( + workspacePath: string, + preferredMode?: string, + remoteConnectionId?: string + ): Promise { try { await this.initializeEventListeners(); - await this.context.flowChatStore.initializeFromDisk(workspacePath); + await this.context.flowChatStore.initializeFromDisk(workspacePath, remoteConnectionId); + + const wsConn = remoteConnectionId?.trim() ?? ''; + const sessionMatchesWorkspace = (session: { workspacePath?: string; remoteConnectionId?: string }) => { + if ((session.workspacePath || workspacePath) !== workspacePath) return false; + const sc = session.remoteConnectionId?.trim() ?? ''; + if (wsConn.length > 0 || sc.length > 0) { + return sc === wsConn; + } + return true; + }; const state = this.context.flowChatStore.getState(); - const workspaceSessions = Array.from(state.sessions.values()) - .filter(session => (session.workspacePath || workspacePath) === workspacePath); + const workspaceSessions = Array.from(state.sessions.values()).filter(sessionMatchesWorkspace); const hasHistoricalSessions = workspaceSessions.length > 0; const activeSession = state.activeSessionId ? state.sessions.get(state.activeSessionId) ?? null : null; - const activeSessionBelongsToWorkspace = !!activeSession && - (activeSession.workspacePath || workspacePath) === workspacePath; + const activeSessionBelongsToWorkspace = + !!activeSession && sessionMatchesWorkspace(activeSession); if (hasHistoricalSessions && !activeSessionBelongsToWorkspace) { const sortedWorkspaceSessions = [...workspaceSessions].sort(compareSessionsForDisplay); @@ -103,7 +116,12 @@ export class FlowChatManager { } if (latestSession.isHistorical) { - await this.context.flowChatStore.loadSessionHistory(latestSession.sessionId, workspacePath); + await this.context.flowChatStore.loadSessionHistory( + latestSession.sessionId, + workspacePath, + undefined, + latestSession.remoteConnectionId + ); } this.context.flowChatStore.switchSession(latestSession.sessionId); @@ -158,9 +176,15 @@ export class FlowChatManager { preferredMode?: string; /** After reinit, ask core to run assistant bootstrap if BOOTSTRAP.md is present (e.g. workspace reset). */ ensureAssistantBootstrap?: boolean; + /** When set, only removes/reinits sessions for this SSH connection (same path, different hosts). */ + remoteConnectionId?: string | null; } ): Promise { - const removedSessionIds = this.context.flowChatStore.removeSessionsByWorkspace(workspacePath); + const remoteConnectionId = options?.remoteConnectionId; + const removedSessionIds = this.context.flowChatStore.removeSessionsByWorkspace( + workspacePath, + remoteConnectionId + ); removedSessionIds.forEach(sessionId => { stateMachineManager.delete(sessionId); @@ -173,17 +197,35 @@ export class FlowChatManager { return; } - const hasHistoricalSessions = await this.initialize(workspacePath, options.preferredMode); + const hasHistoricalSessions = await this.initialize( + workspacePath, + options.preferredMode, + remoteConnectionId ?? undefined + ); const state = this.context.flowChatStore.getState(); const activeSession = state.activeSessionId ? state.sessions.get(state.activeSessionId) ?? null : null; + const wsConn = remoteConnectionId?.trim() ?? ''; const hasActiveWorkspaceSession = !!activeSession && - (activeSession.workspacePath || workspacePath) === workspacePath; + (activeSession.workspacePath || workspacePath) === workspacePath && + (() => { + const sc = activeSession.remoteConnectionId?.trim() ?? ''; + if (wsConn.length > 0 || sc.length > 0) { + return sc === wsConn; + } + return true; + })(); if (!hasHistoricalSessions || !hasActiveWorkspaceSession) { - await this.createChatSession({}, options.preferredMode); + await this.createChatSession( + { + workspacePath, + ...(remoteConnectionId ? { remoteConnectionId } : {}), + }, + options.preferredMode + ); } if (options?.ensureAssistantBootstrap) { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts index 10db236e..84bbf304 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/PersistenceModule.ts @@ -233,7 +233,7 @@ async function performSaveDialogTurnToDisk( const turnIndex = dialogTurn.backendTurnIndex ?? session.dialogTurns.indexOf(dialogTurn); const turnData = convertDialogTurnToBackendFormat(dialogTurn, turnIndex); - await sessionAPI.saveSessionTurn(turnData, workspacePath); + await sessionAPI.saveSessionTurn(turnData, workspacePath, session.remoteConnectionId); await updateSessionMetadata(context, sessionId); @@ -404,14 +404,18 @@ export async function updateSessionMetadata( let existingMetadata: any = null; try { - existingMetadata = await sessionAPI.loadSessionMetadata(sessionId, workspacePath); + existingMetadata = await sessionAPI.loadSessionMetadata( + sessionId, + workspacePath, + session.remoteConnectionId + ); } catch { // ignore } const metadata = buildSessionMetadata(session, existingMetadata); - await sessionAPI.saveSessionMetadata(metadata, workspacePath); + await sessionAPI.saveSessionMetadata(metadata, workspacePath, session.remoteConnectionId); } catch (error) { log.warn('Failed to update session metadata', { sessionId, error }); } @@ -420,10 +424,18 @@ export async function updateSessionMetadata( /** * Update session activity time (used for session switching) */ -export async function touchSessionActivity(sessionId: string, workspacePath?: string): Promise { +export async function touchSessionActivity( + sessionId: string, + workspacePath?: string, + remoteConnectionId?: string +): Promise { try { const { sessionAPI } = await import('@/infrastructure/api'); - await sessionAPI.touchSessionActivity(sessionId, requireWorkspacePath(sessionId, workspacePath)); + await sessionAPI.touchSessionActivity( + sessionId, + requireWorkspacePath(sessionId, workspacePath), + remoteConnectionId + ); } catch (error) { log.debug('Failed to touch session activity', { sessionId, error }); } diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 0145d16f..00333a65 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -8,6 +8,7 @@ import { notificationService } from '../../../shared/notification-system'; import { createLogger } from '@/shared/utils/logger'; import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; import type { FlowChatContext, SessionConfig } from './types'; import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; @@ -41,7 +42,20 @@ const resolveSessionWorkspacePath = ( if (explicitWorkspacePath) { return explicitWorkspacePath; } - return context.currentWorkspacePath || null; + const fromFlowChat = context.currentWorkspacePath?.trim(); + if (fromFlowChat) { + return fromFlowChat; + } + // Remote restore: AppLayout may skip FlowChat.initialize until SSH connects, so + // currentWorkspacePath stays null while global workspace already has rootPath. + const current = workspaceManager.getState().currentWorkspace; + const root = current?.rootPath?.trim(); + if (!root) { + return null; + } + return current?.workspaceKind === WorkspaceKind.Remote + ? normalizeRemoteWorkspacePath(root) + : root; }; const resolveSessionWorkspace = ( @@ -52,10 +66,25 @@ const resolveSessionWorkspace = ( if (!workspacePath) return null; const state = workspaceManager.getState(); - const matchedWorkspace = Array.from(state.openedWorkspaces.values()).find( + const pathMatches = Array.from(state.openedWorkspaces.values()).filter( workspace => workspace.rootPath === workspacePath ); - return matchedWorkspace || state.currentWorkspace; + if (pathMatches.length === 0) { + return state.currentWorkspace; + } + if (pathMatches.length === 1) { + return pathMatches[0]; + } + const configCid = config?.remoteConnectionId?.trim(); + if (configCid) { + const byConn = pathMatches.find(w => w.connectionId === configCid); + if (byConn) return byConn; + } + const cur = state.currentWorkspace; + if (cur && pathMatches.some(w => w.id === cur.id)) { + return cur; + } + return pathMatches[0]; }; const resolveAgentType = ( @@ -126,9 +155,14 @@ export async function createChatSession( if (!workspacePath) { throw new Error('Workspace path is required to create a session'); } + const remoteConnectionId = + workspace?.workspaceKind === WorkspaceKind.Remote ? workspace.connectionId : undefined; const agentType = resolveAgentType(mode, workspace); const sessionMode = normalizeSessionDisplayMode(agentType, workspace); - const creationKey = workspacePath; + const creationKey = + remoteConnectionId != null && remoteConnectionId !== '' + ? `${remoteConnectionId}\n${workspacePath}` + : workspacePath; const pendingCreation = pendingSessionCreations.get(creationKey); if (pendingCreation) { @@ -153,6 +187,7 @@ export async function createChatSession( sessionName, agentType, workspacePath, + remoteConnectionId, config: { modelName: config.modelName || 'auto', enableTools: true, @@ -170,7 +205,8 @@ export async function createChatSession( sessionName, maxContextTokens, agentType, - workspacePath + workspacePath, + remoteConnectionId ); return response.sessionId; @@ -208,10 +244,15 @@ export async function switchChatSession( try { const workspacePath = requireSessionWorkspacePath(session.workspacePath, sessionId); - await context.flowChatStore.loadSessionHistory(sessionId, workspacePath); + await context.flowChatStore.loadSessionHistory( + sessionId, + workspacePath, + undefined, + session.remoteConnectionId + ); try { - await agentAPI.restoreSession(sessionId, workspacePath); + await agentAPI.restoreSession(sessionId, workspacePath, session.remoteConnectionId); context.flowChatStore.setState(prev => { const newSessions = new Map(prev.sessions); @@ -230,6 +271,7 @@ export async function switchChatSession( sessionName: currentSession.title || `Session ${sessionId.slice(0, 8)}`, agentType: currentSession.mode || 'agentic', workspacePath, + remoteConnectionId: currentSession.remoteConnectionId, config: { modelName: currentSession.config.modelName || 'auto', enableTools: true, @@ -257,7 +299,11 @@ export async function switchChatSession( context.flowChatStore.switchSession(sessionId); - touchSessionActivity(sessionId, session?.workspacePath).catch(error => { + touchSessionActivity( + sessionId, + session?.workspacePath, + session?.remoteConnectionId + ).catch(error => { log.debug('Failed to touch session activity', { sessionId, error }); }); } catch (error) { @@ -303,41 +349,59 @@ export async function ensureBackendSession( if (!session) { throw new Error(`Session does not exist: ${sessionId}`); } - + + const workspacePath = requireSessionWorkspacePath(session.workspacePath, sessionId); + const isHistoricalSession = session.isHistorical === true; const isFirstTurn = session.dialogTurns.length <= 1; const needsBackendSetup = isHistoricalSession || isFirstTurn; - - if (needsBackendSetup) { - const workspacePath = requireSessionWorkspacePath(session.workspacePath, sessionId); + /** Avoid createSession when historical data is already loaded but backend files are missing (e.g. new SSH connection id). */ + const allowRecreateOnCoordinatorFailure = + needsBackendSetup && !(isHistoricalSession && session.dialogTurns.length > 1); - try { - await agentAPI.restoreSession(sessionId, workspacePath); - - if (isHistoricalSession) { - context.flowChatStore.setState(prev => { - const newSessions = new Map(prev.sessions); - const sess = newSessions.get(sessionId); - if (sess) { - newSessions.set(sessionId, { ...sess, isHistorical: false }); - } - return { ...prev, sessions: newSessions }; - }); + const clearHistoricalFlag = () => { + if (!isHistoricalSession) return; + context.flowChatStore.setState(prev => { + const newSessions = new Map(prev.sessions); + const sess = newSessions.get(sessionId); + if (sess) { + newSessions.set(sessionId, { ...sess, isHistorical: false }); } - } catch (restoreError: any) { - log.debug('Session restore failed, creating new session', { sessionId, error: restoreError }); - await agentAPI.createSession({ - sessionId: sessionId, - sessionName: session.title || `Session ${sessionId.slice(0, 8)}`, - agentType: session.mode || 'agentic', - workspacePath, - config: { - modelName: session.config.modelName || 'auto', - enableTools: true, - safeMode: true - } - }); + return { ...prev, sessions: newSessions }; + }); + }; + + try { + await agentAPI.ensureCoordinatorSession({ + sessionId, + workspacePath, + remoteConnectionId: session.remoteConnectionId, + }); + clearHistoricalFlag(); + } catch (e: any) { + if (!allowRecreateOnCoordinatorFailure) { + const raw = typeof e?.message === 'string' ? e.message : String(e); + const hint = + raw.includes('Session metadata not found') || raw.includes('Not found') + ? '在后端找不到该会话数据。若刚重新连接过 SSH 远程工作区,请关闭并重新打开该远程项目,或新建会话后再试。' + : raw; + throw new Error(hint); } + + log.debug('Coordinator session missing, creating backend session', { sessionId, error: e }); + await agentAPI.createSession({ + sessionId: sessionId, + sessionName: session.title || `Session ${sessionId.slice(0, 8)}`, + agentType: session.mode || 'agentic', + workspacePath, + remoteConnectionId: session.remoteConnectionId, + config: { + modelName: session.config.modelName || 'auto', + enableTools: true, + safeMode: true + } + }); + clearHistoricalFlag(); } } @@ -360,6 +424,7 @@ export async function retryCreateBackendSession( sessionName: session.title || `Session ${sessionId.slice(0, 8)}`, agentType: session.mode || 'agentic', workspacePath, + remoteConnectionId: session.remoteConnectionId, config: { modelName: session.config.modelName || 'auto', enableTools: true, diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 20a66ce9..1bb5c3d8 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -174,7 +174,8 @@ export class FlowChatStore { title?: string, maxContextTokens?: number, mode?: string, - workspacePath?: string + workspacePath?: string, + remoteConnectionId?: string ): void { import('../state-machine').then(({ stateMachineManager }) => { stateMachineManager.getOrCreate(sessionId); @@ -196,6 +197,7 @@ export class FlowChatStore { maxContextTokens: maxContextTokens || 128128, mode: mode || 'agentic', workspacePath, + remoteConnectionId, parentSessionId: relationship.parentSessionId, sessionKind: relationship.sessionKind, btwThreads: [], @@ -222,7 +224,8 @@ export class FlowChatStore { title: string, mode: string, workspacePath?: string, - meta?: { parentSessionId?: string; sessionKind?: SessionKind; btwOrigin?: Session['btwOrigin'] } + meta?: { parentSessionId?: string; sessionKind?: SessionKind; btwOrigin?: Session['btwOrigin'] }, + remoteConnectionId?: string ): void { import('../state-machine').then(({ stateMachineManager }) => { stateMachineManager.getOrCreate(sessionId); @@ -249,6 +252,7 @@ export class FlowChatStore { mode: mode || 'agentic', isHistorical: false, workspacePath, + remoteConnectionId, parentSessionId: relationship.parentSessionId, sessionKind: relationship.sessionKind, btwThreads: [], @@ -631,9 +635,17 @@ export class FlowChatStore { }); } - public removeSessionsByWorkspace(workspacePath: string): string[] { + public removeSessionsByWorkspace(workspacePath: string, remoteConnectionId?: string | null): string[] { + const wsConn = remoteConnectionId?.trim() ?? ''; const removedSessionIds = Array.from(this.state.sessions.values()) - .filter(session => session.workspacePath === workspacePath) + .filter(session => { + if (session.workspacePath !== workspacePath) return false; + const sc = session.remoteConnectionId?.trim() ?? ''; + if (wsConn.length > 0 || sc.length > 0) { + return sc === wsConn; + } + return true; + }) .map(session => session.sessionId); if (removedSessionIds.length === 0) { @@ -1238,10 +1250,18 @@ export class FlowChatStore { return; } - const metadata = await sessionAPI.loadSessionMetadata(sessionId, workspacePath); + const metadata = await sessionAPI.loadSessionMetadata( + sessionId, + workspacePath, + session.remoteConnectionId + ); const nextMetadata = buildSessionMetadata(session, metadata); - await sessionAPI.saveSessionMetadata(nextMetadata, workspacePath); + await sessionAPI.saveSessionMetadata( + nextMetadata, + workspacePath, + session.remoteConnectionId + ); } catch (error) { log.error('Failed to sync session title', { sessionId, error }); } @@ -1431,7 +1451,7 @@ export class FlowChatStore { status: 'cancelled' as const }; - await sessionAPI.saveSessionTurn(turnData, workspacePath); + await sessionAPI.saveSessionTurn(turnData, workspacePath, session.remoteConnectionId); } catch (error) { log.error('Failed to save cancelled dialog turn', { sessionId, turnId, error }); } @@ -1442,10 +1462,10 @@ export class FlowChatStore { * Initialize by loading persisted session metadata from disk * Clears sessions from other workspaces, then loads sessions for the target workspace. */ - public async initializeFromDisk(workspacePath: string): Promise { + public async initializeFromDisk(workspacePath: string, remoteConnectionId?: string): Promise { try { const { sessionAPI } = await import('@/infrastructure/api'); - const sessions = await sessionAPI.listSessions(workspacePath); + const sessions = await sessionAPI.listSessions(workspacePath, remoteConnectionId); const { stateMachineManager } = await import('../state-machine'); sessions.forEach(metadata => { @@ -1520,6 +1540,8 @@ export class FlowChatStore { maxContextTokens, mode: validatedAgentType, workspacePath: (metadata as any).workspacePath || workspacePath, + remoteConnectionId: + (metadata as any).remoteConnectionId || remoteConnectionId, parentSessionId: relationship.parentSessionId, sessionKind: relationship.sessionKind, btwThreads: [], @@ -1548,7 +1570,8 @@ export class FlowChatStore { public async loadSessionHistory( sessionId: string, workspacePath: string, - limit?: number + limit?: number, + remoteConnectionId?: string ): Promise { try { const { stateMachineManager } = await import('../state-machine'); @@ -1556,7 +1579,7 @@ export class FlowChatStore { try { const { agentAPI } = await import('@/infrastructure/api'); - await agentAPI.restoreSession(sessionId, workspacePath); + await agentAPI.restoreSession(sessionId, workspacePath, remoteConnectionId); } catch (error) { log.warn('Backend session restore failed (may be new session)', { sessionId, error }); } @@ -1565,7 +1588,8 @@ export class FlowChatStore { const turns = await sessionAPI.loadSessionTurns( sessionId, workspacePath, - limit + limit, + remoteConnectionId ); const dialogTurns = this.convertToDialogTurns(turns); diff --git a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss index 3fc59513..dbd0f203 100644 --- a/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss +++ b/src/web-ui/src/flow_chat/tool-cards/ModelThinkingDisplay.scss @@ -86,7 +86,9 @@ .thinking-content { font-size: 12px; line-height: 1.4; - font-family: var(--tool-card-font-mono); + /* Match FlowChat body text (sans); mono stack + generic monospace yields Songti-like CJK on Windows. */ + font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', + sans-serif); word-break: break-word; color: var(--tool-card-text-muted); padding: 10px 12px; @@ -180,11 +182,14 @@ } } -/* Markdown body: keep muted monospace look (overrides default .markdown-renderer) */ +/* Markdown body: same sans as rest of chat; code stays mono via --markdown-font-mono */ .thinking-content .markdown-renderer.thinking-markdown { --markdown-font-mono: var(--tool-card-font-mono); + --markdown-font-heading: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', + 'Microsoft YaHei', sans-serif); - font-family: var(--tool-card-font-mono); + font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', + sans-serif); font-size: 12px; line-height: 1.45; color: var(--tool-card-text-muted); @@ -196,7 +201,8 @@ h4, h5, h6 { - font-family: var(--tool-card-font-mono); + font-family: var(--font-family-sans, 'Noto Sans SC', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Microsoft YaHei', + sans-serif); font-weight: 600; color: var(--tool-card-text-secondary); margin-top: 0.65rem; diff --git a/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss b/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss index adfe08fe..1471c314 100644 --- a/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss +++ b/src/web-ui/src/flow_chat/tool-cards/_tool-card-common.scss @@ -57,5 +57,9 @@ /* CSS variables */ :root { - --tool-card-font-mono: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Fira Mono', 'Droid Sans Mono', 'Source Code Pro', monospace; + /* Prefer explicit Windows/Linux fonts before generic `monospace` — on zh-CN Windows, + * bare `monospace` often resolves to NSimSun (Songti-like) for CJK. */ + --tool-card-font-mono: + 'SF Mono', 'Monaco', 'Cascadia Code', 'Cascadia Mono', Consolas, 'Inconsolata', 'Fira Code', 'Fira Mono', + 'Droid Sans Mono', 'Source Code Pro', 'Lucida Console', 'Courier New', monospace; } diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index 17be83d9..03cb7d4e 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -187,6 +187,9 @@ export interface Session { // Sessions are always kept in store for event processing; only display is filtered. workspacePath?: string; + /** SSH remote: same `workspacePath` on different hosts must not share coordinator/persistence. */ + remoteConnectionId?: string; + /** * Optional parent session id for hierarchical sessions. * Used by /btw "side threads" and potentially other derived sessions. @@ -229,6 +232,8 @@ export interface SessionConfig { agentType?: string; context?: Record; workspacePath?: string; + /** Disambiguates sessions when multiple remote workspaces share the same `workspacePath`. */ + remoteConnectionId?: string; } export interface QueuedMessage { diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 53479050..5bb823a7 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -23,6 +23,7 @@ export interface SessionConfig { maxTurns?: number; enableContextCompression?: boolean; compressionThreshold?: number; + remoteConnectionId?: string; } @@ -31,6 +32,7 @@ export interface CreateSessionRequest { sessionName: string; agentType: string; workspacePath: string; + remoteConnectionId?: string; config?: SessionConfig; } @@ -210,10 +212,10 @@ export class AgentAPI { } - async deleteSession(sessionId: string, workspacePath: string): Promise { + async deleteSession(sessionId: string, workspacePath: string, remoteConnectionId?: string): Promise { try { await api.invoke('delete_session', { - request: { sessionId, workspacePath } + request: { sessionId, workspacePath, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('delete_session', error, { sessionId, workspacePath }); @@ -221,14 +223,32 @@ export class AgentAPI { } - async restoreSession(sessionId: string, workspacePath: string): Promise { + async restoreSession(sessionId: string, workspacePath: string, remoteConnectionId?: string): Promise { try { - return await api.invoke('restore_session', { request: { sessionId, workspacePath } }); + return await api.invoke('restore_session', { + request: { sessionId, workspacePath, remoteConnectionId }, + }); } catch (error) { throw createTauriCommandError('restore_session', error, { sessionId, workspacePath }); } } + /** + * No-op if the session is already in the coordinator; otherwise loads it from disk + * using the same workspace path resolution as restore_session (required for SSH remote workspaces). + */ + async ensureCoordinatorSession(request: { + sessionId: string; + workspacePath: string; + remoteConnectionId?: string; + }): Promise { + try { + await api.invoke('ensure_coordinator_session', { request }); + } catch (error) { + throw createTauriCommandError('ensure_coordinator_session', error, request); + } + } + async updateSessionModel(request: UpdateSessionModelRequest): Promise { try { await api.invoke('update_session_model', { request }); @@ -239,9 +259,11 @@ export class AgentAPI { - async listSessions(workspacePath: string): Promise { + async listSessions(workspacePath: string, remoteConnectionId?: string): Promise { try { - return await api.invoke('list_sessions', { request: { workspacePath } }); + return await api.invoke('list_sessions', { + request: { workspacePath, remoteConnectionId }, + }); } catch (error) { throw createTauriCommandError('list_sessions', error, { workspacePath }); } diff --git a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts index ce818ec1..d225984f 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SessionAPI.ts @@ -3,12 +3,17 @@ import { api } from './ApiClient'; import { createTauriCommandError } from '../errors/TauriCommandError'; import type { SessionMetadata, DialogTurnData } from '@/shared/types/session-history'; +function remoteConnField(remoteConnectionId?: string): Record { + return remoteConnectionId ? { remote_connection_id: remoteConnectionId } : {}; +} + export class SessionAPI { - async listSessions(workspacePath: string): Promise { + async listSessions(workspacePath: string, remoteConnectionId?: string): Promise { try { return await api.invoke('list_persisted_sessions', { request: { - workspace_path: workspacePath + workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), } }); } catch (error) { @@ -19,12 +24,14 @@ export class SessionAPI { async loadSessionTurns( sessionId: string, workspacePath: string, - limit?: number + limit?: number, + remoteConnectionId?: string ): Promise { try { - const request: any = { + const request: Record = { session_id: sessionId, workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), }; if (limit !== undefined) { @@ -41,13 +48,15 @@ export class SessionAPI { async saveSessionTurn( turnData: DialogTurnData, - workspacePath: string + workspacePath: string, + remoteConnectionId?: string ): Promise { try { await api.invoke('save_session_turn', { request: { turn_data: turnData, - workspace_path: workspacePath + workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), } }); } catch (error) { @@ -57,13 +66,15 @@ export class SessionAPI { async saveSessionMetadata( metadata: SessionMetadata, - workspacePath: string + workspacePath: string, + remoteConnectionId?: string ): Promise { try { await api.invoke('save_session_metadata', { request: { metadata, - workspace_path: workspacePath + workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), } }); } catch (error) { @@ -73,13 +84,15 @@ export class SessionAPI { async deleteSession( sessionId: string, - workspacePath: string + workspacePath: string, + remoteConnectionId?: string ): Promise { try { await api.invoke('delete_persisted_session', { request: { session_id: sessionId, - workspace_path: workspacePath + workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), } }); } catch (error) { @@ -89,13 +102,15 @@ export class SessionAPI { async touchSessionActivity( sessionId: string, - workspacePath: string + workspacePath: string, + remoteConnectionId?: string ): Promise { try { await api.invoke('touch_session_activity', { request: { session_id: sessionId, - workspace_path: workspacePath + workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), } }); } catch (error) { @@ -105,13 +120,15 @@ export class SessionAPI { async loadSessionMetadata( sessionId: string, - workspacePath: string + workspacePath: string, + remoteConnectionId?: string ): Promise { try { return await api.invoke('load_persisted_session_metadata', { request: { session_id: sessionId, - workspace_path: workspacePath + workspace_path: workspacePath, + ...remoteConnField(remoteConnectionId), } }); } catch (error) { diff --git a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts index 4d6f177d..7f5e0cc2 100644 --- a/src/web-ui/src/infrastructure/services/business/workspaceManager.ts +++ b/src/web-ui/src/infrastructure/services/business/workspaceManager.ts @@ -1,6 +1,7 @@ import { WorkspaceInfo, WorkspaceKind, globalStateAPI } from '../../../shared/types'; +import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { createLogger } from '@/shared/utils/logger'; import { listen } from '@tauri-apps/api/event'; @@ -415,8 +416,10 @@ class WorkspaceManager { log.info('Opening remote workspace', remoteWorkspace); + const remotePath = normalizeRemoteWorkspacePath(remoteWorkspace.remotePath); + const workspace = await globalStateAPI.openRemoteWorkspace( - remoteWorkspace.remotePath, + remotePath, remoteWorkspace.connectionId, remoteWorkspace.connectionName, ); diff --git a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts index 3fd8564d..23f04e84 100644 --- a/src/web-ui/src/infrastructure/theme/core/ThemeService.ts +++ b/src/web-ui/src/infrastructure/theme/core/ThemeService.ts @@ -557,7 +557,10 @@ export class ThemeService { root.style.setProperty('--input-border-focus', colors.accent[400]); root.style.setProperty('--input-border-error', colors.semantic.error); root.style.setProperty('--input-text', colors.text.primary); - root.style.setProperty('--input-placeholder', colors.text.muted); + root.style.setProperty( + '--input-placeholder', + 'color-mix(in srgb, var(--color-text-muted) 40%, var(--color-bg-primary))' + ); root.style.setProperty('--card-bg', colors.element.base); diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 61d3f2df..a63fbf29 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -933,6 +933,8 @@ "enterPassword": "Enter Password", "enterKeyPath": "Enter Private Key Path", "keyPathDescription": "Enter the private key file path", + "authPromptTitle": "SSH authentication", + "authPromptAgentHint": "Uses keys already loaded in your SSH agent.", "disconnectConfirm": "Are you sure you want to disconnect?", "disconnectWorkspaceConfirm": "Are you sure you want to close the remote workspace? This will disconnect from the remote server.", "newFile": "New File", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index be3661c6..dd2e7a90 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -933,6 +933,8 @@ "enterPassword": "请输入密码", "enterKeyPath": "请输入私钥路径", "keyPathDescription": "输入私钥文件路径", + "authPromptTitle": "SSH 认证", + "authPromptAgentHint": "将使用本机 SSH Agent 中已加载的密钥进行认证。", "disconnectConfirm": "确定要断开连接吗?", "disconnectWorkspaceConfirm": "确定要关闭远程工作区吗?这将断开与远程服务器的连接。", "newFile": "新建文件", diff --git a/src/web-ui/src/shared/utils/pathUtils.ts b/src/web-ui/src/shared/utils/pathUtils.ts index 60ea92f3..d7d33834 100644 --- a/src/web-ui/src/shared/utils/pathUtils.ts +++ b/src/web-ui/src/shared/utils/pathUtils.ts @@ -1,6 +1,20 @@ +/** + * Normalize a remote SSH/SFTP path (always POSIX). Safe on Windows clients where + * UI or path APIs may introduce backslashes or duplicate slashes. + */ +export function normalizeRemoteWorkspacePath(path: string): string { + if (typeof path !== 'string') return path; + let s = path.replace(/\\/g, '/'); + while (s.includes('//')) { + s = s.replace('//', '/'); + } + if (s === '/') return s; + return s.replace(/\/+$/, ''); +} + export function normalizePath(path: string): string { if (typeof path !== 'string') return path; diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index 805dc2b2..ea2fed67 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -1276,6 +1276,8 @@ const CodeEditor: React.FC = ({ } catch (err) { log.warn('Failed to update file modification time', err); } + + globalEventBus.emit('file-tree:refresh'); } catch (err) { const errorMsg = t('editor.common.saveFailedWithMessage', { message: String(err) }); @@ -1345,9 +1347,17 @@ const CodeEditor: React.FC = ({ const currentModifiedTime = fileInfo.modified; if (lastModifiedTimeRef.current !== 0 && currentModifiedTime > lastModifiedTimeRef.current) { + const { workspaceAPI } = await import('@/infrastructure/api'); + const fileContent = await workspaceAPI.readFileContent(filePath); + const editorBuffer = modelRef.current?.getValue(); + if (editorBuffer !== undefined && fileContent === editorBuffer) { + lastModifiedTimeRef.current = currentModifiedTime; + return; + } + log.info('File modified externally', { filePath }); - if (hasChanges) { + if (hasChangesRef.current) { const shouldReload = window.confirm( t('editor.codeEditor.externalModifiedConfirm') ); @@ -1356,9 +1366,6 @@ const CodeEditor: React.FC = ({ return; } } - - const { workspaceAPI } = await import('@/infrastructure/api'); - const fileContent = await workspaceAPI.readFileContent(filePath); updateLargeFileMode(fileContent); if (!isUnmountedRef.current) { @@ -1388,7 +1395,7 @@ const CodeEditor: React.FC = ({ } finally { isCheckingFileRef.current = false; } - }, [applyExternalContentToModel, filePath, hasChanges, onContentChange, t, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, onContentChange, t, updateLargeFileMode]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); @@ -1588,13 +1595,30 @@ const CodeEditor: React.FC = ({ } try { + const { workspaceAPI } = await import('@/infrastructure/api'); + const { invoke } = await import('@tauri-apps/api/core'); + const diskContent = await workspaceAPI.readFileContent(filePath); + const editorBuffer = modelRef.current?.getValue(); + if (editorBuffer !== undefined && diskContent === editorBuffer) { + try { + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + if (typeof fileInfo?.modified === 'number') { + lastModifiedTimeRef.current = fileInfo.modified; + } + } catch (err) { + log.warn('Failed to sync mtime after noop file-changed', err); + } + return; + } + if (hasChangesRef.current) { const shouldReload = window.confirm( t('editor.codeEditor.externalModifiedConfirm') ); if (!shouldReload) { try { - const { invoke } = await import('@tauri-apps/api/core'); const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); @@ -1608,9 +1632,7 @@ const CodeEditor: React.FC = ({ } } - const { workspaceAPI } = await import('@/infrastructure/api'); - const { invoke } = await import('@tauri-apps/api/core'); - const fileContent = await workspaceAPI.readFileContent(filePath); + const fileContent = diskContent; updateLargeFileMode(fileContent); const currentPosition = editor?.getPosition(); diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index 8343d719..c8197eb7 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -11,6 +11,7 @@ import type { EditorInstance } from '../meditor'; import { analyzeMarkdownEditability, type MarkdownEditabilityAnalysis } from '../meditor/utils/tiptapMarkdown'; import { AlertCircle } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; +import { globalEventBus } from '@/infrastructure/event-bus'; import { CubeLoading, Button } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; @@ -214,6 +215,8 @@ const MarkdownEditor: React.FC = ({ onContentChangeRef.current(content, false); } } + + globalEventBus.emit('file-tree:refresh'); } if (onSave) { diff --git a/src/web-ui/src/tools/editor/components/PlanViewer.tsx b/src/web-ui/src/tools/editor/components/PlanViewer.tsx index d90d6c4b..bde02224 100644 --- a/src/web-ui/src/tools/editor/components/PlanViewer.tsx +++ b/src/web-ui/src/tools/editor/components/PlanViewer.tsx @@ -13,6 +13,7 @@ import { workspaceAPI } from '@/infrastructure/api/service-api/WorkspaceAPI'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import { fileSystemService } from '@/tools/file-system/services/FileSystemService'; import { planBuildStateService } from '@/shared/services/PlanBuildStateService'; +import { globalEventBus } from '@/infrastructure/event-bus'; import './PlanViewer.scss'; const log = createLogger('PlanViewer'); @@ -282,6 +283,7 @@ const PlanViewer: React.FC = ({ await workspaceAPI.writeFileContent(workspacePath || '', filePath, fullContent); setOriginalContent(planContent); setOriginalYamlContent(yamlContent); + globalEventBus.emit('file-tree:refresh'); // Re-parse yaml to update planData if (yamlContent) { @@ -413,6 +415,7 @@ const PlanViewer: React.FC = ({ setYamlContent(nextYamlContent); setOriginalYamlContent(nextYamlContent); setOriginalContent(planContent); + globalEventBus.emit('file-tree:refresh'); } catch (err) { log.error('Failed to save todo edit', err); } diff --git a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts index 98b5fdee..886c94de 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts @@ -10,6 +10,9 @@ const log = createLogger('useFileSystem'); const EMPTY_FILE_TREE: FileSystemNode[] = []; +/** Polling keeps remote workspaces and lazy-loaded trees in sync when OS/file watch is unreliable. */ +const FILE_TREE_POLL_INTERVAL_MS = 5000; + function findNodeByPath(nodes: FileSystemNode[], targetPath: string): FileSystemNode | undefined { for (const node of nodes) { if (node.path === targetPath) return node; @@ -85,11 +88,16 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem const rootPathRef = useRef(rootPath); const isLoadingRef = useRef(false); const optionsRef = useRef(state.options); + const expandedFoldersRef = useRef(state.expandedFolders); useEffect(() => { optionsRef.current = state.options; }, [state.options]); + useEffect(() => { + expandedFoldersRef.current = state.expandedFolders; + }, [state.expandedFolders]); + const loadFileTreeLazy = useCallback(async (path?: string, silent = false) => { const targetPath = path || rootPath; if (!targetPath) return; @@ -506,6 +514,41 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem } }, [state.options.showHiddenFiles, state.options.excludePatterns]); + useEffect(() => { + if (!rootPath) { + return; + } + + let pollInFlight = false; + + const runPeriodicRefresh = async () => { + const currentRoot = rootPathRef.current; + if (!currentRoot || pollInFlight) { + return; + } + pollInFlight = true; + try { + if (enableLazyLoad) { + await refreshDirectoryInTree(currentRoot); + const expanded = Array.from(expandedFoldersRef.current); + await Promise.all(expanded.map((p) => refreshDirectoryInTree(p))); + } else { + await loadFileTree(currentRoot, true); + } + } catch (e) { + log.debug('Periodic file tree refresh tick failed', { error: e }); + } finally { + pollInFlight = false; + } + }; + + const pollId = window.setInterval(() => { + void runPeriodicRefresh(); + }, FILE_TREE_POLL_INTERVAL_MS); + + return () => clearInterval(pollId); + }, [rootPath, enableLazyLoad, loadFileTree, refreshDirectoryInTree]); + useEffect(() => { if (!enableAutoWatch || !rootPath) { return; @@ -558,6 +601,9 @@ export function useFileSystem(options: UseFileSystemOptions = {}): UseFileSystem }; const unwatch = fileSystemService.watchFileChanges(rootPath, (event) => { + if (event.type === 'renamed' && event.oldPath) { + handleFileChange(event.oldPath); + } handleFileChange(event.path); if ( diff --git a/src/web-ui/src/tools/file-system/services/FileSystemService.ts b/src/web-ui/src/tools/file-system/services/FileSystemService.ts index 31aaff71..ad390749 100644 --- a/src/web-ui/src/tools/file-system/services/FileSystemService.ts +++ b/src/web-ui/src/tools/file-system/services/FileSystemService.ts @@ -7,7 +7,7 @@ const log = createLogger('FileSystemService'); interface FileWatchEvent { path: string; - kind: 'create' | 'modify' | 'remove' | 'rename'; + kind: string; timestamp: number; from?: string; to?: string; @@ -86,12 +86,20 @@ class FileSystemService implements IFileSystemService { const events = event.payload; + const isUnderRoot = (absPath: string) => + absPath === normalizedRoot || absPath.startsWith(`${normalizedRoot}/`); + events.forEach((fileEvent) => { const normalizedEventPath = normalizeForCompare(fileEvent.path); - const underRoot = - normalizedEventPath === normalizedRoot || - normalizedEventPath.startsWith(`${normalizedRoot}/`); - if (!underRoot) { + const normalizedFrom = fileEvent.from + ? normalizeForCompare(fileEvent.from) + : ''; + + const relevant = + isUnderRoot(normalizedEventPath) || + (fileEvent.kind === 'rename' && normalizedFrom !== '' && isUnderRoot(normalizedFrom)); + + if (!relevant) { return; } diff --git a/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx b/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx index 46efa14e..bd498259 100644 --- a/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx +++ b/src/web-ui/src/tools/git/components/GitDiffEditor/GitDiffEditor.tsx @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { DiffEditor } from '@/tools/editor'; import { X } from 'lucide-react'; import { createLogger } from '@/shared/utils/logger'; +import { globalEventBus } from '@/infrastructure/event-bus'; import './GitDiffEditor.scss'; const log = createLogger('GitDiffEditor'); @@ -93,6 +94,7 @@ export const GitDiffEditor: React.FC = ({ await workspaceAPI.writeFileContent(repositoryPath, filePath, contentToSave); + globalEventBus.emit('file-tree:refresh'); setLastSavedContent(contentToSave); lastSavedContentRef.current = contentToSave;