From f8abfe5b917922f488d562eb3fba56966644f3eb Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 24 Mar 2026 10:25:39 +0800 Subject: [PATCH] fix: remote-ssh cap --- src/apps/desktop/src/api/commands.rs | 12 +- src/apps/desktop/src/api/dto.rs | 10 +- src/apps/desktop/src/api/ssh_api.rs | 2 + .../src/agentic/session/session_manager.rs | 7 +- .../core/src/service/remote_ssh/manager.rs | 62 +++-- src/crates/core/src/service/remote_ssh/mod.rs | 2 +- .../src/service/remote_ssh/workspace_state.rs | 73 +++++- .../components/panels/base/FlexiblePanel.tsx | 3 + .../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 | 24 +- src/web-ui/src/features/ssh-remote/index.ts | 2 +- .../services/business/workspaceManager.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 | 2 + .../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 + 24 files changed, 593 insertions(+), 225 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/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index b80d4236..6c9f7397 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -677,14 +677,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 { @@ -703,7 +705,7 @@ pub async fn open_remote_workspace( 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) => { @@ -733,7 +735,7 @@ pub async fn open_remote_workspace( 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); 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/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/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 86e1ddd6..9d5fef83 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -1280,8 +1280,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 +1329,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/service/remote_ssh/manager.rs b/src/crates/core/src/service/remote_ssh/manager.rs index a1f1a9c3..60dfc575 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 { @@ -390,11 +410,19 @@ impl SSHConnectionManager { } /// Add/update a persisted remote workspace - pub async fn set_remote_workspace(&self, workspace: crate::service::remote_ssh::types::RemoteWorkspace) -> anyhow::Result<()> { + 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(); + guard.retain(|w| { + crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( + &w.remote_path, + ) != rp + }); guard.push(workspace); } self.save_remote_workspaces().await @@ -412,9 +440,14 @@ impl SSHConnectionManager { /// Remove a specific remote workspace by path pub async fn remove_remote_workspace(&self, 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| { + crate::service::remote_ssh::workspace_state::normalize_remote_workspace_path( + &w.remote_path, + ) != rp + }); } self.save_remote_workspaces().await } @@ -472,16 +505,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 +584,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..a1d8f3d0 100644 --- a/src/crates/core/src/service/remote_ssh/mod.rs +++ b/src/crates/core/src/service/remote_ssh/mod.rs @@ -19,6 +19,6 @@ 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, + is_remote_path, lookup_remote_connection, normalize_remote_workspace_path, RemoteWorkspaceEntry, RemoteWorkspaceState, RemoteWorkspaceStateManager, }; 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..a4d325f4 100644 --- a/src/crates/core/src/service/remote_ssh/workspace_state.rs +++ b/src/crates/core/src/service/remote_ssh/workspace_state.rs @@ -10,6 +10,30 @@ 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() +} + +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)) +} + /// A single registered remote workspace entry. #[derive(Debug, Clone)] pub struct RemoteWorkspaceEntry { @@ -85,6 +109,7 @@ impl RemoteWorkspaceStateManager { connection_id: String, connection_name: String, ) { + let remote_path = normalize_remote_workspace_path(&remote_path); let mut guard = self.workspaces.write().await; guard.insert( remote_path, @@ -97,8 +122,9 @@ impl RemoteWorkspaceStateManager { /// Unregister a remote workspace by its path. pub async fn unregister_remote_workspace(&self, remote_path: &str) { + let key = normalize_remote_workspace_path(remote_path); let mut guard = self.workspaces.write().await; - guard.remove(remote_path); + guard.remove(&key); } /// Look up the connection info for a given path. @@ -107,18 +133,26 @@ impl RemoteWorkspaceStateManager { /// 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 path = normalize_remote_workspace_path(path); let guard = self.workspaces.read().await; // Exact match first (most common). - if let Some(entry) = guard.get(path) { + if let Some(entry) = guard.get(&path) { return Some(entry.clone()); } - // Sub-path match. + // Longest root prefix wins when multiple remote workspaces are registered. + let mut best: Option<(usize, RemoteWorkspaceEntry)> = None; for (root, entry) in guard.iter() { - if path.starts_with(&format!("{}/", root)) { - return Some(entry.clone()); + if remote_path_is_under_root(&path, root) { + let pick = match best { + None => true, + Some((best_len, _)) => root.len() > best_len, + }; + if pick { + best = Some((root.len(), entry.clone())); + } } } - None + best.map(|(_, e)| e) } /// Quick boolean check: is `path` inside any registered remote workspace? @@ -263,3 +297,30 @@ pub async fn is_remote_workspace_active() -> bool { false } } + +#[cfg(test)] +mod tests { + use super::normalize_remote_workspace_path; + + #[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/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/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..a561f54b 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'); @@ -215,16 +216,20 @@ export const SSHRemoteProvider: React.FC = ({ children } for (const ws of openedRemote) { if (!ws.connectionId) continue; - toReconnect.set(ws.rootPath, { + const rp = normalizeRemoteWorkspacePath(ws.rootPath); + toReconnect.set(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) { + const leg = normalizeRemoteWorkspacePath(legacyWorkspace.remotePath); + if (!toReconnect.has(leg)) { + toReconnect.set(leg, { ...legacyWorkspace, remotePath: leg }); + } } if (toReconnect.size === 0) { @@ -243,7 +248,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 +426,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/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/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..6e8045d4 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) }); 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;