From f8abfe5b917922f488d562eb3fba56966644f3eb Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 24 Mar 2026 10:25:39 +0800 Subject: [PATCH 1/2] 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; From 83b6c1d4f3d67cecc679964db72e3dfa1d8ce562 Mon Sep 17 00:00:00 2001 From: bowen628 Date: Tue, 24 Mar 2026 13:48:07 +0800 Subject: [PATCH 2/2] feat(web-ui): disk file versioning, tab state, and FS error helpers - Detect external file changes in CodeEditor/MarkdownEditor with reload flow - Canvas tabs: dirty/out-of-sync indicators, overflow menu, and store wiring - Add diskFileVersion and fsErrorUtils; tighten useFileSystem error handling - i18n updates for en-US and zh-CN --- .../components/panels/base/FlexiblePanel.tsx | 11 + .../content-canvas/context/CanvasContext.tsx | 3 + .../content-canvas/editor-area/EditorArea.tsx | 9 + .../editor-area/EditorGroup.tsx | 7 + .../mission-control/ThumbnailCard.tsx | 9 +- .../content-canvas/stores/canvasStore.ts | 13 + .../panels/content-canvas/tab-bar/Tab.scss | 6 + .../panels/content-canvas/tab-bar/Tab.tsx | 9 +- .../panels/content-canvas/tab-bar/TabBar.tsx | 12 +- .../tab-bar/TabOverflowMenu.tsx | 14 +- .../panels/content-canvas/types/tab.ts | 2 + src/web-ui/src/locales/en-US/components.json | 1 + src/web-ui/src/locales/en-US/tools.json | 10 +- src/web-ui/src/locales/zh-CN/components.json | 1 + src/web-ui/src/locales/zh-CN/tools.json | 10 +- src/web-ui/src/shared/utils/fsErrorUtils.ts | 23 ++ .../tools/editor/components/CodeEditor.tsx | 382 ++++++++++++------ .../editor/components/MarkdownEditor.tsx | 278 ++++++++++++- .../src/tools/editor/utils/diskFileVersion.ts | 23 ++ .../tools/file-system/hooks/useFileSystem.ts | 2 +- 20 files changed, 669 insertions(+), 156 deletions(-) create mode 100644 src/web-ui/src/shared/utils/fsErrorUtils.ts create mode 100644 src/web-ui/src/tools/editor/utils/diskFileVersion.ts 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 fcb07050..5fadd6a5 100644 --- a/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx +++ b/src/web-ui/src/app/components/panels/base/FlexiblePanel.tsx @@ -90,6 +90,8 @@ interface ExtendedFlexiblePanelProps extends FlexiblePanelProps { onDirtyStateChange?: (isDirty: boolean) => void; /** Whether this panel is the active/visible tab in its EditorGroup */ isActive?: boolean; + /** File no longer exists on disk (from editor); drives tab "deleted" label */ + onFileMissingFromDiskChange?: (missing: boolean) => void; } const FlexiblePanel: React.FC = memo(({ @@ -101,6 +103,7 @@ const FlexiblePanel: React.FC = memo(({ onBeforeClose, onDirtyStateChange, isActive = true, + onFileMissingFromDiskChange, }) => { const { t } = useI18n('components'); @@ -273,6 +276,8 @@ const FlexiblePanel: React.FC = memo(({ readOnly={markdownEditorData.readOnly || false} jumpToLine={markdownJumpToLine} jumpToColumn={markdownJumpToColumn} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(_newContent, hasChanges) => { if (onDirtyStateChange) { onDirtyStateChange(hasChanges); @@ -407,6 +412,8 @@ const FlexiblePanel: React.FC = memo(({ showMinimap={true} theme="vs-dark" className={fileViewerClass} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} /> ); @@ -442,6 +449,8 @@ const FlexiblePanel: React.FC = memo(({ showMinimap={true} theme="vs-dark" onContentChange={codeData.onContentChange} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} /> @@ -467,6 +476,8 @@ const FlexiblePanel: React.FC = memo(({ jumpToLine={editorData.jumpToLine} jumpToColumn={editorData.jumpToColumn} jumpToRange={editorData.jumpToRange} + isActiveTab={isActive} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(newContent, hasChanges) => { if (onContentChange) { onContentChange({ diff --git a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx index 86e7f417..fac0805d 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/context/CanvasContext.tsx @@ -34,6 +34,9 @@ export interface TabOperations { updateTabContent: (tabId: string, groupId: EditorGroupId, content: PanelContent) => void; /** Set tab dirty state */ setTabDirty: (tabId: string, groupId: EditorGroupId, isDirty: boolean) => void; + + /** File missing on disk (for tab chrome) */ + setTabFileDeletedFromDisk: (tabId: string, groupId: EditorGroupId, deleted: boolean) => void; /** Promote tab state (preview -> active) */ promoteTab: (tabId: string, groupId: EditorGroupId) => void; /** Pin/unpin tab */ diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx index 7ca82439..2cf476ad 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorArea.tsx @@ -49,6 +49,7 @@ export const EditorArea: React.FC = ({ setActiveGroup, updateTabContent, setTabDirty, + setTabFileDeletedFromDisk, } = useCanvasStore(); const handleTabClick = useCallback((groupId: EditorGroupId) => (tabId: string) => { @@ -110,6 +111,13 @@ export const EditorArea: React.FC = ({ setTabDirty(tabId, groupId, isDirty); }, [setTabDirty]); + const handleTabFileDeletedFromDiskChange = useCallback( + (groupId: EditorGroupId) => (tabId: string, missing: boolean) => { + setTabFileDeletedFromDisk(tabId, groupId, missing); + }, + [setTabFileDeletedFromDisk] + ); + const renderEditorGroup = (groupId: EditorGroupId, group: typeof primaryGroup) => ( = ({ onGroupFocus={handleGroupFocus(groupId)} onContentChange={handleContentChange(groupId)} onDirtyStateChange={handleDirtyStateChange(groupId)} + onTabFileDeletedFromDiskChange={handleTabFileDeletedFromDiskChange(groupId)} onOpenMissionControl={groupId === 'primary' ? onOpenMissionControl : undefined} onCloseAllTabs={handleCloseAllTabs(groupId)} onInteraction={onInteraction} diff --git a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx index 0bcc5844..1128542c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/editor-area/EditorGroup.tsx @@ -37,6 +37,7 @@ export interface EditorGroupProps { onGroupFocus: () => void; onContentChange: (tabId: string, content: PanelContent) => void; onDirtyStateChange: (tabId: string, isDirty: boolean) => void; + onTabFileDeletedFromDiskChange?: (tabId: string, missing: boolean) => void; onOpenMissionControl?: () => void; onCloseAllTabs?: () => Promise | void; onInteraction?: (itemId: string, userInput: string) => Promise; @@ -61,6 +62,7 @@ export const EditorGroup: React.FC = ({ onGroupFocus, onContentChange, onDirtyStateChange, + onTabFileDeletedFromDiskChange, onOpenMissionControl, onCloseAllTabs, onInteraction, @@ -163,6 +165,11 @@ export const EditorGroup: React.FC = ({ isActive={group.activeTabId === tab.id} onContentChange={group.activeTabId === tab.id ? handleContentChange : undefined} onDirtyStateChange={group.activeTabId === tab.id ? handleDirtyStateChange : undefined} + onFileMissingFromDiskChange={ + onTabFileDeletedFromDiskChange + ? (missing) => onTabFileDeletedFromDiskChange(tab.id, missing) + : undefined + } onInteraction={onInteraction} workspacePath={workspacePath} /> diff --git a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx index a41a7f25..1634cc5c 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/mission-control/ThumbnailCard.tsx @@ -103,6 +103,11 @@ export const ThumbnailCard: React.FC = ({ return t('canvas.groupTertiary'); }, [groupId, t]); + const titleWithDeleted = useMemo(() => { + const suffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : ''; + return `${tab.title}${suffix}`; + }, [tab.fileDeletedFromDisk, tab.title, t]); + // Handle close const handleClose = useCallback((e: React.MouseEvent) => { e.stopPropagation(); @@ -135,7 +140,7 @@ export const ThumbnailCard: React.FC = ({ return (
= ({
{tab.state === 'pinned' && } - {tab.title} + {titleWithDeleted} {tab.isDirty && }
diff --git a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts index 8e7d7761..b85fa185 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/stores/canvasStore.ts @@ -68,6 +68,9 @@ interface CanvasStoreActions { /** Set tab dirty state */ setTabDirty: (tabId: string, groupId: EditorGroupId, isDirty: boolean) => void; + + /** Mark whether the tab's file is missing on disk (editor-detected) */ + setTabFileDeletedFromDisk: (tabId: string, groupId: EditorGroupId, deleted: boolean) => void; /** Promote tab state (preview -> active) */ promoteTab: (tabId: string, groupId: EditorGroupId) => void; @@ -641,6 +644,16 @@ const createCanvasStoreHook = () => create()( } }); }, + + setTabFileDeletedFromDisk: (tabId, groupId, deleted) => { + set((draft) => { + const group = getGroup(draft, groupId); + const tab = group.tabs.find(t => t.id === tabId); + if (tab) { + tab.fileDeletedFromDisk = deleted; + } + }); + }, promoteTab: (tabId, groupId) => { set((draft) => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss index cc91d87b..52973410 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.scss @@ -62,6 +62,12 @@ } } + &.is-file-deleted { + .canvas-tab__title { + color: var(--color-text-secondary, rgba(255, 255, 255, 0.55)); + } + } + // Dragging &.is-dragging { opacity: 0.5; diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx index 16628cad..f69e39d1 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/Tab.tsx @@ -63,9 +63,11 @@ export const Tab: React.FC = ({ // Build tooltip text const unsavedSuffix = tab.isDirty ? ` (${t('tabs.unsaved')})` : ''; + const deletedSuffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : ''; + const titleDisplay = `${tab.title}${deletedSuffix}`; const tooltipText = tab.content.data?.filePath - ? `${tab.content.data.filePath}${unsavedSuffix}` - : `${tab.title}${unsavedSuffix}`; + ? `${tab.content.data.filePath}${deletedSuffix}${unsavedSuffix}` + : `${titleDisplay}${unsavedSuffix}`; // Handle single click - respond immediately const handleClick = useCallback((e: React.MouseEvent) => { @@ -113,6 +115,7 @@ export const Tab: React.FC = ({ 'canvas-tab', isActive && 'is-active', tab.isDirty && 'is-dirty', + tab.fileDeletedFromDisk && 'is-file-deleted', isDragging && 'is-dragging', getStateClassName(tab.state), isTaskDetail && 'is-task-detail', @@ -153,7 +156,7 @@ export const Tab: React.FC = ({ {/* Title */} - {tab.title} + {titleDisplay} {/* Dirty state indicator */} diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx index 7f8e2c5a..6fd4aa59 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabBar.tsx @@ -76,6 +76,9 @@ const estimateTabWidth = (title: string): number => { return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, estimated)); }; +const tabTitleForWidthEstimate = (tab: CanvasTab, deletedLabel: string): string => + tab.fileDeletedFromDisk ? `${tab.title} - ${deletedLabel}` : tab.title; + export const TabBar: React.FC = ({ tabs, groupId, @@ -108,7 +111,10 @@ export const TabBar: React.FC = ({ const visibleTabs = useMemo(() => tabs.filter(t => !t.isHidden), [tabs]); // Build cache key (id + title because title changes affect width) - const getTabCacheKey = useCallback((tab: CanvasTab) => `${tab.id}:${tab.title}`, []); + const getTabCacheKey = useCallback( + (tab: CanvasTab) => `${tab.id}:${tab.title}:${tab.fileDeletedFromDisk ? '1' : '0'}`, + [] + ); // Get tab width: use cache if available, otherwise estimate const getTabWidth = useCallback((tab: CanvasTab): number => { @@ -118,8 +124,8 @@ export const TabBar: React.FC = ({ return cached; } // Estimated width - return estimateTabWidth(tab.title); - }, [getTabCacheKey]); + return estimateTabWidth(tabTitleForWidthEstimate(tab, t('tabs.fileDeleted'))); + }, [getTabCacheKey, t]); // Compute visible tab count based on DOM measurements const calculateVisibleTabs = useCallback(() => { diff --git a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx index 7d78a0ed..e695307f 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx +++ b/src/web-ui/src/app/components/panels/content-canvas/tab-bar/TabOverflowMenu.tsx @@ -189,17 +189,20 @@ export const TabOverflowMenu: React.FC = ({ {/* Overflow tab list */}
- {overflowTabs.map((tab) => ( + {overflowTabs.map((tab) => { + const deletedSuffix = tab.fileDeletedFromDisk ? ` - ${t('tabs.fileDeleted')}` : ''; + const titleWithDeleted = `${tab.title}${deletedSuffix}`; + return (
handleTabClick(tab.id)} > - {tab.state === 'preview' && {tab.title}} - {tab.state !== 'preview' && tab.title} + {tab.state === 'preview' && {titleWithDeleted}} + {tab.state !== 'preview' && titleWithDeleted} {tab.isDirty && ( @@ -213,7 +216,8 @@ export const TabOverflowMenu: React.FC = ({
- ))} + ); + })}
, document.body diff --git a/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts b/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts index 2d554ea6..c5387770 100644 --- a/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts +++ b/src/web-ui/src/app/components/panels/content-canvas/types/tab.ts @@ -23,6 +23,8 @@ export interface CanvasTab { state: TabState; /** Whether there are unsaved changes */ isDirty: boolean; + /** File path no longer exists on disk (or is not a file); tab title shows a deleted label */ + fileDeletedFromDisk?: boolean; /** Whether hidden (for persistent sessions like terminal) */ isHidden?: boolean; /** Created timestamp */ diff --git a/src/web-ui/src/locales/en-US/components.json b/src/web-ui/src/locales/en-US/components.json index c531b8c5..d5315777 100644 --- a/src/web-ui/src/locales/en-US/components.json +++ b/src/web-ui/src/locales/en-US/components.json @@ -336,6 +336,7 @@ "pin": "Pin Tab", "unpin": "Unpin Tab", "unsaved": "Unsaved", + "fileDeleted": "Deleted", "missionControl": "Mission Control", "hiddenTabsCount": "{{count}} hidden tabs", "confirmCloseWithDirty": "File \"{{title}}\" has unsaved changes.\n\nDiscard changes and close?", diff --git a/src/web-ui/src/locales/en-US/tools.json b/src/web-ui/src/locales/en-US/tools.json index 25b79765..8f55458a 100644 --- a/src/web-ui/src/locales/en-US/tools.json +++ b/src/web-ui/src/locales/en-US/tools.json @@ -46,7 +46,15 @@ "loadingFile": "Loading file...", "saving": "Saving...", "initFailedWithMessage": "Failed to initialize editor: {{message}}", - "externalModifiedConfirm": "This file was modified by another program.\n\nYou have unsaved changes. Discard them and reload the file?" + "externalModifiedConfirm": "This file was modified by another program.\n\nYou have unsaved changes. Discard them and reload the file?", + "externalModifiedTitle": "File changed on disk", + "externalModifiedDetail": "The file's metadata on disk (modified time / size) no longer matches the version this editor is based on. You have unsaved local changes. Reloading will discard them.", + "discardAndReload": "Discard and reload", + "keepLocalEdits": "Keep my edits", + "saveConflictTitle": "Save conflict", + "saveConflictDetail": "The file on disk was modified by another program before save. Overwrite the file with your editor content, or discard local changes and reload from disk.", + "overwriteSave": "Overwrite", + "reloadFromDisk": "Reload from disk" }, "diffEditor": { "loading": "Loading diff editor...", diff --git a/src/web-ui/src/locales/zh-CN/components.json b/src/web-ui/src/locales/zh-CN/components.json index 1a67f947..cf288633 100644 --- a/src/web-ui/src/locales/zh-CN/components.json +++ b/src/web-ui/src/locales/zh-CN/components.json @@ -336,6 +336,7 @@ "pin": "固定标签", "unpin": "取消固定", "unsaved": "未保存", + "fileDeleted": "已删除", "missionControl": "全景模式", "hiddenTabsCount": "{{count}} 个隐藏标签", "confirmCloseWithDirty": "文件 \"{{title}}\" 有未保存的更改。\n\n是否放弃更改并关闭?", diff --git a/src/web-ui/src/locales/zh-CN/tools.json b/src/web-ui/src/locales/zh-CN/tools.json index c792197f..effb53fa 100644 --- a/src/web-ui/src/locales/zh-CN/tools.json +++ b/src/web-ui/src/locales/zh-CN/tools.json @@ -46,7 +46,15 @@ "loadingFile": "正在加载文件...", "saving": "正在保存...", "initFailedWithMessage": "编辑器初始化失败: {{message}}", - "externalModifiedConfirm": "该文件已被外部程序修改。\n\n您当前有未保存的修改,是否要放弃修改并重新加载文件?" + "externalModifiedConfirm": "该文件已被外部程序修改。\n\n您当前有未保存的修改,是否要放弃修改并重新加载文件?", + "externalModifiedTitle": "磁盘上的文件已变更", + "externalModifiedDetail": "检测到该文件在磁盘上的版本(修改时间/大小)与当前编辑器所依据的版本不一致。您有未保存的本地修改。若重新加载,本地未保存内容将丢失。", + "discardAndReload": "放弃修改并重新加载", + "keepLocalEdits": "保留本地编辑", + "saveConflictTitle": "保存冲突", + "saveConflictDetail": "保存前检测到磁盘上的文件已被其他程序修改。您可以选择用当前编辑覆盖磁盘,或放弃本地修改并从磁盘重新加载。", + "overwriteSave": "覆盖保存", + "reloadFromDisk": "从磁盘重新加载" }, "diffEditor": { "loading": "正在加载差异编辑器...", diff --git a/src/web-ui/src/shared/utils/fsErrorUtils.ts b/src/web-ui/src/shared/utils/fsErrorUtils.ts new file mode 100644 index 00000000..9ff1bb26 --- /dev/null +++ b/src/web-ui/src/shared/utils/fsErrorUtils.ts @@ -0,0 +1,23 @@ +/** + * Heuristic detection of "file not found" from API/FS errors (local + remote). + */ + +export function isLikelyFileNotFoundError(err: unknown): boolean { + const s = String(err).toLowerCase(); + return ( + s.includes('no such file') || + s.includes('does not exist') || + s.includes('not found') || + s.includes('os error 2') || + s.includes('enoent') || + s.includes('path not found') + ); +} + +/** Metadata from get_file_metadata: missing remote file uses is_file false and is_dir false. */ +export function isFileMissingFromMetadata(fileInfo: Record | null | undefined): boolean { + if (!fileInfo || typeof fileInfo !== 'object') { + return true; + } + return fileInfo.is_file !== true; +} diff --git a/src/web-ui/src/tools/editor/components/CodeEditor.tsx b/src/web-ui/src/tools/editor/components/CodeEditor.tsx index ea2fed67..733d38e3 100644 --- a/src/web-ui/src/tools/editor/components/CodeEditor.tsx +++ b/src/web-ui/src/tools/editor/components/CodeEditor.tsx @@ -25,6 +25,16 @@ import { CubeLoading } from '@/component-library'; import { getMonacoLanguage } from '@/infrastructure/language-detection'; import { createLogger } from '@/shared/utils/logger'; import { isSamePath } from '@/shared/utils/pathUtils'; +import { + diskVersionFromMetadata, + diskVersionsDiffer, + type DiskFileVersion, +} from '../utils/diskFileVersion'; +import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; +import { + isFileMissingFromMetadata, + isLikelyFileNotFoundError, +} from '@/shared/utils/fsErrorUtils'; import { useI18n } from '@/infrastructure/i18n'; import { EditorBreadcrumb } from './EditorBreadcrumb'; import { EditorStatusBar } from './EditorStatusBar'; @@ -70,6 +80,10 @@ export interface CodeEditorProps { jumpToColumn?: number; /** Jump to line range (preferred, supports single or multi-line selection) */ jumpToRange?: import('@/component-library/components/Markdown').LineRange; + /** When false, disk sync polling is paused (e.g. background editor tab). */ + isActiveTab?: boolean; + /** File path is not an existing file on disk (drives tab "deleted" label). */ + onFileMissingFromDiskChange?: (missing: boolean) => void; } const LARGE_FILE_SIZE_THRESHOLD_BYTES = 1 * 1024 * 1024; // 1MB @@ -78,6 +92,9 @@ const LARGE_FILE_RENDER_LINE_LIMIT = 10000; const LARGE_FILE_MAX_TOKENIZATION_LINE_LENGTH = 2000; const LARGE_FILE_EXPANSION_LABELS = ['show more', '显示更多', '展开更多']; +/** Poll disk metadata for open file; only while tab is active (see isActiveTab). */ +const FILE_SYNC_POLL_INTERVAL_MS = 1000; + function hasVeryLongLine(content: string, maxLineLength: number): boolean { let currentLineLength = 0; for (let i = 0; i < content.length; i++) { @@ -117,7 +134,9 @@ const CodeEditor: React.FC = ({ enableLsp = true, jumpToLine, jumpToColumn, - jumpToRange + jumpToRange, + isActiveTab = true, + onFileMissingFromDiskChange, }) => { // Decode URL-encoded paths (e.g. d%3A/path -> d:/path) const filePath = useMemo(() => { @@ -139,7 +158,7 @@ const CodeEditor: React.FC = ({ }, [language]); const [content, setContent] = useState(''); - const [hasChanges, setHasChanges] = useState(false); + const [, setHasChanges] = useState(false); const [loading, setLoading] = useState(true); const [showLoadingOverlay, setShowLoadingOverlay] = useState(false); const loadingOverlayDelayRef = useRef | null>(null); @@ -196,7 +215,23 @@ const CodeEditor: React.FC = ({ const modelRef = useRef(null); const isUnmountedRef = useRef(false); const isCheckingFileRef = useRef(false); - const lastModifiedTimeRef = useRef(0); + /** Last disk state known to match loaded/saved editor content (mtime + size; local + remote). */ + const diskVersionRef = useRef(null); + const lastReportedMissingRef = useRef(undefined); + + const reportFileMissingFromDisk = useCallback( + (missing: boolean) => { + if (!onFileMissingFromDiskChange) { + return; + } + if (lastReportedMissingRef.current === missing) { + return; + } + lastReportedMissingRef.current = missing; + onFileMissingFromDiskChange(missing); + }, + [onFileMissingFromDiskChange] + ); const contentChangeListenerRef = useRef(null); const ctrlDecorationsRef = useRef([]); const lastHoverWordRef = useRef(null); @@ -262,6 +297,42 @@ const CodeEditor: React.FC = ({ }); }, []); + const applyDiskSnapshotToEditor = useCallback( + ( + fileContent: string, + version: DiskFileVersion | null, + options?: { restoreCursor?: monaco.IPosition | null } + ) => { + updateLargeFileMode(fileContent); + if (isUnmountedRef.current) { + return; + } + isLoadingContentRef.current = true; + setContent(fileContent); + originalContentRef.current = fileContent; + setHasChanges(false); + hasChangesRef.current = false; + if (version) { + diskVersionRef.current = version; + } + applyExternalContentToModel(fileContent); + const pos = options?.restoreCursor; + if (pos && editorRef.current) { + editorRef.current.setPosition(pos); + } + onContentChange?.(fileContent, false); + reportFileMissingFromDisk(false); + queueMicrotask(() => { + isLoadingContentRef.current = false; + if (modelRef.current && !isUnmountedRef.current && filePath) { + savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); + monacoModelManager.markAsSaved(filePath); + } + }); + }, + [applyExternalContentToModel, filePath, onContentChange, reportFileMissingFromDisk, updateLargeFileMode] + ); + const shouldBlockLargeFileExpansionClick = useCallback((target: EventTarget | null): boolean => { if (!(target instanceof HTMLElement)) { return false; @@ -1119,6 +1190,24 @@ const CodeEditor: React.FC = ({ setHasChanges(false); hasChangesRef.current = false; applyExternalContentToModel(content); + try { + const { invoke } = await import('@tauri-apps/api/core'); + const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } + } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } + log.warn('Failed to sync disk version after encoding change', err); + } queueMicrotask(() => { if (modelRef.current && !isUnmountedRef.current) { savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); @@ -1126,9 +1215,12 @@ const CodeEditor: React.FC = ({ } }); } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to reload file with new encoding', err); } - }, [applyExternalContentToModel, filePath, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, reportFileMissingFromDisk, updateLargeFileMode]); const handleLanguageConfirm = useCallback((languageId: string) => { userLanguageOverrideRef.current = true; @@ -1154,10 +1246,19 @@ const CodeEditor: React.FC = ({ const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + return; + } + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; } } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to sync file metadata when skipping load', err); } })(); @@ -1171,22 +1272,33 @@ const CodeEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); + + const fileContent = await workspaceAPI.readFileContent(filePath); + reportFileMissingFromDisk(false); let fileSizeBytes: number | undefined; try { - const fileInfo: any = await invoke('get_file_metadata', { + const fileInfoAfter: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + if (isFileMissingFromMetadata(fileInfoAfter)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfoAfter); + if (v) { + diskVersionRef.current = v; + } } - if (typeof fileInfo?.size === 'number') { - fileSizeBytes = fileInfo.size; + if (typeof fileInfoAfter?.size === 'number') { + fileSizeBytes = fileInfoAfter.size; } } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to get file metadata', err); } - const fileContent = await workspaceAPI.readFileContent(filePath); updateLargeFileMode(fileContent, fileSizeBytes); setContent(fileContent); @@ -1221,13 +1333,16 @@ const CodeEditor: React.FC = ({ } setError(displayError); log.error('Failed to load file', err); + if (errStr.includes('does not exist') || errStr.includes('No such file')) { + reportFileMissingFromDisk(true); + } } finally { setLoading(false); queueMicrotask(() => { isLoadingContentRef.current = false; }); } - }, [applyExternalContentToModel, filePath, detectedLanguage, t, updateLargeFileMode]); + }, [applyExternalContentToModel, filePath, detectedLanguage, reportFileMissingFromDisk, t, updateLargeFileMode]); // Save file content const saveFileContent = useCallback(async () => { @@ -1249,36 +1364,67 @@ const CodeEditor: React.FC = ({ const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); - // Use latest content read from model + const fileInfoPre: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + if (isFileMissingFromMetadata(fileInfoPre)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + } + const diskNow = diskVersionFromMetadata(fileInfoPre); + const baseline = diskVersionRef.current; + + if (diskNow && baseline && diskVersionsDiffer(diskNow, baseline)) { + const overwrite = await confirmDialog({ + title: t('editor.codeEditor.saveConflictTitle'), + message: t('editor.codeEditor.saveConflictDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.overwriteSave'), + cancelText: t('editor.codeEditor.reloadFromDisk'), + confirmDanger: true, + }); + if (!overwrite) { + const diskContent = await workspaceAPI.readFileContent(filePath); + const fileInfoAfter: any = await invoke('get_file_metadata', { + request: { path: filePath } + }); + const vAfter = diskVersionFromMetadata(fileInfoAfter); + applyDiskSnapshotToEditor(diskContent, vAfter); + return; + } + } + await workspaceAPI.writeFileContent(workspacePath || '', filePath, currentContent); - - // Use MonacoGlobalManager to mark as saved + monacoModelManager.markAsSaved(filePath); - + originalContentRef.current = currentContent; setHasChanges(false); hasChangesRef.current = false; - - // Sync local versionId + if (modelRef.current) { savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); } - - // Call onSave callback to clear dirty state + onSave?.(currentContent); - // Update file modification time try { const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - lastModifiedTimeRef.current = fileInfo.modified; + if (!isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } } catch (err) { - log.warn('Failed to update file modification time', err); + log.warn('Failed to update file disk version after save', err); } globalEventBus.emit('file-tree:refresh'); - } catch (err) { const errorMsg = t('editor.common.saveFailedWithMessage', { message: String(err) }); setError(errorMsg); @@ -1286,7 +1432,7 @@ const CodeEditor: React.FC = ({ } finally { setSaving(false); } - }, [filePath, workspacePath, content, hasChanges, onSave, t]); + }, [filePath, workspacePath, onSave, reportFileMissingFromDisk, t, applyDiskSnapshotToEditor]); useEffect(() => { saveFileContentRef.current = saveFileContent; @@ -1332,76 +1478,84 @@ const CodeEditor: React.FC = ({ } }, []); - // Check file modifications const checkFileModification = useCallback(async () => { - if (!filePath || isCheckingFileRef.current) return; + if (!filePath || !isActiveTab || isCheckingFileRef.current) { + return; + } isCheckingFileRef.current = true; try { + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + const { invoke } = await import('@tauri-apps/api/core'); const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + return; + } + reportFileMissingFromDisk(false); + const currentVersion = diskVersionFromMetadata(fileInfo); + if (!currentVersion) { + return; + } - const currentModifiedTime = fileInfo.modified; + const baseline = diskVersionRef.current; + if (!baseline) { + diskVersionRef.current = currentVersion; + return; + } - 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; - } + if (!diskVersionsDiffer(currentVersion, baseline)) { + return; + } - log.info('File modified externally', { filePath }); + const { workspaceAPI } = await import('@/infrastructure/api'); + const fileContent = await workspaceAPI.readFileContent(filePath); + const editorBuffer = modelRef.current?.getValue(); + if (editorBuffer !== undefined && fileContent === editorBuffer) { + diskVersionRef.current = currentVersion; + return; + } - if (hasChangesRef.current) { - const shouldReload = window.confirm( - t('editor.codeEditor.externalModifiedConfirm') - ); - if (!shouldReload) { - lastModifiedTimeRef.current = currentModifiedTime; - return; - } - } - updateLargeFileMode(fileContent); - - if (!isUnmountedRef.current) { - isLoadingContentRef.current = true; - setContent(fileContent); - originalContentRef.current = fileContent; - setHasChanges(false); - hasChangesRef.current = false; - lastModifiedTimeRef.current = currentModifiedTime; - applyExternalContentToModel(fileContent); - - onContentChange?.(fileContent, false); - - queueMicrotask(() => { - isLoadingContentRef.current = false; - if (modelRef.current && !isUnmountedRef.current) { - savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); - monacoModelManager.markAsSaved(filePath); - } - }); + log.info('File modified externally', { filePath }); + + if (hasChangesRef.current) { + const shouldReload = await confirmDialog({ + title: t('editor.codeEditor.externalModifiedTitle'), + message: t('editor.codeEditor.externalModifiedDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.discardAndReload'), + cancelText: t('editor.codeEditor.keepLocalEdits'), + confirmDanger: true, + }); + if (!shouldReload) { + diskVersionRef.current = currentVersion; + return; } - } else { - lastModifiedTimeRef.current = currentModifiedTime; } + + applyDiskSnapshotToEditor(fileContent, currentVersion); } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.error('Failed to check file modification', err); } finally { isCheckingFileRef.current = false; } - }, [applyExternalContentToModel, filePath, onContentChange, t, updateLargeFileMode]); + }, [applyDiskSnapshotToEditor, filePath, isActiveTab, reportFileMissingFromDisk, t]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); useEffect(() => { - // Reset the flag when filePath changes loadFileContentCalledRef.current = false; + diskVersionRef.current = null; + lastReportedMissingRef.current = undefined; }, [filePath]); useEffect(() => { @@ -1411,16 +1565,23 @@ const CodeEditor: React.FC = ({ } }, [loadFileContent]); - // Periodic file modification check useEffect(() => { - const intervalId = setInterval(() => { - checkFileModification(); - }, 5000); + if (!filePath || !isActiveTab) { + return; + } + + const tick = () => { + void checkFileModification(); + }; + + const intervalId = window.setInterval(tick, FILE_SYNC_POLL_INTERVAL_MS); + document.addEventListener('visibilitychange', tick); return () => { - clearInterval(intervalId); + window.clearInterval(intervalId); + document.removeEventListener('visibilitychange', tick); }; - }, [checkFileModification]); + }, [checkFileModification, filePath, isActiveTab]); useEffect(() => { const editor = editorRef.current; @@ -1604,70 +1765,47 @@ const CodeEditor: React.FC = ({ const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; } } catch (err) { - log.warn('Failed to sync mtime after noop file-changed', err); + log.warn('Failed to sync disk version after noop file-changed', err); } return; } if (hasChangesRef.current) { - const shouldReload = window.confirm( - t('editor.codeEditor.externalModifiedConfirm') - ); + const shouldReload = await confirmDialog({ + title: t('editor.codeEditor.externalModifiedTitle'), + message: t('editor.codeEditor.externalModifiedDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.discardAndReload'), + cancelText: t('editor.codeEditor.keepLocalEdits'), + confirmDanger: true, + }); if (!shouldReload) { try { const fileInfo: any = await invoke('get_file_metadata', { request: { path: filePath } }); - if (typeof fileInfo?.modified === 'number') { - lastModifiedTimeRef.current = fileInfo.modified; + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; } } catch (err) { - log.warn('Failed to sync mtime after declining external reload', err); + log.warn('Failed to sync disk version after declining external reload', err); } return; } } - const fileContent = diskContent; - updateLargeFileMode(fileContent); - - const currentPosition = editor?.getPosition(); - - isLoadingContentRef.current = true; - setContent(fileContent); - originalContentRef.current = fileContent; - setHasChanges(false); - hasChangesRef.current = false; - applyExternalContentToModel(fileContent); - - 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 update file modification time after external reload', err); - } - - if (editor && currentPosition) { - editor.setPosition(currentPosition); - } - - onContentChange?.(fileContent, false); - - queueMicrotask(() => { - isLoadingContentRef.current = false; - if (modelRef.current && !isUnmountedRef.current) { - savedVersionIdRef.current = modelRef.current.getAlternativeVersionId(); - monacoModelManager.markAsSaved(filePath); - } + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath } }); + const ver = diskVersionFromMetadata(fileInfo); + const currentPosition = editor?.getPosition() ?? null; + applyDiskSnapshotToEditor(diskContent, ver, { restoreCursor: currentPosition }); } catch (error) { log.error('Failed to reload file', error); } @@ -1684,7 +1822,7 @@ const CodeEditor: React.FC = ({ return () => { unsubscribers.forEach(unsub => unsub()); }; - }, [applyExternalContentToModel, monacoReady, filePath, updateLargeFileMode, onContentChange, t]); + }, [applyDiskSnapshotToEditor, applyExternalContentToModel, monacoReady, filePath, t]); useEffect(() => { userLanguageOverrideRef.current = false; diff --git a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx index c8197eb7..aae16037 100644 --- a/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx +++ b/src/web-ui/src/tools/editor/components/MarkdownEditor.tsx @@ -16,12 +16,25 @@ import { CubeLoading, Button } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useTheme } from '@/infrastructure/theme/hooks/useTheme'; import CodeEditor from './CodeEditor'; +import { + diskVersionFromMetadata, + diskVersionsDiffer, + type DiskFileVersion, +} from '../utils/diskFileVersion'; +import { confirmDialog } from '@/component-library/components/ConfirmDialog/confirmService'; +import { + isFileMissingFromMetadata, + isLikelyFileNotFoundError, +} from '@/shared/utils/fsErrorUtils'; import './MarkdownEditor.scss'; -const log = createLogger('MarkdownEditor'); import 'katex/dist/katex.min.css'; import 'highlight.js/styles/github-dark.css'; +const log = createLogger('MarkdownEditor'); + +const FILE_SYNC_POLL_INTERVAL_MS = 1000; + export interface MarkdownEditorProps { /** File path - loads from file if provided, otherwise uses initialContent */ filePath?: string; @@ -43,6 +56,10 @@ export interface MarkdownEditorProps { jumpToLine?: number; /** Jump to column (auto-jump after file opens) */ jumpToColumn?: number; + /** When false, disk sync polling is paused (background tab). */ + isActiveTab?: boolean; + /** File missing on disk (tab chrome); skipped when embedded CodeEditor handles the same path */ + onFileMissingFromDiskChange?: (missing: boolean) => void; } const MarkdownEditor: React.FC = ({ @@ -56,6 +73,8 @@ const MarkdownEditor: React.FC = ({ onSave, jumpToLine, jumpToColumn, + isActiveTab = true, + onFileMissingFromDiskChange, }) => { const { t } = useI18n('tools'); const { isLight } = useTheme(); @@ -67,14 +86,53 @@ const MarkdownEditor: React.FC = ({ const [editability, setEditability] = useState(() => analyzeMarkdownEditability(initialContent)); const editorRef = useRef(null); const isUnmountedRef = useRef(false); - const lastModifiedTimeRef = useRef(0); + const diskVersionRef = useRef(null); + const isCheckingDiskRef = useRef(false); + const hasChangesRef = useRef(false); const lastJumpPositionRef = useRef<{ filePath: string; line: number } | null>(null); const onContentChangeRef = useRef(onContentChange); const contentRef = useRef(content); const lastReportedDirtyRef = useRef(null); + const unsafeViewModeRef = useRef(unsafeViewMode); + unsafeViewModeRef.current = unsafeViewMode; + const lastReportedMissingRef = useRef(undefined); + + const reportFileMissingFromDisk = useCallback( + (missing: boolean) => { + if (!onFileMissingFromDiskChange) { + return; + } + const isUnsafeSplit = + !!filePath && + (editability.mode === 'unsafe' || + editability.containsRenderOnlyBlocks || + editability.containsRawHtmlInlines); + if (isUnsafeSplit && unsafeViewModeRef.current === 'source') { + return; + } + if (lastReportedMissingRef.current === missing) { + return; + } + lastReportedMissingRef.current = missing; + onFileMissingFromDiskChange(missing); + }, + [editability.containsRawHtmlInlines, editability.containsRenderOnlyBlocks, editability.mode, filePath, onFileMissingFromDiskChange] + ); + onContentChangeRef.current = onContentChange; contentRef.current = content; + useEffect(() => { + hasChangesRef.current = hasChanges; + }, [hasChanges]); + + const toNormalizedMarkdown = useCallback((raw: string) => { + const nextEditability = analyzeMarkdownEditability(raw); + const nextContent = + nextEditability.mode === 'unsafe' ? raw : nextEditability.canonicalMarkdown; + return { nextEditability, nextContent }; + }, []); + const basePath = React.useMemo(() => { if (!filePath) return undefined; const normalizedPath = filePath.replace(/\\/g, '/'); @@ -106,23 +164,32 @@ const MarkdownEditor: React.FC = ({ try { const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); - + const fileContent = await workspaceAPI.readFileContent(filePath); + reportFileMissingFromDisk(false); try { - const fileInfo: any = await invoke('get_file_metadata', { - request: { path: filePath } + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, }); - lastModifiedTimeRef.current = fileInfo.modified; + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } } catch (err) { + if (isLikelyFileNotFoundError(err)) { + reportFileMissingFromDisk(true); + } log.warn('Failed to get file metadata', err); } - + if (!isUnmountedRef.current) { - const nextEditability = analyzeMarkdownEditability(fileContent); - const nextContent = nextEditability.mode === 'unsafe' - ? fileContent - : nextEditability.canonicalMarkdown; + const { nextEditability, nextContent } = toNormalizedMarkdown(fileContent); setEditability(nextEditability); setContent(nextContent); @@ -146,19 +213,23 @@ const MarkdownEditor: React.FC = ({ displayError = t('editor.common.permissionDenied'); } setError(displayError); + if (errStr.includes('does not exist') || errStr.includes('No such file')) { + reportFileMissingFromDisk(true); + } } } finally { if (!isUnmountedRef.current) { setLoading(false); } } - }, [filePath, t]); + }, [filePath, reportFileMissingFromDisk, t, toNormalizedMarkdown]); // Initial file load - only run once when filePath changes const loadFileContentCalledRef = useRef(false); useEffect(() => { - // Reset the flag when filePath changes loadFileContentCalledRef.current = false; + diskVersionRef.current = null; + lastReportedMissingRef.current = undefined; }, [filePath]); useEffect(() => { @@ -186,6 +257,116 @@ const MarkdownEditor: React.FC = ({ } }, [filePath, initialContent, loadFileContent]); + const checkMarkdownDisk = useCallback(async () => { + if (!filePath || !isActiveTab || isUnmountedRef.current || isCheckingDiskRef.current) { + return; + } + if (typeof document !== 'undefined' && document.visibilityState !== 'visible') { + return; + } + + isCheckingDiskRef.current = true; + try { + const { workspaceAPI } = await import('@/infrastructure/api'); + const { invoke } = await import('@tauri-apps/api/core'); + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(true); + return; + } + reportFileMissingFromDisk(false); + const currentVersion = diskVersionFromMetadata(fileInfo); + if (!currentVersion) { + return; + } + const baseline = diskVersionRef.current; + if (!baseline) { + diskVersionRef.current = currentVersion; + return; + } + if (!diskVersionsDiffer(currentVersion, baseline)) { + return; + } + + const raw = await workspaceAPI.readFileContent(filePath); + const { nextEditability, nextContent } = toNormalizedMarkdown(raw); + if (nextContent === contentRef.current) { + diskVersionRef.current = currentVersion; + return; + } + + if (hasChangesRef.current) { + const shouldReload = await confirmDialog({ + title: t('editor.codeEditor.externalModifiedTitle'), + message: t('editor.codeEditor.externalModifiedDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.discardAndReload'), + cancelText: t('editor.codeEditor.keepLocalEdits'), + confirmDanger: true, + }); + if (!shouldReload) { + diskVersionRef.current = currentVersion; + return; + } + } + + if (!isUnmountedRef.current) { + setEditability(nextEditability); + setContent(nextContent); + contentRef.current = nextContent; + setHasChanges(false); + lastReportedDirtyRef.current = false; + onContentChangeRef.current?.(nextContent, false); + setTimeout(() => { + editorRef.current?.setInitialContent?.(nextContent); + }, 0); + editorRef.current?.markSaved?.(); + reportFileMissingFromDisk(false); + } + + const fileInfoAfter: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (!isFileMissingFromMetadata(fileInfoAfter)) { + const vAfter = diskVersionFromMetadata(fileInfoAfter); + if (vAfter) { + diskVersionRef.current = vAfter; + } + } + } catch (e) { + if (isLikelyFileNotFoundError(e)) { + reportFileMissingFromDisk(true); + } + log.error('Markdown disk sync check failed', e); + } finally { + isCheckingDiskRef.current = false; + } + }, [filePath, isActiveTab, reportFileMissingFromDisk, t, toNormalizedMarkdown]); + + const isUnsafeSplitUi = + !!filePath && + (editability.mode === 'unsafe' || + editability.containsRenderOnlyBlocks || + editability.containsRawHtmlInlines); + const pollMarkdownDisk = !isUnsafeSplitUi || unsafeViewMode !== 'source'; + + useEffect(() => { + if (!filePath || !isActiveTab || !pollMarkdownDisk) { + return; + } + const tick = () => { + void checkMarkdownDisk(); + }; + const intervalId = window.setInterval(tick, FILE_SYNC_POLL_INTERVAL_MS); + document.addEventListener('visibilitychange', tick); + return () => { + window.clearInterval(intervalId); + document.removeEventListener('visibilitychange', tick); + }; + }, [checkMarkdownDisk, filePath, isActiveTab, pollMarkdownDisk]); + const saveFileContent = useCallback(async () => { if (!hasChanges || isUnmountedRef.current) return; @@ -196,13 +377,72 @@ const MarkdownEditor: React.FC = ({ const { workspaceAPI } = await import('@/infrastructure/api'); const { invoke } = await import('@tauri-apps/api/core'); + const fileInfoPre: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (isFileMissingFromMetadata(fileInfoPre)) { + reportFileMissingFromDisk(true); + } else { + reportFileMissingFromDisk(false); + } + const diskNow = diskVersionFromMetadata(fileInfoPre); + const baseline = diskVersionRef.current; + + if (diskNow && baseline && diskVersionsDiffer(diskNow, baseline)) { + const overwrite = await confirmDialog({ + title: t('editor.codeEditor.saveConflictTitle'), + message: t('editor.codeEditor.saveConflictDetail'), + type: 'warning', + confirmText: t('editor.codeEditor.overwriteSave'), + cancelText: t('editor.codeEditor.reloadFromDisk'), + confirmDanger: true, + }); + if (!overwrite) { + const raw = await workspaceAPI.readFileContent(filePath); + const { nextEditability, nextContent } = toNormalizedMarkdown(raw); + if (!isUnmountedRef.current) { + setEditability(nextEditability); + setContent(nextContent); + contentRef.current = nextContent; + setHasChanges(false); + lastReportedDirtyRef.current = false; + editorRef.current?.markSaved?.(); + onContentChangeRef.current?.(nextContent, false); + setTimeout(() => { + editorRef.current?.setInitialContent?.(nextContent); + }, 0); + reportFileMissingFromDisk(false); + } + try { + const fileInfoAfter: any = await invoke('get_file_metadata', { + request: { path: filePath }, + }); + if (!isFileMissingFromMetadata(fileInfoAfter)) { + const v = diskVersionFromMetadata(fileInfoAfter); + if (v) { + diskVersionRef.current = v; + } + } + } catch (err) { + log.warn('Failed to sync disk version after save conflict reload', err); + } + return; + } + } + await workspaceAPI.writeFileContent(workspacePath, filePath, content); - + try { - const fileInfo: any = await invoke('get_file_metadata', { - request: { path: filePath } + const fileInfo: any = await invoke('get_file_metadata', { + request: { path: filePath }, }); - lastModifiedTimeRef.current = fileInfo.modified; + if (!isFileMissingFromMetadata(fileInfo)) { + reportFileMissingFromDisk(false); + const v = diskVersionFromMetadata(fileInfo); + if (v) { + diskVersionRef.current = v; + } + } } catch (err) { log.warn('Failed to get file metadata', err); } @@ -229,7 +469,7 @@ const MarkdownEditor: React.FC = ({ setError(t('editor.common.saveFailedWithMessage', { message: errorMessage })); } } - }, [content, filePath, workspacePath, hasChanges, onSave, t]); + }, [content, filePath, workspacePath, hasChanges, onSave, reportFileMissingFromDisk, t, toNormalizedMarkdown]); const handleContentChange = useCallback((newContent: string) => { contentRef.current = newContent; @@ -375,6 +615,8 @@ const MarkdownEditor: React.FC = ({ showMinimap={true} jumpToLine={jumpToLine} jumpToColumn={jumpToColumn} + isActiveTab={isActiveTab} + onFileMissingFromDiskChange={onFileMissingFromDiskChange} onContentChange={(newContent, dirty) => { contentRef.current = newContent; setContent(newContent); diff --git a/src/web-ui/src/tools/editor/utils/diskFileVersion.ts b/src/web-ui/src/tools/editor/utils/diskFileVersion.ts new file mode 100644 index 00000000..c21ebb32 --- /dev/null +++ b/src/web-ui/src/tools/editor/utils/diskFileVersion.ts @@ -0,0 +1,23 @@ +/** + * Disk file identity for external-change detection (local + remote via get_file_metadata). + */ + +export type DiskFileVersion = { modified: number; size: number }; + +export function diskVersionFromMetadata(fileInfo: unknown): DiskFileVersion | null { + if (!fileInfo || typeof fileInfo !== 'object') { + return null; + } + const o = fileInfo as Record; + if (typeof o.modified !== 'number') { + return null; + } + return { + modified: o.modified, + size: typeof o.size === 'number' ? o.size : 0, + }; +} + +export function diskVersionsDiffer(a: DiskFileVersion, b: DiskFileVersion): boolean { + return a.modified !== b.modified || a.size !== b.size; +} 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 886c94de..62014b00 100644 --- a/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts +++ b/src/web-ui/src/tools/file-system/hooks/useFileSystem.ts @@ -11,7 +11,7 @@ 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; +const FILE_TREE_POLL_INTERVAL_MS = 1000; function findNodeByPath(nodes: FileSystemNode[], targetPath: string): FileSystemNode | undefined { for (const node of nodes) {