diff --git a/src/apps/desktop/src/api/path_target.rs b/src/apps/desktop/src/api/path_target.rs index 87298e3689..f71306df76 100644 --- a/src/apps/desktop/src/api/path_target.rs +++ b/src/apps/desktop/src/api/path_target.rs @@ -590,7 +590,13 @@ pub async fn create_empty_file( ) -> Result<(), String> { match resolve_desktop_path_target(app_state, raw_path, preferred_remote_connection_id).await? { DesktopPathTarget::Local { resolved_path, .. } => { - let options = FileOperationOptions::default(); + if resolved_path.exists() { + return Err("Path already exists".to_string()); + } + let options = FileOperationOptions { + backup_on_overwrite: false, + ..FileOperationOptions::default() + }; app_state .filesystem_service .write_file_with_options(&resolved_path.to_string_lossy(), "", options) @@ -606,6 +612,13 @@ pub async fn create_empty_file( .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; + if remote_fs + .exists(&entry.connection_id, &requested_path) + .await + .map_err(|e| format!("Failed to check remote path: {}", e))? + { + return Err("Path already exists".to_string()); + } remote_fs .write_file(&entry.connection_id, &requested_path, b"") .await @@ -633,6 +646,13 @@ pub async fn create_directory( .get_remote_file_service_async() .await .map_err(|e| format!("Remote file service not available: {}", e))?; + if remote_fs + .exists(&entry.connection_id, &requested_path) + .await + .map_err(|e| format!("Failed to check remote path: {}", e))? + { + return Err("Path already exists".to_string()); + } remote_fs .create_dir_all(&entry.connection_id, &requested_path) .await diff --git a/src/crates/services/services-core/src/filesystem/operations.rs b/src/crates/services/services-core/src/filesystem/operations.rs index e019e0419e..6909641fb3 100644 --- a/src/crates/services/services-core/src/filesystem/operations.rs +++ b/src/crates/services/services-core/src/filesystem/operations.rs @@ -453,7 +453,7 @@ impl FileOperationService { self.validate_file_access(path, true).await?; - fs::create_dir_all(path) + fs::create_dir(path) .await .map_err(|e| FileSystemError::service(format!("Failed to create directory: {}", e)))?; diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index 5c61d97114..bd28fbebb7 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -59,6 +59,26 @@ const FOCUS_REFRESH_THROTTLE_MS = 1000; const REMOTE_REFRESH_POLL_MS = 15000; const LARGE_FILE_THRESHOLD_BYTES = 2 * 1024 * 1024; +function getChildNames(nodes: FileSystemNode[], parentPath: string): string[] { + for (const node of nodes) { + if (pathsEquivalentFs(node.path, parentPath)) { + return (node.children ?? []).map((child) => child.name); + } + if (node.children) { + const childNames = getChildNames(node.children, parentPath); + if (childNames.length > 0) { + return childNames; + } + } + } + return []; +} + +function isAlreadyExistsError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /already exists|file exists|os error 17|os error 183|EEXIST/i.test(message); +} + /** Format a byte-per-second speed value for display, e.g. "1.4 MB/s". */ function formatSpeed(bytesPerSec: number): string { return `${formatBytes(bytesPerSec)}/s`; @@ -262,6 +282,7 @@ const FilesPanel: React.FC = ({ expandFolder, expandFolderLazy, expandFolderEnsure, + collapseAll, removePath, } = useFileSystem({ rootPath: workspacePath, @@ -358,15 +379,23 @@ const FilesPanel: React.FC = ({ }); }, []); - const handleInputDialogClose = useCallback(() => { - setInputDialog({ - isOpen: false, - type: null, - parentPath: '', + const focusFileTree = useCallback(() => { + window.requestAnimationFrame(() => { + panelRef.current + ?.querySelector('[data-shortcut-scope="filetree"]') + ?.focus(); }); }, []); - const handleConfirmNewFile = useCallback(async (fileName: string) => { + const handleInputDialogClose = useCallback(() => { + setInputDialog((current) => ({ + ...current, + isOpen: false, + })); + focusFileTree(); + }, [focusFileTree]); + + const handleConfirmNewFile = useCallback(async (fileName: string): Promise => { const filePath = joinWorkspaceTargetPath( inputDialog.parentPath, fileName, @@ -376,13 +405,17 @@ const FilesPanel: React.FC = ({ try { await workspaceAPI.createFile(filePath, currentWorkspace?.connectionId); log.info('File created', { path: filePath }); - handleInputDialogClose(); - loadFileTree(workspacePath || '', true); + void loadFileTree(workspacePath || '', true); + return true; } catch (error) { log.error('Failed to create file', error); - notification.error(t('notifications.createFileFailed', { error: String(error) })); + const messageKey = isAlreadyExistsError(error) + ? 'notifications.createFileAlreadyExists' + : 'notifications.createFileFailed'; + notification.error(t(messageKey)); + return false; } - }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose, currentWorkspace]); + }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, currentWorkspace]); const handleNewFolder = useCallback((data: { parentPath: string }) => { setInputDialog({ @@ -392,7 +425,7 @@ const FilesPanel: React.FC = ({ }); }, []); - const handleConfirmNewFolder = useCallback(async (folderName: string) => { + const handleConfirmNewFolder = useCallback(async (folderName: string): Promise => { const folderPath = joinWorkspaceTargetPath( inputDialog.parentPath, folderName, @@ -402,20 +435,26 @@ const FilesPanel: React.FC = ({ try { await workspaceAPI.createDirectory(folderPath, currentWorkspace?.connectionId); log.info('Directory created', { path: folderPath }); - handleInputDialogClose(); - loadFileTree(workspacePath || '', true); + void loadFileTree(workspacePath || '', true); + return true; } catch (error) { log.error('Failed to create directory', error); - notification.error(t('notifications.createFolderFailed', { error: String(error) })); + const messageKey = isAlreadyExistsError(error) + ? 'notifications.createFolderAlreadyExists' + : 'notifications.createFolderFailed'; + notification.error(t(messageKey)); + return false; } - }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, handleInputDialogClose, currentWorkspace]); + }, [inputDialog.parentPath, workspacePath, loadFileTree, notification, t, currentWorkspace]); - const handleInputDialogConfirm = useCallback((value: string) => { + const handleInputDialogConfirm = useCallback((value: string): Promise | boolean => { if (inputDialog.type === 'newFile') { - handleConfirmNewFile(value); - } else if (inputDialog.type === 'newFolder') { - handleConfirmNewFolder(value); + return handleConfirmNewFile(value); + } + if (inputDialog.type === 'newFolder') { + return handleConfirmNewFolder(value); } + return false; }, [inputDialog.type, handleConfirmNewFile, handleConfirmNewFolder]); const handleStartRename = useCallback((data: { path: string; name: string }) => { @@ -750,6 +789,12 @@ const FilesPanel: React.FC = ({ () => handlePaste(), { enabled: Boolean(workspacePath) } ); + useShortcut( + 'filetree.collapseAll', + { key: '[', ctrl: true, shift: true, scope: 'filetree' }, + collapseAll, + { enabled: Boolean(workspacePath) && viewMode === 'tree' } + ); // macOS bridge: the native menu bar intercepts Cmd+V before the DOM sees a // keydown event, so ShortcutManager never fires. In "System" edit-menu mode @@ -1321,8 +1366,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) => { - const errorKey = validateFileName(value, { isRemote: isRemoteCurrentWorkspace }); - return errorKey ? t(errorKey) : null; + const siblingNames = getChildNames(fileTree, inputDialog.parentPath); + const errorKey = validateFileName(value, { + isRemote: isRemoteCurrentWorkspace, + siblings: siblingNames, + }); + return errorKey ? t(errorKey, { name: value.trim() }) : null; }} /> diff --git a/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx b/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx index ea5d7500f3..f52b2657a7 100644 --- a/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx +++ b/src/web-ui/src/app/scenes/file-viewer/FileViewerNav.tsx @@ -40,7 +40,7 @@ const FileViewerNav: React.FC = () => { }, []); return ( -
+