From 1ab4d323f1a2697f879dd5cc73138af70e4f8d9d Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 4 Jul 2026 17:08:31 +0800 Subject: [PATCH 1/2] fix(remote-workspace): wire file CRUD through remote connection context Route remote_connection_id through desktop file commands and the file explorer so create, rename, and delete work in SSH workspaces, and improve path normalization, reveal-in-explorer, and delete confirmation UX. --- src/apps/desktop/src/api/commands.rs | 104 +++++++++++++++--- src/apps/desktop/src/api/path_target.rs | 63 +++++++---- .../src/remote_ssh/disabled.rs | 4 + .../src/remote_ssh/remote_fs.rs | 6 + .../src/remote_ssh/workspace_registry.rs | 9 +- .../src/app/components/panels/FilesPanel.tsx | 39 ++++--- ...rnFlowChatContainer.history-state.test.tsx | 11 ++ .../api/service-api/WorkspaceAPI.ts | 30 ++--- .../commands/builtin/file/CopyPathCommand.ts | 10 +- .../commands/builtin/file/DeleteCommand.ts | 5 +- .../commands/builtin/file/NewFileCommand.ts | 7 +- .../commands/builtin/file/NewFolderCommand.ts | 7 +- .../providers/FileExplorerMenuProvider.ts | 6 +- src/web-ui/src/shared/utils/pathUtils.ts | 16 ++- 14 files changed, 236 insertions(+), 81 deletions(-) diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 7c7015477..677d8d819 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -810,6 +810,8 @@ async fn search_remote_file_names_with_progress( pub struct RenameFileRequest { pub old_path: String, pub new_path: String, + #[serde(default)] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -822,22 +824,30 @@ pub struct ExportLocalFileRequest { #[derive(Debug, Deserialize)] pub struct DeleteFileRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct DeleteDirectoryRequest { pub path: String, pub recursive: Option, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct CreateFileRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] pub struct CreateDirectoryRequest { pub path: String, + #[serde(default, rename = "remoteConnectionId")] + pub remote_connection_id: Option, } #[derive(Debug, Deserialize)] @@ -2952,7 +2962,13 @@ pub async fn rename_file( state: State<'_, AppState>, request: RenameFileRequest, ) -> Result<(), String> { - rename_path(&state, &request.old_path, &request.new_path).await + rename_path( + &state, + &request.old_path, + &request.new_path, + request.remote_connection_id.as_deref(), + ) + .await } /// Copy a local file to another local path (binary-safe). Used for export and drag-upload into local workspaces. @@ -2979,7 +2995,12 @@ pub async fn delete_file( state: State<'_, AppState>, request: DeleteFileRequest, ) -> Result<(), String> { - delete_desktop_file(&state, &request.path).await + delete_desktop_file( + &state, + &request.path, + request.remote_connection_id.as_deref(), + ) + .await } #[tauri::command] @@ -2988,7 +3009,13 @@ pub async fn delete_directory( request: DeleteDirectoryRequest, ) -> Result<(), String> { let recursive = request.recursive.unwrap_or(false); - delete_desktop_directory(&state, &request.path, recursive).await + delete_desktop_directory( + &state, + &request.path, + recursive, + request.remote_connection_id.as_deref(), + ) + .await } #[tauri::command] @@ -2996,7 +3023,12 @@ pub async fn create_file( state: State<'_, AppState>, request: CreateFileRequest, ) -> Result<(), String> { - create_empty_file(&state, &request.path).await + create_empty_file( + &state, + &request.path, + request.remote_connection_id.as_deref(), + ) + .await } #[tauri::command] @@ -3004,7 +3036,12 @@ pub async fn create_directory( state: State<'_, AppState>, request: CreateDirectoryRequest, ) -> Result<(), String> { - create_desktop_directory(&state, &request.path).await + create_desktop_directory( + &state, + &request.path, + request.remote_connection_id.as_deref(), + ) + .await } #[derive(Debug, Deserialize)] @@ -3123,7 +3160,7 @@ pub async fn reveal_in_explorer( } else { let normalized_path = path_str.replace("/", "\\"); bitfun_core::util::process_manager::create_command("explorer") - .args(["/select,", &normalized_path]) + .arg(format!("/select,{}", normalized_path)) .spawn() .map_err(|e| format!("Failed to open explorer: {}", e))?; } @@ -3146,17 +3183,52 @@ pub async fn reveal_in_explorer( #[cfg(target_os = "linux")] { - let target = if is_directory { - path.to_path_buf() + if is_directory { + bitfun_core::util::process_manager::create_command("xdg-open") + .arg(&path_str) + .spawn() + .map_err(|e| format!("Failed to open file manager: {}", e))?; } else { - path.parent() - .ok_or_else(|| "Failed to get parent directory".to_string())? - .to_path_buf() - }; - bitfun_core::util::process_manager::create_command("xdg-open") - .arg(target) - .spawn() - .map_err(|e| format!("Failed to open file manager: {}", e))?; + // On Linux there is no cross-desktop standard to select a specific + // file in the file manager. Try the freedesktop FileManager1 D-Bus + // interface (supported by Nautilus, Dolphin, Nemo) to highlight the + // file; fall back to opening the parent directory with xdg-open. + // Encode each path segment so spaces and other special characters + // do not break the dbus-send array:string: syntax (which splits on + // spaces) and produce a valid file:// URI. + let encoded_path: String = path + .to_string_lossy() + .split('/') + .map(|s| urlencoding::encode(s).to_string()) + .collect::>() + .join('/'); + let file_uri = format!("file://{}", encoded_path); + let dbus_ok = match bitfun_core::util::process_manager::create_command("dbus-send") + .args([ + "--session", + "--print-reply", + "--dest=org.freedesktop.FileManager1", + "/org/freedesktop/FileManager1", + "org.freedesktop.FileManager1.ShowItems", + &format!("array:string:{}", file_uri), + "string:", + ]) + .spawn() + { + Ok(mut child) => child.wait().map(|s| s.success()).unwrap_or(false), + Err(_) => false, + }; + + if !dbus_ok { + let parent = path + .parent() + .ok_or_else(|| "Failed to get parent directory".to_string())?; + bitfun_core::util::process_manager::create_command("xdg-open") + .arg(parent) + .spawn() + .map_err(|e| format!("Failed to open file manager: {}", e))?; + } + } } Ok(()) diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 66f68960f..4a5648e40 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -218,7 +218,9 @@ pub async fn read_text_file( raw_path: &str, preferred_remote_connection_id: Option<&str>, ) -> Result { - match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { + let target = + resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await?; + match &target { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .read_file(&resolved_path.to_string_lossy()) @@ -234,7 +236,7 @@ pub async fn read_text_file( .await .map_err(|e| format!("Remote file service not available: {}", e))?; let bytes = remote_fs - .read_file(&entry.connection_id, &requested_path) + .read_file(&entry.connection_id, requested_path) .await .map_err(|e| format!("Failed to read remote file: {}", e))?; String::from_utf8(bytes).map_err(|e| format!("File is not valid UTF-8: {}", e)) @@ -352,22 +354,28 @@ pub async fn rename_path( app_state: &AppState, old_path: &str, new_path: &str, + preferred_remote_connection_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, old_path, None).await? { + match resolve_desktop_path_target(app_state, old_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path: old_resolved_path, .. } => { - let new_resolved_path = - match resolve_desktop_path_target(app_state, new_path, None).await? { - DesktopPathTarget::Local { resolved_path, .. } => resolved_path, - DesktopPathTarget::Remote { .. } => { - return Err(format!( - "Cannot rename local path '{}' to remote destination '{}'", - old_path, new_path - )) - } - }; + let new_resolved_path = match resolve_desktop_path_target( + app_state, + new_path, + preferred_remote_connection_id, + ) + .await? + { + DesktopPathTarget::Local { resolved_path, .. } => resolved_path, + DesktopPathTarget::Remote { .. } => { + return Err(format!( + "Cannot rename local path '{}' to remote destination '{}'", + old_path, new_path + )) + } + }; app_state .filesystem_service @@ -391,8 +399,12 @@ pub async fn rename_path( } } -pub async fn delete_file(app_state: &AppState, raw_path: &str) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { +pub async fn delete_file( + app_state: &AppState, + raw_path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Result<(), String> { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .delete_file(&resolved_path.to_string_lossy()) @@ -418,8 +430,9 @@ pub async fn delete_directory( app_state: &AppState, raw_path: &str, recursive: bool, + preferred_remote_connection_id: Option<&str>, ) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .delete_directory(&resolved_path.to_string_lossy(), recursive) @@ -440,7 +453,7 @@ pub async fn delete_directory( .map_err(|e| format!("Failed to delete remote directory: {}", e)) } else { remote_fs - .remove_dir_all(&entry.connection_id, &requested_path) + .remove_dir(&entry.connection_id, &requested_path) .await .map_err(|e| format!("Failed to delete remote directory: {}", e)) } @@ -448,8 +461,12 @@ pub async fn delete_directory( } } -pub async fn create_empty_file(app_state: &AppState, raw_path: &str) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { +pub async fn create_empty_file( + app_state: &AppState, + raw_path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Result<(), String> { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => { let options = FileOperationOptions::default(); app_state @@ -475,8 +492,12 @@ pub async fn create_empty_file(app_state: &AppState, raw_path: &str) -> Result<( } } -pub async fn create_directory(app_state: &AppState, raw_path: &str) -> Result<(), String> { - match resolve_desktop_path_target(app_state, raw_path, None).await? { +pub async fn create_directory( + app_state: &AppState, + raw_path: &str, + preferred_remote_connection_id: Option<&str>, +) -> Result<(), String> { + match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => app_state .filesystem_service .create_directory(&resolved_path.to_string_lossy()) diff --git a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs index f585b5259..38251b282 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/disabled.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/disabled.rs @@ -599,6 +599,10 @@ impl RemoteFileService { Err(unsupported()) } + pub async fn remove_dir(&self, _connection_id: &str, _path: &str) -> anyhow::Result<()> { + Err(unsupported()) + } + pub async fn rename( &self, _connection_id: &str, diff --git a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs index ae7fe9571..c44e8ae8a 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/remote_fs.rs @@ -327,6 +327,12 @@ impl RemoteFileService { manager.sftp_rmdir(connection_id, path).await } + /// Remove an empty directory via SFTP (non-recursive; fails if not empty) + pub async fn remove_dir(&self, connection_id: &str, path: &str) -> anyhow::Result<()> { + let manager = self.get_manager(connection_id).await?; + manager.sftp_rmdir(connection_id, path).await + } + /// Rename/move a remote file or directory via SFTP pub async fn rename( &self, diff --git a/src/crates/services/services-integrations/src/remote_ssh/workspace_registry.rs b/src/crates/services/services-integrations/src/remote_ssh/workspace_registry.rs index 9c20f1d24..b2c9426be 100644 --- a/src/crates/services/services-integrations/src/remote_ssh/workspace_registry.rs +++ b/src/crates/services/services-integrations/src/remote_ssh/workspace_registry.rs @@ -128,8 +128,13 @@ impl RemoteWorkspaceRegistry { .filter(|r| registration_matches_path(r, &path_norm)) .collect(); - if let Some(pref) = preferred_connection_id { - candidates.retain(|r| r.connection_id == pref); + // Only use preferred_connection_id to disambiguate when multiple + // path-matching candidates exist. When there is exactly one match, + // the path is unambiguous and the preferred hint must not override it. + if candidates.len() > 1 { + if let Some(pref) = preferred_connection_id { + candidates.retain(|r| r.connection_id == pref); + } } let best_len = candidates.iter().map(|r| r.remote_root.len()).max()?; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index e7fd626ea..9a0b0eae3 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -26,6 +26,7 @@ import { createLogger } from '@/shared/utils/logger'; import { basenamePath, normalizeLocalPathForRename, + normalizeRemoteWorkspacePath, pathsEquivalentFs, replaceBasename, } from '@/shared/utils/pathUtils'; @@ -228,6 +229,14 @@ const FilesPanel: React.FC = ({ prevWorkspacePathRef.current = workspacePath; }, [workspacePath, clearSearch, onViewModeChange]); + const normalizePathForCurrentWorkspace = useCallback( + (path: string) => + isRemoteCurrentWorkspace + ? normalizeRemoteWorkspacePath(path) + : normalizeLocalPathForRename(path), + [isRemoteCurrentWorkspace] + ); + // ===== File Operation Handlers ===== const handleOpenFile = useCallback((data: { path: string; line?: number; column?: number }) => { @@ -265,7 +274,7 @@ const FilesPanel: React.FC = ({ ); try { - await workspaceAPI.createFile(filePath); + await workspaceAPI.createFile(filePath, currentWorkspace?.connectionId); log.info('File created', { path: filePath }); handleInputDialogClose(); loadFileTree(workspacePath || '', true); @@ -291,7 +300,7 @@ const FilesPanel: React.FC = ({ ); try { - await workspaceAPI.createDirectory(folderPath); + await workspaceAPI.createDirectory(folderPath, currentWorkspace?.connectionId); log.info('Directory created', { path: folderPath }); handleInputDialogClose(); loadFileTree(workspacePath || '', true); @@ -310,11 +319,11 @@ const FilesPanel: React.FC = ({ }, [inputDialog.type, handleConfirmNewFile, handleConfirmNewFolder]); const handleStartRename = useCallback((data: { path: string; name: string }) => { - setRenamingPath(normalizeLocalPathForRename(data.path)); - }, []); + setRenamingPath(normalizePathForCurrentWorkspace(data.path)); + }, [normalizePathForCurrentWorkspace]); const handleExecuteRename = useCallback(async (oldPath: string, newName: string) => { - const normalizedOld = normalizeLocalPathForRename(oldPath); + const normalizedOld = normalizePathForCurrentWorkspace(oldPath); const oldName = basenamePath(normalizedOld); if (newName.trim() === oldName) { @@ -325,7 +334,7 @@ const FilesPanel: React.FC = ({ const newPath = replaceBasename(normalizedOld, newName.trim()); try { - await workspaceAPI.renameFile(normalizedOld, newPath); + await workspaceAPI.renameFile(normalizedOld, newPath, currentWorkspace?.connectionId); log.info('File renamed', { oldPath: normalizedOld, newPath }); setRenamingPath(null); removePath(normalizedOld); @@ -335,20 +344,20 @@ const FilesPanel: React.FC = ({ notification.error(t('notifications.renameFailed', { error: String(error) })); setRenamingPath(null); } - }, [workspacePath, loadFileTree, removePath, notification, t]); + }, [workspacePath, loadFileTree, removePath, notification, t, normalizePathForCurrentWorkspace, currentWorkspace]); const handleCancelRename = useCallback(() => { setRenamingPath(null); }, []); const handleDelete = useCallback(async (data: { path: string; isDirectory: boolean }) => { - const normalizedPath = normalizeLocalPathForRename(data.path); + const normalizedPath = normalizePathForCurrentWorkspace(data.path); try { if (data.isDirectory) { - await workspaceAPI.deleteDirectory(normalizedPath); + await workspaceAPI.deleteDirectory(normalizedPath, true, currentWorkspace?.connectionId); } else { - await workspaceAPI.deleteFile(normalizedPath); + await workspaceAPI.deleteFile(normalizedPath, currentWorkspace?.connectionId); } log.info('File deleted', { path: normalizedPath, isDirectory: data.isDirectory }); removePath(normalizedPath); @@ -357,7 +366,7 @@ const FilesPanel: React.FC = ({ log.error('Failed to delete file', error); notification.error(t('notifications.deleteFailed', { error: String(error) })); } - }, [workspacePath, loadFileTree, removePath, notification, t]); + }, [workspacePath, loadFileTree, removePath, notification, t, normalizePathForCurrentWorkspace, currentWorkspace]); const handleReveal = useCallback(async (data: { path: string }) => { if (isRemoteWorkspace(workspaceManager.getState().currentWorkspace)) { @@ -1072,8 +1081,12 @@ const FilesPanel: React.FC = ({ confirmText={inputDialog.type === 'newFile' ? t('dialog.newFile.confirm') : t('dialog.newFolder.confirm')} cancelText={inputDialog.type === 'newFile' ? t('dialog.newFile.cancel') : t('dialog.newFolder.cancel')} validator={(value) => { - // eslint-disable-next-line no-control-regex -- Windows filename rules explicitly forbid ASCII control characters. - if (!/^[^<>:"/\\|?*\x00-\x1F]+$/.test(value)) { + const validPattern = isRemoteCurrentWorkspace + // eslint-disable-next-line no-control-regex -- filename rules explicitly forbid ASCII control characters. + ? /^[^/\x00-\x1F]+$/ + // eslint-disable-next-line no-control-regex -- filename rules explicitly forbid ASCII control characters. + : /^[^<>:"/\\|?*\x00-\x1F]+$/; + if (!validPattern.test(value)) { return t('validation.invalidFilename'); } return null; diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 84a28afff..28ba448d8 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -247,6 +247,17 @@ describe('ModernFlowChatContainer historical empty state', () => { return rafCallbacks.length; })); vi.stubGlobal('cancelAnimationFrame', vi.fn()); + // jsdom in vitest 4.x may expose window.localStorage without a callable + // getItem; provide a minimal storage so shouldShowMockBackgroundActivities + // does not crash during render. + vi.stubGlobal('localStorage', { + getItem: vi.fn(() => null), + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + key: vi.fn(() => null), + length: 0, + }); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); diff --git a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts index 39b68fc2f..6de264468 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -262,10 +262,10 @@ export class WorkspaceAPI { } - async createFile(path: string): Promise { + async createFile(path: string, remoteConnectionId?: string): Promise { try { - await api.invoke('create_file', { - request: { path } + await api.invoke('create_file', { + request: { path, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('create_file', error, { path }); @@ -273,10 +273,10 @@ export class WorkspaceAPI { } - async deleteFile(path: string): Promise { + async deleteFile(path: string, remoteConnectionId?: string): Promise { try { - await api.invoke('delete_file', { - request: { path } + await api.invoke('delete_file', { + request: { path, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('delete_file', error, { path }); @@ -284,10 +284,10 @@ export class WorkspaceAPI { } - async createDirectory(path: string): Promise { + async createDirectory(path: string, remoteConnectionId?: string): Promise { try { - await api.invoke('create_directory', { - request: { path } + await api.invoke('create_directory', { + request: { path, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('create_directory', error, { path }); @@ -295,10 +295,10 @@ export class WorkspaceAPI { } - async deleteDirectory(path: string, recursive: boolean = true): Promise { + async deleteDirectory(path: string, recursive: boolean = true, remoteConnectionId?: string): Promise { try { - await api.invoke('delete_directory', { - request: { path, recursive } + await api.invoke('delete_directory', { + request: { path, recursive, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('delete_directory', error, { path, recursive }); @@ -898,10 +898,10 @@ export class WorkspaceAPI { } - async renameFile(oldPath: string, newPath: string): Promise { + async renameFile(oldPath: string, newPath: string, remoteConnectionId?: string): Promise { try { - await api.invoke('rename_file', { - request: { oldPath, newPath } + await api.invoke('rename_file', { + request: { oldPath, newPath, remoteConnectionId } }); } catch (error) { throw createTauriCommandError('rename_file', error, { oldPath, newPath }); diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/CopyPathCommand.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/CopyPathCommand.ts index 0c3a99301..e177c3e8a 100644 --- a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/CopyPathCommand.ts +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/CopyPathCommand.ts @@ -40,12 +40,18 @@ export class CopyPathCommand extends BaseCommand { async execute(context: MenuContext): Promise { try { const t = i18nService.getT(); - const filePath = getContextFilePath(context); + let filePath = getContextFilePath(context); if (!filePath) { return this.failure(t('errors:contextMenu.copyPathFailed')); } - + + // Convert forward slashes back to native backslashes on Windows-style + // paths (drive letters or UNC) so the clipboard yields OS-native paths. + if (/^[a-zA-Z]:\//.test(filePath) || filePath.startsWith('//')) { + filePath = filePath.replace(/\//g, '\\'); + } + if (navigator.clipboard) { await navigator.clipboard.writeText(filePath); } else { diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/DeleteCommand.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/DeleteCommand.ts index 296ae9c8c..e36e9c9c9 100644 --- a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/DeleteCommand.ts +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/DeleteCommand.ts @@ -5,6 +5,7 @@ import { CommandResult } from '../../../types/command.types'; import { MenuContext, ContextType, FileNodeContext } from '../../../types/context.types'; import { globalEventBus } from '../../../../../infrastructure/event-bus'; import { i18nService } from '@/infrastructure/i18n'; +import { confirmDanger } from '@/component-library/components/ConfirmDialog/confirmService'; export class DeleteFileCommand extends BaseCommand { constructor() { @@ -55,7 +56,9 @@ export class DeleteFileCommand extends BaseCommand { ? t('common:contextMenu.confirmDeleteFolder', { name: context.fileName }) : t('common:contextMenu.confirmDeleteFile', { name: context.fileName }); - return window.confirm(message); + return confirmDanger(t('common:file.delete'), message, { + confirmText: t('common:actions.delete'), + }); } } diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFileCommand.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFileCommand.ts index 445e8c33f..4cfa2c7ac 100644 --- a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFileCommand.ts +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFileCommand.ts @@ -5,6 +5,7 @@ import { CommandResult } from '../../../types/command.types'; import { MenuContext, ContextType, FileNodeContext } from '../../../types/context.types'; import { globalEventBus } from '../../../../../infrastructure/event-bus'; import { i18nService } from '../../../../../infrastructure/i18n'; +import { dirnameAbsolutePath } from '@/shared/utils/pathUtils'; export class NewFileCommand extends BaseCommand { constructor() { @@ -30,9 +31,9 @@ export class NewFileCommand extends BaseCommand { async execute(context: MenuContext): Promise { try { const fileContext = context as FileNodeContext; - const parentPath = fileContext.isDirectory - ? fileContext.filePath - : fileContext.filePath.substring(0, fileContext.filePath.lastIndexOf('/')); + const parentPath = fileContext.isDirectory + ? fileContext.filePath + : dirnameAbsolutePath(fileContext.filePath); globalEventBus.emit('file:new-file', { parentPath }); diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFolderCommand.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFolderCommand.ts index 56bc88632..4c1d8d4fb 100644 --- a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFolderCommand.ts +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/NewFolderCommand.ts @@ -5,6 +5,7 @@ import { CommandResult } from '../../../types/command.types'; import { MenuContext, ContextType, FileNodeContext } from '../../../types/context.types'; import { globalEventBus } from '../../../../../infrastructure/event-bus'; import { i18nService } from '../../../../../infrastructure/i18n'; +import { dirnameAbsolutePath } from '@/shared/utils/pathUtils'; export class NewFolderCommand extends BaseCommand { constructor() { @@ -30,9 +31,9 @@ export class NewFolderCommand extends BaseCommand { async execute(context: MenuContext): Promise { try { const fileContext = context as FileNodeContext; - const parentPath = fileContext.isDirectory - ? fileContext.filePath - : fileContext.filePath.substring(0, fileContext.filePath.lastIndexOf('/')); + const parentPath = fileContext.isDirectory + ? fileContext.filePath + : dirnameAbsolutePath(fileContext.filePath); globalEventBus.emit('file:new-folder', { parentPath }); diff --git a/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts b/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts index 053f964c4..5ec9d8c03 100644 --- a/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts +++ b/src/web-ui/src/shared/context-menu-system/providers/FileExplorerMenuProvider.ts @@ -12,6 +12,8 @@ import { addFileMentionToChat } from '@/shared/utils/chatContext'; import { dirnameAbsolutePath } from '@/shared/utils/pathUtils'; import { isHtmlFilePath } from '@/shared/utils/htmlFilePreview'; +const PASTE_SHORTCUT = /Mac|iPhone|iPad|iPod/.test(navigator.userAgent) ? 'Cmd+V' : 'Ctrl+V'; + export class FileExplorerMenuProvider implements IMenuProvider { readonly id = 'file-explorer'; readonly name = i18nService.t('common:contextMenu.fileExplorerMenu.name'); @@ -75,7 +77,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-paste', label: i18nService.t('common:actions.paste'), icon: 'Clipboard', - shortcut: 'Ctrl+V', + shortcut: PASTE_SHORTCUT, onClick: async () => { globalEventBus.emit('file:paste', { targetDirectory: parentPath }); } @@ -201,7 +203,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { id: 'file-paste', label: i18nService.t('common:actions.paste'), icon: 'Clipboard', - shortcut: 'Ctrl+V', + shortcut: PASTE_SHORTCUT, onClick: async () => { globalEventBus.emit('file:paste', { targetDirectory }); } diff --git a/src/web-ui/src/shared/utils/pathUtils.ts b/src/web-ui/src/shared/utils/pathUtils.ts index 23e48968e..8c6c1449e 100644 --- a/src/web-ui/src/shared/utils/pathUtils.ts +++ b/src/web-ui/src/shared/utils/pathUtils.ts @@ -119,13 +119,23 @@ export function replaceBasename(fullPath: string, newName: string): string { } /** - * Normalize for local rename IPC: `normalizePath` except skip UNC (`\\?\`, `\\server\...`) - * so we do not turn backslashes into slashes there. + * Normalize an OS-native local path for rename/delete IPC. + * + * Mirrors `normalizePath`'s separator and drive-letter normalization, but + * skips URI percent-decoding: the input is an OS-native path (from the file + * tree DOM), not a URI, so file names containing literal `%XX` sequences + * must be preserved. UNC paths (`\\?\`, `\\server\...`) are returned as-is + * so their backslashes are not turned into slashes. */ export function normalizeLocalPathForRename(path: string): string { const t = path.trim(); if (t.startsWith('\\\\')) return t; - return normalizePath(t); + let normalized = t.replace(/^file:\/+/, ''); + normalized = normalized.replace(/\\/g, '/'); + normalized = normalized.replace(/^\/+([a-zA-Z]:)/, '$1'); + normalized = normalized.replace(/^([a-z]):/, (_match, letter) => letter.toUpperCase() + ':'); + normalized = normalized.replace(/\/+/g, '/'); + return normalized; } /** From 95441b7828bcc4821c91c15914bd4ac51c8be823 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 4 Jul 2026 17:34:57 +0800 Subject: [PATCH 2/2] fix(desktop): use string literal in path segment join for Linux build Fix E0308 on ubuntu-latest where Slice::join expects &str, not char. --- src/apps/desktop/src/api/commands.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 677d8d819..08ce7012d 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -3201,7 +3201,7 @@ pub async fn reveal_in_explorer( .split('/') .map(|s| urlencoding::encode(s).to_string()) .collect::>() - .join('/'); + .join("/"); let file_uri = format!("file://{}", encoded_path); let dbus_ok = match bitfun_core::util::process_manager::create_command("dbus-send") .args([