diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index d2accfd3..b80d4236 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -158,6 +158,13 @@ pub struct RenameFileRequest { pub new_path: String, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportLocalFileRequest { + pub source_path: String, + pub destination_path: String, +} + #[derive(Debug, Deserialize)] pub struct DeleteFileRequest { pub path: String, @@ -1579,6 +1586,25 @@ pub async fn rename_file( Ok(()) } +/// Copy a local file to another local path (binary-safe). Used for export and drag-upload into local workspaces. +#[tauri::command] +pub async fn export_local_file_to_path(request: ExportLocalFileRequest) -> Result<(), String> { + let src = request.source_path; + let dst = request.destination_path; + tokio::task::spawn_blocking(move || { + let dst_path = Path::new(&dst); + if let Some(parent) = dst_path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + } + std::fs::copy(&src, &dst).map_err(|e| e.to_string())?; + Ok::<(), String>(()) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn delete_file( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/ssh_api.rs b/src/apps/desktop/src/api/ssh_api.rs index f54491fb..33d32ec9 100644 --- a/src/apps/desktop/src/api/ssh_api.rs +++ b/src/apps/desktop/src/api/ssh_api.rs @@ -252,6 +252,54 @@ pub async fn remote_rename( .map_err(|e| e.to_string()) } +/// Read a remote file via SFTP and write it to a local path (binary-safe). +#[tauri::command] +pub async fn remote_download_to_local_path( + state: State<'_, AppState>, + connection_id: String, + remote_path: String, + local_path: String, +) -> Result<(), String> { + let remote_fs = state.get_remote_file_service_async().await?; + let bytes = remote_fs + .read_file(&connection_id, &remote_path) + .await + .map_err(|e| e.to_string())?; + + tokio::task::spawn_blocking(move || { + let path = std::path::Path::new(&local_path); + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + } + std::fs::write(path, &bytes).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())? +} + +/// Read a local file and write it to the remote path via SFTP (binary-safe). +#[tauri::command] +pub async fn remote_upload_from_local_path( + state: State<'_, AppState>, + connection_id: String, + local_path: String, + remote_path: String, +) -> Result<(), String> { + let bytes = tokio::task::spawn_blocking(move || { + std::fs::read(&local_path).map_err(|e| e.to_string()) + }) + .await + .map_err(|e| e.to_string())??; + + let remote_fs = state.get_remote_file_service_async().await?; + remote_fs + .write_file(&connection_id, &remote_path, &bytes) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn remote_execute( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index e1341270..b5aecab8 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -334,6 +334,7 @@ pub async fn run() { check_path_exists, get_file_metadata, rename_file, + export_local_file_to_path, reveal_in_explorer, get_file_tree, get_directory_children, @@ -656,6 +657,8 @@ pub async fn run() { api::ssh_api::remote_create_dir, api::ssh_api::remote_remove, api::ssh_api::remote_rename, + api::ssh_api::remote_download_to_local_path, + api::ssh_api::remote_upload_from_local_path, api::ssh_api::remote_execute, api::ssh_api::remote_open_workspace, api::ssh_api::remote_close_workspace, diff --git a/src/apps/desktop/src/theme.rs b/src/apps/desktop/src/theme.rs index a93023f4..2d5238a7 100644 --- a/src/apps/desktop/src/theme.rs +++ b/src/apps/desktop/src/theme.rs @@ -242,8 +242,7 @@ pub fn create_main_window(app_handle: &tauri::AppHandle) { .visible(false) .background_color(bg_color) .accept_first_mouse(true) - .initialization_script(&init_script) - .disable_drag_drop_handler(); + .initialization_script(&init_script); #[cfg(target_os = "macos")] { diff --git a/src/crates/core/src/service/remote_ssh/remote_fs.rs b/src/crates/core/src/service/remote_ssh/remote_fs.rs index 2fab7d0a..2b66d498 100644 --- a/src/crates/core/src/service/remote_ssh/remote_fs.rs +++ b/src/crates/core/src/service/remote_ssh/remote_fs.rs @@ -88,8 +88,10 @@ impl RemoteFileService { let is_file = entry.file_type().is_file(); // FileAttributes mtime is Unix timestamp in seconds; convert to milliseconds - // for JavaScript Date compatibility - let size = if is_file { metadata.size } else { None }; + // for JavaScript Date compatibility. + // Use size for any non-directory (regular files, symlinks, etc.). SFTP `is_file()` + // is false for symlinks and some file types, which previously hid size incorrectly. + let size = if is_dir { None } else { metadata.size }; let modified = metadata.mtime.map(|t| (t as u64) * 1000); // Get permissions string @@ -274,7 +276,7 @@ impl RemoteFileService { let is_symlink = attrs.is_symlink(); // File is neither dir nor symlink let is_file = !is_dir && !is_symlink; - let size = if is_file { attrs.size } else { None }; + let size = if is_dir { None } else { attrs.size }; let modified = attrs.mtime.map(|t| (t as u64) * 1000); let permissions = Some(format_permissions(attrs.permissions)); diff --git a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx index f0501e23..0b594a9e 100644 --- a/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx +++ b/src/web-ui/src/app/components/NavPanel/components/PersistentFooterActions.tsx @@ -1,5 +1,5 @@ import React, { useState, useCallback } from 'react'; -import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Wifi, Globe } from 'lucide-react'; +import { Settings, Info, MoreVertical, PictureInPicture2, SquareTerminal, Smartphone, Globe } from 'lucide-react'; import { Tooltip, Modal } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; import { useSceneManager } from '../../../hooks/useSceneManager'; @@ -162,7 +162,7 @@ const PersistentFooterActions: React.FC = () => { aria-disabled={!hasWorkspace} onClick={handleRemoteConnect} > - + {t('header.remoteConnect')} diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx index 9296e6c4..4e168d74 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/WorkspaceItem.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; -import { Folder, FolderOpen, MoreHorizontal, GitBranch, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw } from 'lucide-react'; +import { Folder, FolderOpen, MoreHorizontal, GitBranch, FolderSearch, Plus, ChevronDown, Trash2, RotateCcw, Copy } from 'lucide-react'; import { ConfirmDialog, Tooltip } from '@/component-library'; import { useI18n } from '@/infrastructure/i18n'; import { useWorkspaceContext } from '@/infrastructure/contexts/WorkspaceContext'; @@ -195,6 +195,7 @@ const WorkspaceItem: React.FC = ({ const handleReveal = useCallback(async () => { setMenuOpen(false); + if (isRemoteWorkspace(workspace)) return; try { await workspaceAPI.revealInExplorer(workspace.rootPath); } catch (error) { @@ -203,6 +204,21 @@ const WorkspaceItem: React.FC = ({ { duration: 4000 } ); } + }, [t, workspace]); + + const handleCopyWorkspacePath = useCallback(async () => { + setMenuOpen(false); + const path = workspace.rootPath; + if (!path) return; + try { + await navigator.clipboard.writeText(path); + notificationService.success(t('contextMenu.status.copyPathSuccess'), { duration: 2000 }); + } catch (error) { + notificationService.error( + error instanceof Error ? error.message : t('nav.workspaces.copyPathFailed'), + { duration: 4000 } + ); + } }, [t, workspace.rootPath]); const handleCreateSession = useCallback(async (mode?: 'agentic' | 'Cowork' | 'Claw') => { @@ -360,7 +376,21 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.deleteAssistant')} )} - + @@ -507,7 +537,21 @@ const WorkspaceItem: React.FC = ({ {t('nav.workspaces.actions.newWorktree')} - + diff --git a/src/web-ui/src/app/components/panels/FilesPanel.scss b/src/web-ui/src/app/components/panels/FilesPanel.scss index be554a00..274c110e 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.scss +++ b/src/web-ui/src/app/components/panels/FilesPanel.scss @@ -158,6 +158,56 @@ &__main-content { flex: 1; overflow: hidden; + transition: box-shadow $motion-base $easing-standard, background $motion-base $easing-standard; + + &--drop-target { + box-shadow: inset 0 0 0 2px $color-accent-500; + background: var(--element-bg-subtle); + } + } + + &__transfer { + flex-shrink: 0; + padding: $size-gap-2 $size-gap-3; + border-top: 1px solid $border-base; + background: var(--color-bg-secondary); + } + + &__transfer-label { + font-size: $font-size-xs; + color: var(--color-text-secondary); + margin-bottom: $size-gap-1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__transfer-track { + height: 4px; + border-radius: $size-radius-full; + background: var(--element-bg-base); + overflow: hidden; + } + + &__transfer-track--indeterminate .bitfun-files-panel__transfer-fill { + width: 40% !important; + animation: bitfun-files-panel-transfer-indeterminate 1.1s ease-in-out infinite; + } + + &__transfer-fill { + height: 100%; + border-radius: $size-radius-full; + background: $color-accent-500; + transition: width $motion-base $easing-standard; + } + + @keyframes bitfun-files-panel-transfer-indeterminate { + 0% { + transform: translateX(-100%); + } + 100% { + transform: translateX(350%); + } } &__explorer { diff --git a/src/web-ui/src/app/components/panels/FilesPanel.tsx b/src/web-ui/src/app/components/panels/FilesPanel.tsx index e850a24e..207291e2 100644 --- a/src/web-ui/src/app/components/panels/FilesPanel.tsx +++ b/src/web-ui/src/app/components/panels/FilesPanel.tsx @@ -19,6 +19,15 @@ import { InputDialog, CubeLoading } from '@/component-library'; import { openFileInBestTarget } from '@/shared/utils/tabUtils'; import { PanelHeader } from './base'; import { createLogger } from '@/shared/utils/logger'; +import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; +import { isRemoteWorkspace } from '@/shared/types'; +import { + downloadWorkspaceFileToDisk, + isDragPositionOverElement, + resolveDropTargetDirectoryFromDragPosition, + uploadLocalPathsToWorkspaceDirectory, + type TransferProgressState, +} from '@/tools/file-system/services/workspaceFileTransfer'; import '@/tools/file-system/styles/FileExplorer.scss'; import './FilesPanel.scss'; @@ -65,6 +74,8 @@ const FilesPanel: React.FC = ({ }); const [renamingPath, setRenamingPath] = useState(null); + const [transferProgress, setTransferProgress] = useState(null); + const [fileDropHighlight, setFileDropHighlight] = useState(false); const [inputDialog, setInputDialog] = useState<{ isOpen: boolean; type: 'newFile' | 'newFolder' | null; @@ -259,6 +270,9 @@ const FilesPanel: React.FC = ({ }, [workspacePath, loadFileTree, notification, t]); const handleReveal = useCallback(async (data: { path: string }) => { + if (isRemoteWorkspace(workspaceManager.getState().currentWorkspace)) { + return; + } try { await workspaceAPI.revealInExplorer(data.path); } catch (error) { @@ -267,6 +281,20 @@ const FilesPanel: React.FC = ({ } }, [notification, t]); + const handleFileDownload = useCallback( + async (data: { path: string }) => { + const ws = workspaceManager.getState().currentWorkspace; + try { + await downloadWorkspaceFileToDisk(data.path, ws, setTransferProgress); + } catch (error) { + log.error('Failed to download file', error); + setTransferProgress(null); + notification.error(t('transfer.failed', { error: String(error) })); + } + }, + [notification, t] + ); + const handleFileTreeRefresh = useCallback(() => { loadFileTree(undefined, true); }, [loadFileTree]); @@ -441,6 +469,7 @@ const FilesPanel: React.FC = ({ globalEventBus.on('file:rename', handleStartRename); globalEventBus.on('file:delete', handleDelete); globalEventBus.on('file:reveal', handleReveal); + globalEventBus.on('file:download', handleFileDownload); globalEventBus.on('file:paste', handlePasteFromContextMenu); globalEventBus.on('file-tree:refresh', handleFileTreeRefresh); globalEventBus.on('file-explorer:navigate', handleNavigateToPath); @@ -452,11 +481,93 @@ const FilesPanel: React.FC = ({ globalEventBus.off('file:rename', handleStartRename); globalEventBus.off('file:delete', handleDelete); globalEventBus.off('file:reveal', handleReveal); + globalEventBus.off('file:download', handleFileDownload); globalEventBus.off('file:paste', handlePasteFromContextMenu); globalEventBus.off('file-tree:refresh', handleFileTreeRefresh); globalEventBus.off('file-explorer:navigate', handleNavigateToPath); }; - }, [handleOpenFile, handleNewFile, handleNewFolder, handleStartRename, handleDelete, handleReveal, handlePasteFromContextMenu, handleFileTreeRefresh, handleNavigateToPath]); + }, [handleOpenFile, handleNewFile, handleNewFolder, handleStartRename, handleDelete, handleReveal, handleFileDownload, handlePasteFromContextMenu, handleFileTreeRefresh, handleNavigateToPath]); + + useEffect(() => { + if (typeof window === 'undefined' || !('__TAURI__' in window) || !workspacePath) { + return; + } + + let unlisten: (() => void) | undefined; + let cancelled = false; + let lastEnterPaths: string[] = []; + + const setup = async () => { + try { + // File-drop IPC is scoped to the webview; Window.onDragDropEvent may not receive events. + const { getCurrentWebview } = await import('@tauri-apps/api/webview'); + const webview = getCurrentWebview(); + unlisten = await webview.onDragDropEvent(async (event) => { + if (cancelled) return; + const payload = event.payload; + if (payload.type === 'leave') { + setFileDropHighlight(false); + lastEnterPaths = []; + return; + } + if (payload.type === 'enter') { + lastEnterPaths = payload.paths; + return; + } + if (payload.type === 'over') { + const factor = await webview.window.scaleFactor(); + const panelEl = panelRef.current; + setFileDropHighlight( + isDragPositionOverElement(payload.position, factor, panelEl) + ); + return; + } + if (payload.type === 'drop') { + setFileDropHighlight(false); + const paths = + payload.paths.length > 0 ? payload.paths : [...lastEnterPaths]; + lastEnterPaths = []; + if (!workspacePath || paths.length === 0) { + return; + } + + const factor = await webview.window.scaleFactor(); + const targetDir = resolveDropTargetDirectoryFromDragPosition( + payload.position, + factor, + workspacePath + ); + + const ws = workspaceManager.getState().currentWorkspace; + try { + await uploadLocalPathsToWorkspaceDirectory( + paths, + targetDir, + ws, + setTransferProgress + ); + loadFileTree(workspacePath, true); + if (targetDir !== workspacePath) { + expandFolder(targetDir, true); + } + } catch (error) { + log.error('Failed to upload dropped files', error); + setTransferProgress(null); + notification.error(t('transfer.failed', { error: String(error) })); + } + } + }); + } catch (e) { + log.warn('File drag-drop listener not available', e); + } + }; + + void setup(); + return () => { + cancelled = true; + unlisten?.(); + }; + }, [workspacePath, loadFileTree, expandFolder, notification, t]); const handleFileSelect = useCallback((filePath: string, fileName: string) => { selectFile(filePath); @@ -558,7 +669,11 @@ const FilesPanel: React.FC = ({ )} -
+
{!workspacePath ? (
@@ -661,6 +776,36 @@ const FilesPanel: React.FC = ({
+ {transferProgress && ( +
+
+ {transferProgress.phase === 'download' + ? t('transfer.downloading') + : t('transfer.uploading')} + {transferProgress.label ? ` — ${transferProgress.label}` : ''} +
+
+
+
+
+ )} + = ({ children, size = 'medium', contentInset = false, + contentClassName, showCloseButton = true, draggable = false, resizable = false, @@ -278,7 +281,15 @@ export const Modal: React.FC = ({
)} -
+
{children}
diff --git a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.scss b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.scss index 810909fb..07b89004 100644 --- a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.scss +++ b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.scss @@ -12,6 +12,14 @@ backdrop-filter: blur(4px); z-index: 260; animation: fadeIn 0.2s ease; + + // Beat `.bitfun-app-layout *:focus-visible` (same specificity as a single-class :focus-visible; load order can win). + .remote-file-browser__path-input:focus, + .remote-file-browser__path-input:focus-visible { + outline: none !important; + outline-offset: 0 !important; + box-shadow: none !important; + } } @keyframes fadeIn { @@ -76,8 +84,18 @@ padding: 10px 20px; background: var(--color-bg-tertiary); border-bottom: 1px solid var(--border-subtle); - overflow-x: auto; flex-shrink: 0; + min-height: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + width: 0; + height: 0; + } } &__breadcrumb-path { @@ -86,6 +104,7 @@ gap: 4px; flex: 1; min-width: 0; + flex-wrap: nowrap; cursor: text; border-radius: 6px; padding: 2px 4px; @@ -108,6 +127,15 @@ font-family: var(--font-family-mono); color: var(--color-text-primary); outline: none; + box-shadow: none; + overflow: hidden; + + &:focus, + &:focus-visible { + outline: none !important; + outline-offset: 0 !important; + box-shadow: none !important; + } } &__breadcrumb-btn { @@ -181,6 +209,21 @@ } } + &__transfer-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 20px; + font-size: 13px; + color: var(--color-text-secondary); + border-bottom: 1px solid var(--border-subtle); + flex-shrink: 0; + + .remote-file-browser__spinner-inline { + animation: spin 0.8s linear infinite; + } + } + // Content area &__content { flex: 1; @@ -229,6 +272,16 @@ &__table { width: 100%; border-collapse: collapse; + + th, + td { + border-left: none; + border-right: none; + } + + tbody td { + border-bottom: none; + } } &__thead { @@ -243,6 +296,7 @@ font-weight: 600; color: var(--color-text-muted); text-align: left; + background: var(--color-bg-secondary); border-bottom: 1px solid var(--border-subtle); text-transform: uppercase; letter-spacing: 0.5px; @@ -250,15 +304,28 @@ } } + &__thead th#{&}__th--size, + &__thead th#{&}__th--date { + text-align: right; + } + + &__tbody td#{&}__td--size, + &__tbody td#{&}__td--date { + text-align: right; + } + &__th { &--name { width: auto; } &--size { - width: 100px; + width: 152px; + min-width: 152px; + box-sizing: border-box; } &--date { width: 120px; + min-width: 120px; } } @@ -288,20 +355,30 @@ } } + &__name-cell { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + } + &__td { &--name { - display: flex; - align-items: center; - gap: 10px; + vertical-align: middle; } &--size { color: var(--color-text-muted); font-family: var(--font-family-mono); font-size: 12px; + white-space: nowrap; + min-width: 152px; + box-sizing: border-box; + text-align: right; } &--date { color: var(--color-text-muted); font-size: 12px; + text-align: right; } } diff --git a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx index c291eadf..ddee181f 100644 --- a/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx +++ b/src/web-ui/src/features/ssh-remote/RemoteFileBrowser.tsx @@ -19,6 +19,8 @@ import { Home, ArrowLeft, Loader2, + Upload, + Download, } from 'lucide-react'; import './RemoteFileBrowser.scss'; @@ -41,6 +43,19 @@ interface DeleteConfirmState { entry: RemoteFileEntry | null; } +function joinRemotePath(dir: string, fileName: string): string { + const name = fileName.replace(/^\/+/, ''); + if (!dir || dir === '/') { + return `/${name}`; + } + const base = dir.endsWith('/') ? dir.slice(0, -1) : dir; + return `${base}/${name}`; +} + +function isTauriDesktop(): boolean { + return typeof window !== 'undefined' && '__TAURI__' in window; +} + export const RemoteFileBrowser: React.FC = ({ connectionId, initialPath = '/', @@ -68,6 +83,7 @@ export const RemoteFileBrowser: React.FC = ({ show: false, entry: null, }); + const [transferBusy, setTransferBusy] = useState(false); const contextMenuRef = useRef(null); useEffect(() => { @@ -175,6 +191,10 @@ export const RemoteFileBrowser: React.FC = ({ setRenameEntry(entry); setRenameValue(entry.name); break; + case 'download': { + void handleDownloadEntry(entry); + break; + } } } catch (e) { setError(e instanceof Error ? e.message : 'Operation failed'); @@ -222,12 +242,70 @@ export const RemoteFileBrowser: React.FC = ({ return '/' + parts.join('/'); }; + const handleDownloadEntry = async (entry: RemoteFileEntry) => { + if (entry.isDir) return; + if (!isTauriDesktop()) { + setError(t('ssh.remote.transferNeedsDesktop')); + return; + } + const { save } = await import('@tauri-apps/plugin-dialog'); + const localPath = await save({ + title: t('ssh.remote.downloadDialogTitle'), + defaultPath: entry.name, + }); + if (localPath === null) return; + + setTransferBusy(true); + setError(null); + try { + await sshApi.downloadToLocalPath(connectionId, entry.path, localPath); + } catch (e) { + setError(e instanceof Error ? e.message : t('ssh.remote.transferFailed')); + } finally { + setTransferBusy(false); + } + }; + + const handleUploadToCurrentDir = async () => { + if (!isTauriDesktop()) { + setError(t('ssh.remote.transferNeedsDesktop')); + return; + } + const { open } = await import('@tauri-apps/plugin-dialog'); + const selected = await open({ + title: t('ssh.remote.uploadDialogTitle'), + multiple: true, + directory: false, + }); + if (selected === null) return; + const paths = Array.isArray(selected) ? selected : [selected]; + if (paths.length === 0) return; + + setTransferBusy(true); + setError(null); + try { + for (const localPath of paths) { + const segments = localPath.split(/[/\\]/); + const base = segments.pop(); + if (!base) continue; + const remotePath = joinRemotePath(currentPath, base); + await sshApi.uploadFromLocalPath(connectionId, localPath, remotePath); + } + await loadDirectory(currentPath); + } catch (e) { + setError(e instanceof Error ? e.message : t('ssh.remote.transferFailed')); + } finally { + setTransferBusy(false); + } + }; + const openSelectedWorkspace = () => { onSelect(selectedPath || currentPath); }; const formatFileSize = (bytes?: number): string => { - if (!bytes) return '-'; + if (bytes === undefined || bytes === null) return '-'; + if (bytes === 0) return '0 B'; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`; @@ -236,7 +314,11 @@ export const RemoteFileBrowser: React.FC = ({ const formatDate = (timestamp?: number): string => { if (!timestamp) return '-'; - return new Date(timestamp).toLocaleDateString(); + const d = new Date(timestamp); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}/${m}/${day}`; }; const getEntryIcon = (entry: RemoteFileEntry) => { @@ -319,6 +401,7 @@ export const RemoteFileBrowser: React.FC = ({ className="remote-file-browser__toolbar-btn" onClick={() => loadDirectory(currentPath)} title={t('actions.refresh')} + disabled={transferBusy} > @@ -326,12 +409,28 @@ export const RemoteFileBrowser: React.FC = ({ className="remote-file-browser__toolbar-btn" onClick={() => navigateTo(getParentPath(currentPath) || '/')} title="Go up" - disabled={currentPath === '/'} + disabled={currentPath === '/' || transferBusy} > +
+ {transferBusy && ( +
+ + {t('ssh.remote.transferring')} +
+ )} + {/* File List */}
{error && ( @@ -386,8 +485,10 @@ export const RemoteFileBrowser: React.FC = ({ className={`remote-file-browser__row ${selectedPath === entry.path ? 'remote-file-browser__row--selected' : ''}`} > - {getEntryIcon(entry)} - {entry.name} +
+ {getEntryIcon(entry)} + {entry.name} +
{entry.isDir ? '-' : formatFileSize(entry.size)} @@ -423,6 +524,16 @@ export const RemoteFileBrowser: React.FC = ({ {t('actions.open') || 'Open'} + {!contextMenu.entry.isDir && ( + + )} +
))}
@@ -568,6 +590,7 @@ export const SSHConnectionDialog: React.FC = ({ )} + {/* Actions */}
diff --git a/src/web-ui/src/features/ssh-remote/sshApi.ts b/src/web-ui/src/features/ssh-remote/sshApi.ts index c07b5028..e159967f 100644 --- a/src/web-ui/src/features/ssh-remote/sshApi.ts +++ b/src/web-ui/src/features/ssh-remote/sshApi.ts @@ -156,6 +156,36 @@ export const sshApi = { return api.invoke('remote_rename', { connectionId, oldPath, newPath }); }, + /** + * Download a remote file to a local filesystem path (desktop; binary-safe). + */ + async downloadToLocalPath( + connectionId: string, + remotePath: string, + localPath: string + ): Promise { + return api.invoke('remote_download_to_local_path', { + connectionId, + remotePath, + localPath, + }); + }, + + /** + * Upload a local file to a remote path (desktop; binary-safe). + */ + async uploadFromLocalPath( + connectionId: string, + localPath: string, + remotePath: string + ): Promise { + return api.invoke('remote_upload_from_local_path', { + connectionId, + localPath, + remotePath, + }); + }, + /** * Execute command on remote server */ diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.css b/src/web-ui/src/flow_chat/components/WelcomePanel.css index 982aedc9..0566ed60 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.css +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.css @@ -120,6 +120,19 @@ color: var(--color-text-muted); } +/* One flex row: lead text, workspace/branch controls, trail text share the same vertical center */ +.welcome-panel__narrative-sentence { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + column-gap: 0.35em; + row-gap: 0.2em; +} + +.welcome-panel__narrative-sentence__text { + line-height: inherit; +} + .welcome-panel__inline-btn { display: inline-flex; align-items: center; @@ -130,10 +143,13 @@ border: none; border-radius: 5px; color: var(--color-text-primary); - font: inherit; + font-size: inherit; + font-family: inherit; font-weight: 600; + /* Do not inherit narrative line-height (1.8); it inflates the flex line box vs fixed-size SVGs */ + line-height: 1.2; cursor: pointer; - vertical-align: -0.1em; + vertical-align: middle; transition: background 0.15s; } @@ -152,12 +168,14 @@ .welcome-panel__inline-icon { flex-shrink: 0; + display: block; } .welcome-panel__inline-chevron { opacity: 0.5; transition: transform 0.2s ease; flex-shrink: 0; + display: block; } .welcome-panel__inline-chevron--open { @@ -174,7 +192,6 @@ display: inline-flex; align-items: center; flex-wrap: nowrap; - vertical-align: -0.1em; margin: 0 1px; } diff --git a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx index 9b20e8d1..8ebc7703 100644 --- a/src/web-ui/src/flow_chat/components/WelcomePanel.tsx +++ b/src/web-ui/src/flow_chat/components/WelcomePanel.tsx @@ -208,74 +208,78 @@ export const WelcomePanel: React.FC = ({ ) : ( <> - {isCoworkSession ? t('welcome.workingInCowork') : t('welcome.workingIn')}{' '} - - - - {workspaceDropdownOpen && ( -
- {hasWorkspace && currentWorkspace && ( -
- - - {currentWorkspace.name} -
- )} - {otherWorkspaces.length > 0 && ( - <> - {hasWorkspace &&
} - {otherWorkspaces.map(ws => ( - - ))} - - )} -
- )} + + + {isCoworkSession ? t('welcome.workingInCowork') : t('welcome.workingIn')} - {!isCoworkSession && gitState && ( - <> - / - - - )} + {workspaceDropdownOpen && ( +
+ {hasWorkspace && currentWorkspace && ( +
+ + + {currentWorkspace.name} +
+ )} + {otherWorkspaces.length > 0 && ( + <> + {hasWorkspace &&
} + {otherWorkspaces.map(ws => ( + + ))} + + )} +
+ )} + + {!isCoworkSession && gitState && ( + <> + / + + + )} + + + {!isCoworkSession && gitState ? t('welcome.project') : t('welcome.projectCowork')} + {!isCoworkSession && gitState ? ( <> - {t('welcome.project')}
{isGitClean ? {t('welcome.gitClean')} : buildGitNarrative() } - ) : ( - <>{t('welcome.projectCowork')} - )} + ) : null} )}

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 dce42841..331bc965 100644 --- a/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/WorkspaceAPI.ts @@ -327,6 +327,22 @@ export class WorkspaceAPI { } } + /** + * Copy a local file to another local path (binary-safe). + */ + async exportLocalFileToPath(sourcePath: string, destinationPath: string): Promise { + try { + await api.invoke('export_local_file_to_path', { + request: { sourcePath, destinationPath }, + }); + } catch (error) { + throw createTauriCommandError('export_local_file_to_path', error, { + sourcePath, + destinationPath, + }); + } + } + async revealInExplorer(path: string): Promise { try { diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 82441d72..8addf33f 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -268,6 +268,7 @@ "dropBefore": "Release to insert before", "dropAfter": "Release to insert after", "revealFailed": "Failed to reveal workspace folder", + "copyPathFailed": "Failed to copy path", "createSessionFailed": "Failed to create session", "worktreeCreated": "Worktree created", "worktreeCreateFailed": "Failed to create worktree: {{error}}", @@ -286,6 +287,7 @@ "deleteAssistant": "Delete personal assistant", "resetWorkspace": "Reset workspace", "newWorktree": "New worktree", + "copyPath": "Copy path", "reveal": "Reveal in explorer", "close": "Close workspace" }, @@ -634,6 +636,8 @@ "reveal": "Reveal in Explorer", "copyPath": "Copy Path", "copyRelativePath": "Copy Relative Path", + "download": "Download", + "downloadSaveTitle": "Save file as", "newFileDescription": "Create a new file in the current folder", "newFolderDescription": "Create a new folder in the current folder", "renameDescription": "Rename a file or folder", @@ -921,7 +925,14 @@ "delete": "Delete", "deleteTitle": "Confirm Delete", "deleteConfirm": "Are you sure you want to delete this file/folder? This action cannot be undone.", - "rename": "Rename" + "rename": "Rename", + "upload": "Upload to remote", + "download": "Download to local", + "uploadDialogTitle": "Select files to upload", + "downloadDialogTitle": "Save file as", + "transferFailed": "Transfer failed", + "transferNeedsDesktop": "Upload and download require the desktop app.", + "transferring": "Transferring..." } } } diff --git a/src/web-ui/src/locales/en-US/panels/files.json b/src/web-ui/src/locales/en-US/panels/files.json index f0e353eb..5c8cdcb7 100644 --- a/src/web-ui/src/locales/en-US/panels/files.json +++ b/src/web-ui/src/locales/en-US/panels/files.json @@ -40,6 +40,13 @@ "validation": { "invalidFilename": "File name contains invalid characters" }, + "transfer": { + "downloading": "Downloading", + "uploading": "Uploading", + "missingConnection": "Remote workspace has no SSH connection id.", + "failed": "File transfer failed: {{error}}", + "dropHint": "Drop files here to upload" + }, "notifications": { "createFileSuccess": "File created successfully", "createFileFailed": "Failed to create file: {{error}}", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index b7bbe2da..54bbabb7 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -268,6 +268,7 @@ "dropBefore": "松开后插入到此项前", "dropAfter": "松开后插入到此项后", "revealFailed": "打开工作区目录失败", + "copyPathFailed": "复制路径失败", "createSessionFailed": "新建会话失败", "worktreeCreated": "已创建 worktree", "worktreeCreateFailed": "创建 worktree 失败:{{error}}", @@ -286,6 +287,7 @@ "deleteAssistant": "删除个人助理", "resetWorkspace": "重置工作区", "newWorktree": "新建 worktree", + "copyPath": "复制路径", "reveal": "在资源管理器中打开", "close": "关闭工作区" }, @@ -634,6 +636,8 @@ "reveal": "在资源管理器中显示", "copyPath": "复制路径", "copyRelativePath": "复制相对路径", + "download": "下载", + "downloadSaveTitle": "保存文件", "newFileDescription": "在当前文件夹中创建新文件", "newFolderDescription": "在当前文件夹中创建新文件夹", "renameDescription": "重命名文件或文件夹", @@ -921,7 +925,14 @@ "delete": "删除", "deleteTitle": "确认删除", "deleteConfirm": "确定要删除此文件/文件夹吗?此操作无法撤销。", - "rename": "重命名" + "rename": "重命名", + "upload": "上传到远程", + "download": "下载到本地", + "uploadDialogTitle": "选择要上传的文件", + "downloadDialogTitle": "保存文件", + "transferFailed": "传输失败", + "transferNeedsDesktop": "上传与下载仅适用于桌面版应用。", + "transferring": "正在传输…" } } } diff --git a/src/web-ui/src/locales/zh-CN/panels/files.json b/src/web-ui/src/locales/zh-CN/panels/files.json index 7a1bf3ca..a159316d 100644 --- a/src/web-ui/src/locales/zh-CN/panels/files.json +++ b/src/web-ui/src/locales/zh-CN/panels/files.json @@ -40,6 +40,13 @@ "validation": { "invalidFilename": "文件名包含非法字符" }, + "transfer": { + "downloading": "正在下载", + "uploading": "正在上传", + "missingConnection": "远程工作区缺少 SSH 连接信息。", + "failed": "文件传输失败:{{error}}", + "dropHint": "将文件拖放到此处以上传" + }, "notifications": { "createFileSuccess": "文件创建成功", "createFileFailed": "创建文件失败: {{error}}", diff --git a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RevealInExplorerCommand.ts b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RevealInExplorerCommand.ts index 14ee474c..92bdfd55 100644 --- a/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RevealInExplorerCommand.ts +++ b/src/web-ui/src/shared/context-menu-system/commands/builtin/file/RevealInExplorerCommand.ts @@ -5,6 +5,8 @@ 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 { workspaceManager } from '../../../../../infrastructure/services/business/workspaceManager'; +import { isRemoteWorkspace } from '../../../../../shared/types'; export class RevealInExplorerCommand extends BaseCommand { constructor() { @@ -18,8 +20,11 @@ export class RevealInExplorerCommand extends BaseCommand { } canExecute(context: MenuContext): boolean { - return context.type === ContextType.FILE_NODE || - context.type === ContextType.FOLDER_NODE; + const isFileOrFolder = + context.type === ContextType.FILE_NODE || context.type === ContextType.FOLDER_NODE; + if (!isFileOrFolder) return false; + if (isRemoteWorkspace(workspaceManager.getState().currentWorkspace)) return false; + return true; } async execute(context: MenuContext): Promise { 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 d0c8b7a3..d38672c6 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 @@ -6,6 +6,8 @@ import { MenuContext, ContextType, FileNodeContext } from '../types/context.type import { commandExecutor } from '../commands/CommandExecutor'; import { globalEventBus } from '../../../infrastructure/event-bus'; import { i18nService } from '../../../infrastructure/i18n'; +import { workspaceManager } from '../../../infrastructure/services/business/workspaceManager'; +import { isRemoteWorkspace } from '../../../shared/types'; export class FileExplorerMenuProvider implements IMenuProvider { readonly id = 'file-explorer'; @@ -30,8 +32,8 @@ export class FileExplorerMenuProvider implements IMenuProvider { async getMenuItems(context: MenuContext): Promise { const items: MenuItem[] = []; - - + const revealInExplorerDisabled = isRemoteWorkspace(workspaceManager.getState().currentWorkspace); + if (context.type === ContextType.EMPTY_SPACE) { const emptyContext = context as any; @@ -96,6 +98,15 @@ export class FileExplorerMenuProvider implements IMenuProvider { } }); + items.push({ + id: 'file-download', + label: i18nService.t('common:file.download'), + icon: 'Download', + onClick: () => { + globalEventBus.emit('file:download', { path: fileContext.filePath }); + } + }); + items.push({ id: 'file-separator-1', label: '', @@ -219,6 +230,7 @@ export class FileExplorerMenuProvider implements IMenuProvider { label: i18nService.t('common:file.reveal'), icon: 'FolderOpen', command: 'file.reveal-in-explorer', + disabled: revealInExplorerDisabled, onClick: async (ctx) => { await commandExecutor.execute('file.reveal-in-explorer', ctx); } diff --git a/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts new file mode 100644 index 00000000..be48a6c9 --- /dev/null +++ b/src/web-ui/src/tools/file-system/services/workspaceFileTransfer.ts @@ -0,0 +1,214 @@ +/** + * Upload / download between workspace (local or remote SFTP) and local disk. + */ + +import { PhysicalPosition } from '@tauri-apps/api/dpi'; +import { sshApi } from '@/features/ssh-remote/sshApi'; +import { workspaceAPI } from '@/infrastructure/api'; +import { i18nService } from '@/infrastructure/i18n'; +import { isRemoteWorkspace, type WorkspaceInfo } from '@/shared/types'; + +export type TransferPhase = 'download' | 'upload'; + +export interface TransferProgressState { + phase: TransferPhase; + current: number; + total: number; + label: string; + /** Single-file transfer: no byte-level progress from backend — show indeterminate bar */ + indeterminate?: boolean; +} + +function isTauri(): boolean { + return typeof window !== 'undefined' && '__TAURI__' in window; +} + +export function joinWorkspaceTargetPath(dir: string, fileName: string): string { + const sep = dir.includes('\\') ? '\\' : '/'; + const base = dir.replace(/[/\\]+$/, ''); + return `${base}${sep}${fileName}`; +} + +export function getParentPathFromFile(filePath: string): string { + const isWin = filePath.includes('\\'); + const sep = isWin ? '\\' : '/'; + const parts = filePath.split(sep); + parts.pop(); + return parts.join(sep); +} + +export function resolveExplorerDropTargetDirectory( + clientX: number, + clientY: number, + workspacePath: string +): string { + const el = document.elementFromPoint(clientX, clientY); + if (!el) { + return workspacePath; + } + const explorer = el.closest('.bitfun-file-explorer'); + if (!explorer) { + return workspacePath; + } + const node = el.closest('[data-file-path]'); + if (!node) { + return workspacePath; + } + const path = node.getAttribute('data-file-path'); + if (!path) { + return workspacePath; + } + const isDir = node.getAttribute('data-is-directory') === 'true'; + if (isDir) { + return path; + } + return getParentPathFromFile(path) || workspacePath; +} + +/** + * Tauri emits physical pixel positions; `elementFromPoint` / `getBoundingClientRect` use logical CSS pixels. + * Try a few conversions because platform / overlay titlebars can differ. + */ +export function resolveDropTargetDirectoryFromDragPosition( + position: { x: number; y: number }, + scaleFactor: number, + workspacePath: string +): string { + const logical = new PhysicalPosition(position.x, position.y).toLogical(scaleFactor); + const candidates: { x: number; y: number }[] = [ + { x: logical.x, y: logical.y }, + { x: position.x, y: position.y }, + { x: position.x / scaleFactor, y: position.y / scaleFactor }, + ]; + for (const { x, y } of candidates) { + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + const hit = document.elementFromPoint(x, y); + if (!hit?.closest('.bitfun-file-explorer')) { + continue; + } + return resolveExplorerDropTargetDirectory(x, y, workspacePath); + } + return workspacePath; +} + +export function isDragPositionOverElement( + position: { x: number; y: number }, + scaleFactor: number, + element: HTMLElement | null +): boolean { + if (!element) { + return false; + } + const rect = element.getBoundingClientRect(); + const logical = new PhysicalPosition(position.x, position.y).toLogical(scaleFactor); + const candidates = [ + { x: logical.x, y: logical.y }, + { x: position.x, y: position.y }, + { x: position.x / scaleFactor, y: position.y / scaleFactor }, + ]; + for (const { x, y } of candidates) { + if (!Number.isFinite(x) || !Number.isFinite(y)) { + continue; + } + if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) { + return true; + } + } + return false; +} + +export async function downloadWorkspaceFileToDisk( + filePath: string, + workspace: WorkspaceInfo | null, + onProgress: (state: TransferProgressState | null) => void +): Promise { + if (!isTauri()) { + throw new Error(i18nService.t('common:ssh.remote.transferNeedsDesktop')); + } + const { save } = await import('@tauri-apps/plugin-dialog'); + const baseName = filePath.split(/[/\\]/).pop() || 'file'; + const dest = await save({ + title: i18nService.t('common:file.downloadSaveTitle'), + defaultPath: baseName, + }); + if (dest === null) { + return; + } + + onProgress({ + phase: 'download', + current: 0, + total: 1, + label: baseName, + indeterminate: true, + }); + try { + if (isRemoteWorkspace(workspace)) { + const cid = workspace?.connectionId; + if (!cid) { + throw new Error(i18nService.t('panels/files:transfer.missingConnection')); + } + await sshApi.downloadToLocalPath(cid, filePath, dest); + } else { + await workspaceAPI.exportLocalFileToPath(filePath, dest); + } + onProgress({ + phase: 'download', + current: 1, + total: 1, + label: baseName, + indeterminate: false, + }); + } finally { + window.setTimeout(() => onProgress(null), 450); + } +} + +export async function uploadLocalPathsToWorkspaceDirectory( + localPaths: string[], + targetDirectory: string, + workspace: WorkspaceInfo | null, + onProgress: (state: TransferProgressState | null) => void +): Promise { + if (!isTauri()) { + throw new Error(i18nService.t('common:ssh.remote.transferNeedsDesktop')); + } + if (localPaths.length === 0) { + return; + } + const total = localPaths.length; + for (let i = 0; i < total; i++) { + const lp = localPaths[i]!; + const name = lp.split(/[/\\]/).pop(); + if (!name) { + continue; + } + const destPath = joinWorkspaceTargetPath(targetDirectory, name); + onProgress({ + phase: 'upload', + current: i, + total, + label: name, + indeterminate: total === 1, + }); + if (isRemoteWorkspace(workspace)) { + const cid = workspace?.connectionId; + if (!cid) { + throw new Error(i18nService.t('panels/files:transfer.missingConnection')); + } + await sshApi.uploadFromLocalPath(cid, lp, destPath); + } else { + await workspaceAPI.exportLocalFileToPath(lp, destPath); + } + } + onProgress({ + phase: 'upload', + current: total, + total, + label: '', + indeterminate: false, + }); + window.setTimeout(() => onProgress(null), 450); +}