Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions src/apps/desktop/src/api/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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>,
Expand Down
48 changes: 48 additions & 0 deletions src/apps/desktop/src/api/ssh_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand Down
3 changes: 3 additions & 0 deletions src/apps/desktop/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 1 addition & 2 deletions src/apps/desktop/src/theme.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
{
Expand Down
8 changes: 5 additions & 3 deletions src/crates/core/src/service/remote_ssh/remote_fs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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));

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -162,7 +162,7 @@ const PersistentFooterActions: React.FC = () => {
aria-disabled={!hasWorkspace}
onClick={handleRemoteConnect}
>
<Wifi size={14} />
<Smartphone size={14} />
<span>{t('header.remoteConnect')}</span>
</button>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -195,6 +195,7 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({

const handleReveal = useCallback(async () => {
setMenuOpen(false);
if (isRemoteWorkspace(workspace)) return;
try {
await workspaceAPI.revealInExplorer(workspace.rootPath);
} catch (error) {
Expand All @@ -203,6 +204,21 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
{ 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') => {
Expand Down Expand Up @@ -360,7 +376,21 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.deleteAssistant')}</span>
</button>
)}
<button type="button" className="bitfun-nav-panel__workspace-item-menu-item" onClick={() => { void handleReveal(); }}>
<button
type="button"
className="bitfun-nav-panel__workspace-item-menu-item"
onClick={() => { void handleCopyWorkspacePath(); }}
disabled={!workspace.rootPath}
>
<Copy size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.copyPath')}</span>
</button>
<button
type="button"
className="bitfun-nav-panel__workspace-item-menu-item"
onClick={() => { void handleReveal(); }}
disabled={isRemoteWorkspace(workspace)}
>
<FolderSearch size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.reveal')}</span>
</button>
Expand Down Expand Up @@ -507,7 +537,21 @@ const WorkspaceItem: React.FC<WorkspaceItemProps> = ({
<GitBranch size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.newWorktree')}</span>
</button>
<button type="button" className="bitfun-nav-panel__workspace-item-menu-item" onClick={() => { void handleReveal(); }}>
<button
type="button"
className="bitfun-nav-panel__workspace-item-menu-item"
onClick={() => { void handleCopyWorkspacePath(); }}
disabled={!workspace.rootPath}
>
<Copy size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.copyPath')}</span>
</button>
<button
type="button"
className="bitfun-nav-panel__workspace-item-menu-item"
onClick={() => { void handleReveal(); }}
disabled={isRemoteWorkspace(workspace)}
>
<FolderSearch size={13} />
<span className="bitfun-nav-panel__workspace-item-menu-label">{t('nav.workspaces.actions.reveal')}</span>
</button>
Expand Down
50 changes: 50 additions & 0 deletions src/web-ui/src/app/components/panels/FilesPanel.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading