feat(file-explorer): enhance file transfer and dialog functionality

- Added a new TransferDuplicateDialog component to handle duplicate transfer requests.
- Implemented backend support for transfer duplicate prompts in the App component.
- Updated DeleteDialog to use AlertDialog components for improved user experience.
- Enhanced FileTransfer component with selection and deletion capabilities for transfers.
- Introduced ExternalFileDropOverlay for better file drop handling in the FileExplorerView.
- Improved terminal file drop handling and added support for Zmodem upload notifications.
This commit is contained in:
shideqin committed 2026-06-14 21:58:45 +08:00
1 parent 61fdd11fae
commit 46db1104eb
68 files changed
+10289 -595

No files matched your search

BIN
View File
Binary file not shown.
-2
View File
@@ -9193,8 +9193,6 @@ checksum = "4de98dfa5d5b7fef4ee834d0073d560c9ca7b6c46a71d058c48db7960f8cfaf7"
[[package]]
name = "zmodem2"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "515c519057f07982ebb6eed12d5316a52ee563b004cf08961019166e33a7cc9d"
dependencies = [
"bitflags 2.11.1",
"hex",
+3
View File
@@ -157,3 +157,6 @@ windows = { version = "0.61", features = [
"Win32_UI_Shell",
"Win32_UI_WindowsAndMessaging",
] }
[patch.crates-io]
zmodem2 = { path = "vendor/zmodem2" }
+24 -2
View File
@@ -1,6 +1,7 @@
use crate::core::SessionManager;
use crate::core::sftp;
use crate::error::AppResult;
use crate::core::sftp::{self, TransferDuplicateManager};
use crate::core::sftp::duplicate::DuplicateChoice;
use crate::error::{AppError, AppResult};
use std::sync::Arc;
#[tauri::command]
@@ -67,6 +68,7 @@ pub async fn upload_local_file(
local_path: String,
remote_path: String,
transfer_id: Option<String>,
duplicate_strategy_override: Option<String>,
) -> AppResult<()> {
sftp::upload_local_file(
app,
@@ -75,6 +77,7 @@ pub async fn upload_local_file(
&local_path,
&remote_path,
transfer_id,
duplicate_strategy_override,
)
.await
}
@@ -176,6 +179,7 @@ pub async fn upload_local_directory(
local_path: String,
remote_path: String,
transfer_id: Option<String>,
duplicate_strategy_override: Option<String>,
) -> AppResult<()> {
sftp::upload_local_directory(
app,
@@ -184,6 +188,7 @@ pub async fn upload_local_directory(
&local_path,
&remote_path,
transfer_id,
duplicate_strategy_override,
)
.await
}
@@ -202,3 +207,20 @@ pub async fn resume_transfer(app: tauri::AppHandle, transfer_id: String) -> AppR
pub async fn cancel_transfer(app: tauri::AppHandle, transfer_id: String) -> AppResult<()> {
sftp::cancel_transfer(app, &transfer_id).await
}
#[tauri::command]
pub async fn respond_transfer_duplicate(
state: tauri::State<'_, Arc<TransferDuplicateManager>>,
request_id: String,
action: String,
) -> AppResult<()> {
let choice = DuplicateChoice::from_action(&action)
.ok_or_else(|| AppError::Config(format!("Invalid duplicate action: {action}")))?;
if state.respond(&request_id, choice).await {
Ok(())
} else {
Err(AppError::Config(format!(
"No pending duplicate prompt with id '{request_id}'"
)))
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ fn default_transfer_threads() -> u32 {
3
}
fn default_duplicate_strategy() -> String {
"overwrite".to_string()
"ask".to_string()
}
fn default_file_permissions() -> String {
"644".to_string()
+29 -2
View File
@@ -8,7 +8,8 @@ use super::session::{
};
use super::update_cwd_if_changed;
use super::zmodem::{
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer,
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent,
ZmodemTransfer, start_zmodem_transfer,
};
use crate::config::AiExecutionProfile;
use crate::core::SessionOutputCoalescer;
@@ -587,7 +588,30 @@ fn pty_session_thread(
output_reader.push_owned(pre);
}
}
let transfer = ZmodemTransfer::new(direction, &initial_bytes);
let prepared_upload = if direction == ZmodemDirection::Upload {
rt_for_reader.block_on(async {
manager_reader
.take_pending_zmodem_upload(&sid_read)
.await
})
} else {
None
};
let (transfer, bootstrap_actions) = start_zmodem_transfer(
direction,
&initial_bytes,
prepared_upload,
);
for action in bootstrap_actions {
match action {
ZmodemAction::SendToRemote(data) => {
let _ = zmodem_out_tx.send(data);
}
ZmodemAction::EmitEvent(event) => {
let _ = app_read.emit(&zmodem_event_reader, &event);
}
}
}
*zmodem_state_reader.lock().unwrap() = Some(transfer);
let _ = app_read.emit(
&zmodem_event_reader,
@@ -761,6 +785,9 @@ fn pty_session_thread(
}
}
SessionCommand::ZmodemCancel => {
rt_handle.block_on(async {
manager.clear_pending_zmodem_upload(&session_id).await;
});
let mut zm = zmodem_state.lock().unwrap();
if let Some(ref mut transfer) = *zm {
let actions = transfer.cancel();
+33 -2
View File
@@ -4,7 +4,8 @@ use super::session::{
SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType, SharedCwd,
};
use super::zmodem::{
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer,
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent,
ZmodemTransfer, start_zmodem_transfer,
};
use crate::config::AiExecutionProfile;
use crate::core::capture::OutputCaptureProcessor;
@@ -226,6 +227,8 @@ fn serial_session_thread(
// Reader thread
let app_reader = app.clone();
let sid_reader = session_id.clone();
let manager_reader = manager.clone();
let rt_handle_reader = rt_handle.clone();
let port_writer_reader = port_writer.clone();
let output_reader = output.clone();
let recording_mgr_reader = recording_mgr.clone();
@@ -284,7 +287,32 @@ fn serial_session_thread(
output_reader.push_owned(pre);
}
}
let transfer = ZmodemTransfer::new(direction, &initial_bytes);
let prepared_upload = if direction == ZmodemDirection::Upload {
rt_handle_reader.block_on(async {
manager_reader
.take_pending_zmodem_upload(&sid_reader)
.await
})
} else {
None
};
let (transfer, bootstrap_actions) = start_zmodem_transfer(
direction,
&initial_bytes,
prepared_upload,
);
for action in bootstrap_actions {
match action {
ZmodemAction::SendToRemote(data) => {
let mut p = port_writer_reader.lock().unwrap();
let _ = p.write_all(&data);
let _ = p.flush();
}
ZmodemAction::EmitEvent(event) => {
let _ = app_reader.emit(&zmodem_event_reader, &event);
}
}
}
*zmodem_state_reader.lock().unwrap() = Some(transfer);
let _ = app_reader
.emit(&zmodem_event_reader, &ZmodemEvent::Detected { direction });
@@ -419,6 +447,9 @@ fn serial_session_thread(
}
}
SessionCommand::ZmodemCancel => {
rt_handle.block_on(async {
manager.clear_pending_zmodem_upload(&session_id).await;
});
let mut zm = zmodem_state.lock().unwrap();
if let Some(ref mut transfer) = *zm {
let actions = transfer.cancel();
+22
View File
@@ -153,6 +153,7 @@ pub struct SessionManager {
pub sessions: Arc<Mutex<HashMap<String, SessionHandle>>>,
pub history_store: Arc<Mutex<CommandHistoryStore>>,
command_submissions: Arc<Mutex<HashMap<String, CommandSubmissionState>>>,
pending_zmodem_uploads: Arc<Mutex<HashMap<String, Vec<std::path::PathBuf>>>>,
history_save_notify: Arc<Notify>,
history_save_worker_started: AtomicBool,
history_event_notify: Arc<Notify>,
@@ -168,6 +169,7 @@ impl SessionManager {
sessions: Arc::new(Mutex::new(HashMap::new())),
history_store: Arc::new(Mutex::new(CommandHistoryStore::new())),
command_submissions: Arc::new(Mutex::new(HashMap::new())),
pending_zmodem_uploads: Arc::new(Mutex::new(HashMap::new())),
history_save_notify: Arc::new(Notify::new()),
history_save_worker_started: AtomicBool::new(false),
history_event_notify: Arc::new(Notify::new()),
@@ -261,6 +263,7 @@ impl SessionManager {
if removed {
self.flush_pending_submission(id).await;
self.command_submissions.lock().await.remove(id);
self.pending_zmodem_uploads.lock().await.remove(id);
}
if removed {
if let Some(app) = self.app_handle.get() {
@@ -271,6 +274,25 @@ impl SessionManager {
removed
}
/// Takes and clears any prepared ZMODEM upload paths for a session.
pub async fn take_pending_zmodem_upload(
&self,
session_id: &str,
) -> Option<Vec<std::path::PathBuf>> {
self.pending_zmodem_uploads
.lock()
.await
.remove(session_id)
}
/// Clears prepared ZMODEM upload paths without starting a transfer.
pub async fn clear_pending_zmodem_upload(&self, session_id: &str) {
self.pending_zmodem_uploads
.lock()
.await
.remove(session_id);
}
/// Sends a command to a session's I/O loop; errors if session not found.
pub async fn send_command(&self, id: &str, cmd: SessionCommand) -> AppResult<()> {
let sessions = self.sessions.lock().await;
+103
View File
@@ -0,0 +1,103 @@
use crate::core::SessionManager;
use crate::error::{AppError, AppResult};
use serde::Serialize;
use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::{Mutex, oneshot};
const DUPLICATE_PROMPT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DuplicateChoice {
Skip,
Overwrite,
}
impl DuplicateChoice {
pub fn from_action(action: &str) -> Option<Self> {
match action {
"skip" => Some(Self::Skip),
"overwrite" => Some(Self::Overwrite),
_ => None,
}
}
}
/// Manages pending duplicate-file prompts awaiting user input from the frontend.
pub struct TransferDuplicateManager {
pending: Mutex<HashMap<String, oneshot::Sender<DuplicateChoice>>>,
}
impl TransferDuplicateManager {
pub fn new() -> Self {
Self {
pending: Mutex::new(HashMap::new()),
}
}
pub async fn register(&self, request_id: String) -> oneshot::Receiver<DuplicateChoice> {
let (tx, rx) = oneshot::channel();
self.pending.lock().await.insert(request_id, tx);
rx
}
pub async fn respond(&self, request_id: &str, choice: DuplicateChoice) -> bool {
if let Some(tx) = self.pending.lock().await.remove(request_id) {
tx.send(choice).is_ok()
} else {
false
}
}
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TransferDuplicateRequestPayload {
pub request_id: String,
pub session_id: String,
pub remote_path: String,
pub file_name: String,
pub is_directory: bool,
pub target_window_label: Option<String>,
}
pub async fn prompt_duplicate_choice(
app: &AppHandle,
session_manager: &SessionManager,
session_id: &str,
remote_path: &str,
file_name: &str,
is_directory: bool,
) -> AppResult<DuplicateChoice> {
let manager = app
.try_state::<Arc<TransferDuplicateManager>>()
.ok_or_else(|| AppError::Config("TransferDuplicateManager not available".to_string()))?;
let target_window_label = session_manager
.session_info(session_id)
.await
.ok()
.and_then(|info| info.owner_window_label);
let request_id = uuid::Uuid::new_v4().to_string();
let rx = manager.register(request_id.clone()).await;
let _ = app.emit(
"transfer-duplicate-request",
TransferDuplicateRequestPayload {
request_id,
session_id: session_id.to_string(),
remote_path: remote_path.to_string(),
file_name: file_name.to_string(),
is_directory,
target_window_label,
},
);
match tokio::time::timeout(DUPLICATE_PROMPT_TIMEOUT, rx).await {
Ok(Ok(choice)) => Ok(choice),
_ => Ok(DuplicateChoice::Skip),
}
}
+23 -3
View File
@@ -5,6 +5,7 @@
//! The upper layers and the frontend never need to know which protocol is in use.
mod cache;
pub(crate) mod duplicate;
mod scp_enhanced;
mod scp_normal;
mod sftp_backend;
@@ -24,6 +25,7 @@ use crate::error::{AppError, AppResult};
use std::sync::Arc;
use tokio::sync::RwLock;
pub(crate) use duplicate::TransferDuplicateManager;
pub(crate) use transfer::{active_transfer_count, transfer_target_directory};
pub use transfer::{cancel_transfer, pause_transfer, resume_transfer};
pub use util::{FileEntry, FileProperties, RemoteFileAttributeUpdate, RemoteTextFile};
@@ -338,11 +340,15 @@ pub async fn upload_local_file(
local_path: &str,
remote_path: &str,
transfer_id: Option<String>,
duplicate_strategy_override: Option<String>,
) -> AppResult<()> {
let auto_fs = get_or_create_auto_fs(&manager, session_id).await?;
let transfer_settings = crate::config::load_app_settings(&app)
let mut transfer_settings = crate::config::load_app_settings(&app)
.map(|s| s.transfer)
.unwrap_or_default();
if let Some(strategy) = duplicate_strategy_override {
transfer_settings.duplicate_strategy = strategy;
}
let guard = auto_fs.backend().await?;
let fs = guard.as_ref().unwrap();
fs.upload_file(
@@ -531,10 +537,24 @@ pub async fn upload_local_directory(
local_path: &str,
remote_path: &str,
transfer_id: Option<String>,
duplicate_strategy_override: Option<String>,
) -> AppResult<()> {
let auto_fs = get_or_create_auto_fs(&manager, session_id).await?;
let mut transfer_settings = crate::config::load_app_settings(&app)
.map(|s| s.transfer)
.unwrap_or_default();
if let Some(strategy) = duplicate_strategy_override {
transfer_settings.duplicate_strategy = strategy;
}
let guard = auto_fs.backend().await?;
let fs = guard.as_ref().unwrap();
fs.upload_directory(&app, session_id, local_path, remote_path, transfer_id)
.await
fs.upload_directory(
&app,
session_id,
local_path,
remote_path,
&transfer_settings,
transfer_id,
)
.await
}
+1
View File
@@ -1069,6 +1069,7 @@ impl RemoteFs for ScpEnhancedBackend {
session_id: &str,
local_path: &str,
remote_path: &str,
_transfer_settings: &crate::config::TransferSettings,
transfer_id: Option<String>,
) -> AppResult<()> {
let local_stats = collect_local_directory_stats(local_path).await?;
+89 -29
View File
@@ -12,7 +12,7 @@ use crate::error::{AppError, AppResult};
use crate::observability::{StructuredLog, StructuredLogLevel, log_event};
use russh::ChannelMsg;
use std::sync::Arc;
use tauri::Emitter;
use tauri::{Emitter, Manager};
pub(crate) struct ScpNormalBackend {
ssh_handle: Arc<SshConnectionHandles>,
@@ -694,15 +694,30 @@ async fn resolve_remote_exists(ssh_handle: &Arc<SshConnectionHandles>, remote_pa
.unwrap_or(false)
}
async fn resolve_remote_path(
async fn resolve_remote_is_directory(
ssh_handle: &Arc<SshConnectionHandles>,
remote_path: &str,
) -> bool {
exec_command(ssh_handle, &format!("test -d {}", sh_quote(remote_path)))
.await
.map(|r| r.exit_code == Some(0))
.unwrap_or(false)
}
async fn resolve_remote_path(
app: &tauri::AppHandle,
session_manager: &crate::core::SessionManager,
ssh_handle: &Arc<SshConnectionHandles>,
session_id: &str,
remote_path: &str,
strategy: &str,
) -> Option<String> {
let exists = resolve_remote_exists(ssh_handle, remote_path).await;
if !exists {
return Some(remote_path.to_string());
}
let file_name = file_name_from_path(remote_path);
let is_directory = resolve_remote_is_directory(ssh_handle, remote_path).await;
match strategy {
"skip" => None,
"rename" => {
@@ -728,10 +743,62 @@ async fn resolve_remote_path(
}
Some(remote_path.to_string())
}
"ask" => {
match super::duplicate::prompt_duplicate_choice(
app,
session_manager,
session_id,
remote_path,
&file_name,
is_directory,
)
.await
{
Ok(super::duplicate::DuplicateChoice::Skip) => None,
Ok(super::duplicate::DuplicateChoice::Overwrite) => Some(remote_path.to_string()),
Err(_) => None,
}
}
_ => Some(remote_path.to_string()),
}
}
async fn ensure_remote_upload_target_allowed(
app: &tauri::AppHandle,
session_manager: &crate::core::SessionManager,
ssh_handle: &Arc<SshConnectionHandles>,
session_id: &str,
remote_path: &str,
strategy: &str,
) -> bool {
if !resolve_remote_exists(ssh_handle, remote_path).await {
return true;
}
let file_name = file_name_from_path(remote_path);
let is_directory = resolve_remote_is_directory(ssh_handle, remote_path).await;
match strategy {
"skip" => false,
"rename" => true,
"ask" => {
matches!(
super::duplicate::prompt_duplicate_choice(
app,
session_manager,
session_id,
remote_path,
&file_name,
is_directory,
)
.await,
Ok(super::duplicate::DuplicateChoice::Overwrite)
)
}
_ => true,
}
}
#[async_trait::async_trait]
impl RemoteFs for ScpNormalBackend {
fn backend_name(&self) -> &'static str {
@@ -1020,41 +1087,19 @@ impl RemoteFs for ScpNormalBackend {
transfer_id: Option<String>,
) -> AppResult<()> {
let max_retries = transfer_settings.max_transfer_retries;
let session_manager = app.state::<Arc<crate::core::SessionManager>>();
let actual_remote_path = match resolve_remote_path(
app,
session_manager.inner(),
&self.ssh_handle,
session_id,
remote_path,
&transfer_settings.duplicate_strategy,
)
.await
{
Some(path) => path,
None => {
let file_name = file_name_from_path(remote_path);
let transfer_id = transfer_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let _ = app.emit(
"transfer-event",
&TransferEvent {
id: transfer_id,
session_id: session_id.to_string(),
file_name,
remote_path: remote_path.to_string(),
local_path: local_path.to_string(),
direction: "upload".to_string(),
kind: "file".to_string(),
status: "completed".to_string(),
size: 0,
bytes_transferred: 0,
total_size: 0,
parent_id: None,
item_count_total: None,
item_count_completed: None,
error_msg: None,
},
);
return Ok(());
}
None => return Ok(()),
};
let mut last_err = None;
@@ -1178,8 +1223,23 @@ impl RemoteFs for ScpNormalBackend {
session_id: &str,
local_path: &str,
remote_path: &str,
transfer_settings: &crate::config::TransferSettings,
transfer_id: Option<String>,
) -> AppResult<()> {
let session_manager = app.state::<Arc<crate::core::SessionManager>>();
if !ensure_remote_upload_target_allowed(
app,
session_manager.inner(),
&self.ssh_handle,
session_id,
remote_path,
&transfer_settings.duplicate_strategy,
)
.await
{
return Ok(());
}
let local_stats = collect_local_directory_stats(local_path).await?;
let directory_controller = create_directory_transfer_controller(
transfer_id,
+90 -29
View File
@@ -15,7 +15,7 @@ use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use tauri::Emitter;
use tauri::{Emitter, Manager};
use tokio::sync::RwLock;
const SFTP_MIN_REQUEST_KIB: usize = 64;
@@ -464,7 +464,10 @@ async fn remove_dir_recursive(sftp: &SftpSession, path: &str) -> AppResult<()> {
}
async fn resolve_remote_path(
app: &tauri::AppHandle,
session_manager: &crate::core::SessionManager,
sftp: &SftpSession,
session_id: &str,
remote_path: &str,
strategy: &str,
) -> Option<String> {
@@ -472,6 +475,12 @@ async fn resolve_remote_path(
if !exists {
return Some(remote_path.to_string());
}
let file_name = remote_path.split('/').last().unwrap_or(remote_path);
let is_directory = sftp
.metadata(remote_path)
.await
.map(|attrs| sftp_attrs_is_dir(&attrs))
.unwrap_or(false);
match strategy {
"skip" => None,
"rename" => {
@@ -497,10 +506,67 @@ async fn resolve_remote_path(
}
Some(remote_path.to_string())
}
"ask" => {
match super::duplicate::prompt_duplicate_choice(
app,
session_manager,
session_id,
remote_path,
file_name,
is_directory,
)
.await
{
Ok(super::duplicate::DuplicateChoice::Skip) => None,
Ok(super::duplicate::DuplicateChoice::Overwrite) => Some(remote_path.to_string()),
Err(_) => None,
}
}
_ => Some(remote_path.to_string()),
}
}
async fn ensure_remote_upload_target_allowed(
app: &tauri::AppHandle,
session_manager: &crate::core::SessionManager,
sftp: &SftpSession,
session_id: &str,
remote_path: &str,
strategy: &str,
) -> bool {
let exists = sftp.metadata(remote_path).await.is_ok();
if !exists {
return true;
}
let file_name = file_name_from_path(remote_path);
let is_directory = sftp
.metadata(remote_path)
.await
.map(|attrs| sftp_attrs_is_dir(&attrs))
.unwrap_or(false);
match strategy {
"skip" => false,
"rename" => true,
"ask" => {
matches!(
super::duplicate::prompt_duplicate_choice(
app,
session_manager,
session_id,
remote_path,
&file_name,
is_directory,
)
.await,
Ok(super::duplicate::DuplicateChoice::Overwrite)
)
}
_ => true,
}
}
async fn apply_remote_mode(sftp: &SftpSession, path: &str, requested_mode: u32) -> AppResult<()> {
let original_attrs = sftp.metadata(path).await?;
let original_permissions = original_attrs.permissions;
@@ -1455,9 +1521,13 @@ impl RemoteFs for SftpBackend {
transfer_id: Option<String>,
) -> AppResult<()> {
let max_retries = transfer_settings.max_transfer_retries;
let session_manager = app.state::<Arc<crate::core::SessionManager>>();
let sftp_for_resolve = self.open_sftp().await?;
let actual_remote_path = match resolve_remote_path(
app,
session_manager.inner(),
&sftp_for_resolve,
session_id,
remote_path,
&transfer_settings.duplicate_strategy,
)
@@ -1465,30 +1535,6 @@ impl RemoteFs for SftpBackend {
{
Some(path) => path,
None => {
let file_name = remote_path.split('/').last().unwrap_or(remote_path);
let transfer_id = transfer_id
.clone()
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
let _ = app.emit(
"transfer-event",
&TransferEvent {
id: transfer_id,
session_id: session_id.to_string(),
file_name: file_name.to_string(),
remote_path: remote_path.to_string(),
local_path: local_path.to_string(),
direction: "upload".to_string(),
kind: "file".to_string(),
status: "completed".to_string(),
size: 0,
bytes_transferred: 0,
total_size: 0,
parent_id: None,
item_count_total: None,
item_count_completed: None,
error_msg: None,
},
);
let _ = sftp_for_resolve.close().await;
return Ok(());
}
@@ -1643,13 +1689,28 @@ impl RemoteFs for SftpBackend {
session_id: &str,
local_path: &str,
remote_path: &str,
transfer_settings: &crate::config::TransferSettings,
transfer_id: Option<String>,
) -> AppResult<()> {
let transfer_settings = crate::config::load_app_settings(app)
.map(|s| s.transfer)
.unwrap_or_default();
let session_manager = app.state::<Arc<crate::core::SessionManager>>();
let sftp_for_check = self.open_sftp().await?;
if !ensure_remote_upload_target_allowed(
app,
session_manager.inner(),
&sftp_for_check,
session_id,
remote_path,
&transfer_settings.duplicate_strategy,
)
.await
{
let _ = sftp_for_check.close().await;
return Ok(());
}
let _ = sftp_for_check.close().await;
let (request_kib, pipeline_depth, max_concurrent_writes) =
sftp_pipeline_config(&transfer_settings);
sftp_pipeline_config(transfer_settings);
let concurrent_tasks =
background_transfer_task_concurrency(transfer_settings.upload_threads);
let transfer_started = Instant::now();
+1
View File
@@ -57,6 +57,7 @@ pub(crate) trait RemoteFs: Send + Sync {
session_id: &str,
local_path: &str,
remote_path: &str,
transfer_settings: &crate::config::TransferSettings,
transfer_id: Option<String>,
) -> AppResult<()>;
}
+25 -27
View File
@@ -2,7 +2,8 @@ use super::client::{SshHandle, SshHandler, SshPostLoginConfig};
use crate::core::capture::OutputCaptureProcessor;
use crate::core::ssh::osc::{self, OscStripper, ShellKind};
use crate::core::zmodem::{
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer,
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent,
ZmodemTransfer, start_zmodem_transfer,
};
use crate::core::{
RecordingManager, SessionCommand, SessionManager, SessionOutputCoalescer, SharedCwd,
@@ -376,9 +377,15 @@ pub(super) async fn ssh_io_loop(
if transfer.is_done() {
zmodem_transfer = None;
}
} else {
tracing::warn!(
session_id = %session_id,
"Received ZmodemAcceptUpload without an active transfer"
);
}
}
Some(SessionCommand::ZmodemCancel) => {
manager.clear_pending_zmodem_upload(&session_id).await;
if let Some(ref mut transfer) = zmodem_transfer {
let actions = transfer.cancel();
handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await;
@@ -406,8 +413,9 @@ pub(super) async fn ssh_io_loop(
}
// ZMODEM: detect header in raw bytes before lossy UTF-8 conversion.
if phase == IoPhase::Normal {
match zmodem_detector.feed(data) {
// Detection must run in every phase so an early `rz` is not missed
// while shell-integration output is still being suppressed.
match zmodem_detector.feed(data) {
ZmodemDetectResult::Detected { direction, passthrough, initial_bytes } => {
// Forward any pre-header bytes to the terminal.
if !passthrough.is_empty() {
@@ -416,8 +424,21 @@ pub(super) async fn ssh_io_loop(
output.push_owned(pre);
}
}
let transfer = ZmodemTransfer::new(direction, &initial_bytes);
let prepared_upload = if direction == ZmodemDirection::Upload {
manager.take_pending_zmodem_upload(&session_id).await
} else {
None
};
let (transfer, bootstrap_actions) =
start_zmodem_transfer(direction, &initial_bytes, prepared_upload);
zmodem_transfer = Some(transfer);
handle_zmodem_actions(
&app,
&zmodem_event_name,
&mut channel,
bootstrap_actions,
)
.await;
let _ = app.emit(&zmodem_event_name, &ZmodemEvent::Detected { direction });
tracing::info!(
session_id = %session_id,
@@ -454,29 +475,6 @@ pub(super) async fn ssh_io_loop(
continue;
}
}
}
let text = String::from_utf8_lossy(data).to_string();
let mut result = stripper.push(&text);
if capture_processor.has_active() {
result.visible = capture_processor.process(&result.visible);
}
handle_osc_result(
&app,
&output,
&cwd_event,
&cwd,
&recording_mgr,
&session_id,
&manager,
&mut channel,
&mut pending_script,
&mut inject_deadline,
&mut phase,
&result,
).await;
}
Some(ChannelMsg::ExtendedData { ref data, .. }) => {
let text = String::from_utf8_lossy(data).to_string();
+26 -2
View File
@@ -4,7 +4,8 @@ use super::session::{
SessionCommand, SessionHandle, SessionInfo, SessionManager, SessionType, SharedCwd,
};
use super::zmodem::{
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemEvent, ZmodemTransfer,
ZmodemAction, ZmodemDetectResult, ZmodemDetector, ZmodemDirection, ZmodemEvent,
ZmodemTransfer, start_zmodem_transfer,
};
use crate::config::AiExecutionProfile;
use crate::core::capture::OutputCaptureProcessor;
@@ -251,6 +252,7 @@ async fn telnet_session_task(
let app_reader = app.clone();
let sid_reader = session_id.clone();
let manager_reader = manager.clone();
let output_reader = output.clone();
let reader_connection_id = connection_id.clone();
let recording_mgr_reader = recording_mgr.clone();
@@ -314,7 +316,28 @@ async fn telnet_session_task(
output_reader.push_owned(pre);
}
}
let transfer = ZmodemTransfer::new(direction, &initial_bytes);
let prepared_upload = if direction == ZmodemDirection::Upload {
manager_reader
.take_pending_zmodem_upload(&sid_reader)
.await
} else {
None
};
let (transfer, bootstrap_actions) = start_zmodem_transfer(
direction,
&initial_bytes,
prepared_upload,
);
for action in bootstrap_actions {
match action {
ZmodemAction::SendToRemote(data) => {
let _ = zmodem_out_tx.send(data);
}
ZmodemAction::EmitEvent(event) => {
let _ = app_reader.emit(&zmodem_event_reader, &event);
}
}
}
*zmodem_state_reader.lock().await = Some(transfer);
let _ = app_reader
.emit(&zmodem_event_reader, &ZmodemEvent::Detected { direction });
@@ -465,6 +488,7 @@ async fn telnet_session_task(
}
}
Some(SessionCommand::ZmodemCancel) => {
manager.clear_pending_zmodem_upload(&session_id).await;
let mut zm = zmodem_state.lock().await;
if let Some(ref mut transfer) = *zm {
let actions = transfer.cancel();
+36 -1
View File
@@ -63,6 +63,20 @@ pub enum ZmodemEvent {
Failed { reason: String },
}
/// Creates a transfer and optionally auto-accepts a prepared upload.
pub fn start_zmodem_transfer(
direction: ZmodemDirection,
initial_bytes: &[u8],
prepared_upload_files: Option<Vec<PathBuf>>,
) -> (ZmodemTransfer, Vec<ZmodemAction>) {
let mut transfer = ZmodemTransfer::new(direction, initial_bytes);
let bootstrap_actions = match (direction, prepared_upload_files) {
(ZmodemDirection::Upload, Some(files)) => transfer.accept_upload(files),
_ => Vec::new(),
};
(transfer, bootstrap_actions)
}
/// Actions returned to the I/O loop after feeding bytes.
pub enum ZmodemAction {
/// Send these bytes back to the remote (protocol responses).
@@ -115,13 +129,18 @@ impl ZmodemDetector {
///
/// `passthrough` contains bytes that can be shown in the terminal. Split
/// header prefixes are retained internally until enough bytes arrive.
/// When an upload is detected, the "rz -y" shell echo is stripped from
/// passthrough so the user doesn't see the command.
pub fn feed(&mut self, data: &[u8]) -> ZmodemDetectResult {
self.pending.extend_from_slice(data);
if let Some((direction, header_start)) = detect_zmodem_start(&self.pending) {
let passthrough = self.pending[..header_start].to_vec();
let mut passthrough = self.pending[..header_start].to_vec();
let initial_bytes = self.pending[header_start..].to_vec();
self.reset();
if direction == ZmodemDirection::Upload {
strip_rz_echo(&mut passthrough);
}
return ZmodemDetectResult::Detected {
direction,
passthrough,
@@ -221,6 +240,22 @@ fn has_rz_receive_prompt_before(data: &[u8], start: usize) -> bool {
.any(|window| window == RZ_RECEIVE_PROMPT)
}
/// Strip the "rz" shell echo from the end of the passthrough data
/// so that the user doesn't see the upload command in the terminal.
/// Handles common echo variants: "rz\r\n", "rz\r", "rz".
fn strip_rz_echo(data: &mut Vec<u8>) {
// Try to strip from the end: \r\n, \r, or just the command text.
// The command is always at the end of the passthrough because the
// ZMODEM header follows immediately.
let patterns: &[&[u8]] = &[b"rz\r\n", b"rz\r", b"rz"];
for &pat in patterns {
if data.ends_with(pat) {
data.truncate(data.len() - pat.len());
return;
}
}
}
fn is_possible_zmodem_prefix(data: &[u8]) -> bool {
match data {
[] => true,
+4
View File
@@ -17,6 +17,7 @@ use std::sync::Arc;
use crate::core::ai::AgentApprovalManager;
use crate::core::ssh::{HostKeyVerifyManager, PendingAuthManager, TunnelManager};
use crate::core::sftp::TransferDuplicateManager;
use crate::core::{CloudSyncManager, QuickCommandsStore, RecordingManager, SessionManager};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
@@ -32,6 +33,7 @@ pub fn run() {
let quick_commands_store = Arc::new(QuickCommandsStore::new());
let cloud_sync_manager = Arc::new(CloudSyncManager::new());
let agent_approval_manager = Arc::new(AgentApprovalManager::new());
let transfer_duplicate_manager = Arc::new(TransferDuplicateManager::new());
let builder = tauri::Builder::default();
#[cfg(not(any(target_os = "android", target_os = "ios")))]
@@ -64,6 +66,7 @@ pub fn run() {
.manage(quick_commands_store.clone())
.manage(cloud_sync_manager.clone())
.manage(agent_approval_manager.clone())
.manage(transfer_duplicate_manager.clone())
.setup(move |a| {
app::setup(
a,
@@ -155,6 +158,7 @@ pub fn run() {
cmd::sftp::pause_transfer,
cmd::sftp::resume_transfer,
cmd::sftp::cancel_transfer,
cmd::sftp::respond_transfer_duplicate,
cmd::connection::get_saved_connections,
cmd::connection::save_connection,
cmd::connection::delete_connection,
+1
View File
@@ -0,0 +1 @@
{"v":1}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "9635055dbc74652765e4066d18e1cfaac880e58c"
},
"path_in_vcs": ""
}
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+1
View File
@@ -0,0 +1 @@
/target
+433
View File
@@ -0,0 +1,433 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "bitflags"
version = "2.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967"
[[package]]
name = "cfg-if"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9555578bc9e57714c812a1f84e4fc5b4d21fcb063490c624de019f7464c91268"
[[package]]
name = "cfg_aliases"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "futures-core"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
[[package]]
name = "futures-macro"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
[[package]]
name = "futures-timer"
version = "3.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24"
[[package]]
name = "futures-util"
version = "0.3.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"pin-utils",
"slab",
]
[[package]]
name = "getrandom"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
dependencies = [
"cfg-if",
"libc",
"r-efi",
"wasip2",
]
[[package]]
name = "glob"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8d1add55171497b4705a648c6b583acafb01d58050a51727785f0b2c8e0a2b2"
[[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
[[package]]
name = "hex"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "indexmap"
version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "libc"
version = "0.2.177"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "memchr"
version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "nix"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "pin-project-lite"
version = "0.2.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
[[package]]
name = "pin-utils"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "proc-macro-crate"
version = "3.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edce586971a4dfaa28950c6f18ed55e0406c1ab88bbce2c6f6293a7aaba73d35"
dependencies = [
"toml_edit",
]
[[package]]
name = "proc-macro2"
version = "1.0.95"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02b3e5e68a3a1a02aad3ec490a98007cbc13c37cbe84a3cd7b8e406d76e7f778"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "5.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
[[package]]
name = "regex"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b544ef1b4eac5dc2db33ea63606ae9ffcfac26c1416a2806ae0bf5f56b201191"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "809e8dc61f6de73b46c85f4c96486310fe304c434cfa43669d7b40f711150908"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c"
[[package]]
name = "relative-path"
version = "1.9.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2"
[[package]]
name = "rstest"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6fc39292f8613e913f7df8fa892b8944ceb47c247b78e1b1ae2f09e019be789d"
dependencies = [
"futures-timer",
"futures-util",
"rstest_macros",
"rustc_version",
]
[[package]]
name = "rstest_macros"
version = "0.25.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f168d99749d307be9de54d23fd226628d99768225ef08f6ffb52e0182a27746"
dependencies = [
"cfg-if",
"glob",
"proc-macro-crate",
"proc-macro2",
"quote",
"regex",
"relative-path",
"rustc_version",
"syn",
"unicode-ident",
]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "semver"
version = "1.0.26"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "56e6fa9c48d24d85fb3de5ad847117517440f6beceb7798af16b4a87d616b8d0"
[[package]]
name = "slab"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
[[package]]
name = "syn"
version = "2.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "17b6f705963418cdb9927482fa304bc562ece2fdd4f616084c50b7023b435a40"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "tempfile"
version = "3.23.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "toml_datetime"
version = "0.6.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
[[package]]
name = "toml_edit"
version = "0.22.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
dependencies = [
"indexmap",
"toml_datetime",
"winnow",
]
[[package]]
name = "unicode-ident"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "wasip2"
version = "1.0.1+wasi-0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
dependencies = [
"wit-bindgen",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "winnow"
version = "0.7.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95"
dependencies = [
"memchr",
]
[[package]]
name = "wit-bindgen"
version = "0.46.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
[[package]]
name = "zmodem2"
version = "0.5.0"
dependencies = [
"bitflags",
"hex",
"nix",
"rstest",
"tempfile",
"thiserror",
]
+65
View File
@@ -0,0 +1,65 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
rust-version = "1.81"
name = "zmodem2"
version = "0.5.0"
authors = ["Jarkko Sakkinen <jarkko.sakkinen@iki.fi>"]
build = "build.rs"
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "ZMODEM file transfer protocol"
homepage = "https://codeberg.org/jarkko/zmodem2"
readme = "README.md"
license = "MIT OR Apache-2.0"
repository = "https://codeberg.org/jarkko/zmodem2.git"
[features]
default = ["std"]
std = []
[lib]
name = "zmodem2"
path = "src/lib.rs"
[[test]]
name = "integration"
path = "tests/integration.rs"
harness = true
[[test]]
name = "protocol"
path = "tests/protocol.rs"
harness = true
[dependencies.bitflags]
version = "2.4"
[dependencies.hex]
version = "0.4.3"
[dependencies.thiserror]
version = "1.0"
[dev-dependencies.nix]
version = "0.29"
features = ["fs"]
[dev-dependencies.rstest]
version = "0.25"
[dev-dependencies.tempfile]
version = "3.10"
+35
View File
@@ -0,0 +1,35 @@
[package]
authors = ["Jarkko Sakkinen <jarkko.sakkinen@iki.fi>"]
description = "ZMODEM file transfer protocol"
edition = "2021"
homepage = "https://codeberg.org/jarkko/zmodem2"
license = "MIT OR Apache-2.0"
name = "zmodem2"
repository = "https://codeberg.org/jarkko/zmodem2.git"
rust-version = "1.81"
version = "0.5.0"
build = "build.rs"
[features]
default = ["std"]
std = []
[dependencies]
bitflags = "2.4"
hex = "0.4.3"
thiserror = "1.0"
[dev-dependencies]
nix = { version = "0.29", features = ["fs"] }
rstest = "0.25"
tempfile = "3.10"
[[test]]
name = "protocol"
path = "tests/protocol.rs"
harness = true
[[test]]
name = "integration"
path = "tests/integration.rs"
harness = true
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright (c) 2017-2020 Alexey Arbuzov
Copyright (c) 2023-2025 Jarkko Sakkinen
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+26
View File
@@ -0,0 +1,26 @@
Copyright (c) 2017-2020 Alexey Arbuzov
Copyright (c) 2023-2025 Jarkko Sakkinen
Permission is hereby granted, free of charge, to any
person obtaining a copy of this software and associated
documentation files (the "Software"), to deal in the
Software without restriction, including without
limitation the rights to use, copy, modify, merge,
publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software
is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice
shall be included in all copies or substantial portions
of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE.
+16
View File
@@ -0,0 +1,16 @@
# zmodem2
`zmodem2` provides a ZMODEM file tranmission protocol implementation with step
executed state by the caller. It is based on prior work of Aleksei Arbuzov in
the [`zmodem`](https://github.com/lexxvir/zmodem) crate.
This crate is fully `no_std` compatible and *heapless*.
## Development
* Git: https://codeberg.org/jarkko/zmodem2.git
* Commits follow the
[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
specification.
* New commits must include a `Signed-off-by` trailer.
* Versioning scheme uses [Semantic Versioning](https://semver.org/).
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2025 Jarkko Sakkinen
use std::env;
use std::path::PathBuf;
fn main() {
println!("cargo::rustc-check-cfg=cfg(has_lrzsz)");
let rz = find_prog("ZMODEM_RZ_BIN", &["rz", "lrzsz-rz"]);
let sz = find_prog("ZMODEM_SZ_BIN", &["sz", "lrzsz-sz"]);
match (rz, sz) {
(Some(rz_path), Some(sz_path)) => {
println!("cargo:rustc-cfg=has_lrzsz");
println!("cargo:rustc-env=ZMODEM_RZ_BIN={}", rz_path.display());
println!("cargo:rustc-env=ZMODEM_SZ_BIN={}", sz_path.display());
}
_ => {
println!("cargo:warning=lrzsz not found");
}
}
}
fn find_prog(env_var: &str, candidates: &[&str]) -> Option<PathBuf> {
if let Ok(path) = env::var(env_var) {
let p = PathBuf::from(path);
if p.is_file() {
return Some(p);
}
}
let path = env::var_os("PATH")?;
for dir in env::split_paths(&path) {
for &name in candidates {
let full = dir.join(name);
if full.is_file() {
return Some(full);
}
}
}
None
}
File diff suppressed because it is too large. Load diff
+136
View File
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2023-2025 Jarkko Sakkinen
//! A simple stack-allocated buffer with a fixed capacity for staging data coming
//! from the serial link.
use core::fmt;
use core::ops::{Deref, DerefMut};
/// An error indicating a buffer's capacity was exceeded.
#[derive(Debug, PartialEq)]
pub struct CapacityError;
/// A buffer type for incoming and outgoing and other flyaway data.
pub struct Buffer<const CAP: usize> {
bytes: [u8; CAP],
len: usize,
}
impl<const CAP: usize> Default for Buffer<CAP> {
fn default() -> Self {
Self::new()
}
}
impl<const CAP: usize> Buffer<CAP> {
/// Creates a new instance.
#[must_use]
pub const fn new() -> Self {
Self {
bytes: [0; CAP],
len: 0,
}
}
/// Returns the number of bytes stored in the buffer.
#[must_use]
pub const fn len(&self) -> usize {
self.len
}
/// Returns the capacity of the buffer in bytes.
#[must_use]
pub const fn capacity(&self) -> usize {
CAP
}
/// Returns `true` if the buffer is empty.
#[must_use]
pub const fn is_empty(&self) -> bool {
self.len() == 0
}
/// Resets buffer length back to zero.
pub fn clear(&mut self) {
self.len = 0;
}
/// Appends a byte to the buffer.
///
/// # Errors
///
/// Returns `Err(CapacityError)`, if the capacity would be exceeded.
pub fn push(&mut self, value: u8) -> Result<(), CapacityError> {
if self.len < CAP {
self.bytes[self.len] = value;
self.len += 1;
Ok(())
} else {
Err(CapacityError)
}
}
/// Removes the last byte and returns it to the caller. If the buffer is
/// empty, returns `None`.
pub fn pop(&mut self) -> Option<u8> {
if self.is_empty() {
None
} else {
self.len -= 1;
Some(self.bytes[self.len])
}
}
/// Copies bytes from a slice to the end of the buffer.
///
/// # Errors
///
/// Returns `Err(CapacityError)`, if the capacity would be exceeded.
pub fn extend_from_slice(&mut self, slice: &[u8]) -> Result<(), CapacityError> {
if self.len + slice.len() > CAP {
return Err(CapacityError);
}
let end = self.len + slice.len();
self.bytes[self.len..end].copy_from_slice(slice);
self.len = end;
Ok(())
}
}
impl<const CAP: usize> fmt::Write for Buffer<CAP> {
fn write_str(&mut self, s: &str) -> fmt::Result {
self.extend_from_slice(s.as_bytes()).map_err(|_| fmt::Error)
}
}
impl<const CAP: usize> Deref for Buffer<CAP> {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.bytes[0..self.len]
}
}
impl<const CAP: usize> DerefMut for Buffer<CAP> {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.bytes[0..self.len]
}
}
impl<const CAP: usize> AsRef<[u8]> for Buffer<CAP> {
fn as_ref(&self) -> &[u8] {
&self.bytes[0..self.len]
}
}
impl<const CAP: usize, T: ?Sized> PartialEq<T> for Buffer<CAP>
where
T: AsRef<[u8]>,
{
fn eq(&self, other: &T) -> bool {
self.as_ref() == other.as_ref()
}
}
impl<const CAP: usize> Eq for Buffer<CAP> {}
+118
View File
@@ -0,0 +1,118 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2023-2025 Jarkko Sakkinen <jarkko.sakkinen@iki.fi>
/// Computes the CRC-16-XMODEM checksum.
#[must_use]
pub const fn crc16_xmodem(data: &[u8]) -> u16 {
let mut crc: u16 = 0x0000;
let mut i = 0;
while i < data.len() {
crc = crc16_update(crc, data[i]);
i += 1;
}
crc
}
/// Computes the CRC-32-ISO-HDLC checksum.
#[must_use]
pub const fn crc32_iso_hdlc(data: &[u8]) -> u32 {
let mut crc: u32 = 0xFFFF_FFFF;
let mut i = 0;
while i < data.len() {
crc = crc32_update(crc, data[i]);
i += 1;
}
!crc
}
/// A stateful, iterative CRC-16-XMODEM calculator.
pub struct Crc16 {
crc: u16,
}
impl Crc16 {
/// Creates a new CRC-16 calculator.
#[must_use]
pub const fn new() -> Self {
Self { crc: 0x0000 }
}
/// Updates the CRC state with a slice of bytes.
pub fn update(&mut self, data: &[u8]) {
for &byte in data {
self.update_byte(byte);
}
}
/// Updates the CRC state with a single byte.
pub fn update_byte(&mut self, byte: u8) {
self.crc = crc16_update(self.crc, byte);
}
/// Finalizes the CRC calculation and returns the checksum.
#[must_use]
pub const fn finalize(&self) -> u16 {
self.crc
}
}
/// A stateful, iterative CRC-32-ISO-HDLC calculator.
pub struct Crc32 {
crc: u32,
}
impl Crc32 {
/// Creates a new CRC-32 calculator.
#[must_use]
pub const fn new() -> Self {
Self { crc: 0xFFFF_FFFF }
}
/// Updates the CRC state with a slice of bytes.
pub fn update(&mut self, data: &[u8]) {
for &byte in data {
self.update_byte(byte);
}
}
/// Updates the CRC state with a single byte.
pub fn update_byte(&mut self, byte: u8) {
self.crc = crc32_update(self.crc, byte);
}
/// Finalizes the CRC calculation and returns the checksum.
#[must_use]
pub const fn finalize(&self) -> u32 {
!self.crc
}
}
/// Performs a single byte update for CRC-16-XMODEM.
const fn crc16_update(mut crc: u16, byte: u8) -> u16 {
crc ^= (byte as u16) << 8;
let mut i = 0;
while i < 8 {
if (crc & 0x8000) != 0 {
crc = (crc << 1) ^ 0x1021;
} else {
crc <<= 1;
}
i += 1;
}
crc
}
/// Performs a single byte update for CRC-32-ISO-HDLC.
const fn crc32_update(mut crc: u32, byte: u8) -> u32 {
crc ^= byte as u32;
let mut i = 0;
while i < 8 {
if (crc & 1) != 0 {
crc = (crc >> 1) ^ 0xEDB8_8320;
} else {
crc >>= 1;
}
i += 1;
}
crc
}
+38
View File
@@ -0,0 +1,38 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2023-2025 Jarkko Sakkinen
use crate::String;
use thiserror::Error;
/// Top-level error type.
#[derive(Error, Debug, PartialEq)]
pub enum Error {
#[error("malformed encoding type: {0:#02x}")]
MalformedEncoding(u8),
#[error("malformed file size")]
MalformedFileSize,
#[error("malformed filename")]
MalformedFileName,
#[error("malformed frame type: {0:#02x}")]
MalformedFrame(u8),
#[error("malformed header")]
MalformedHeader,
#[error("malformed packet type: {0:#02x}")]
MalformedPacket(u8),
#[error("not connected")]
NotConnected,
#[error("read: {0}")]
Read(String),
#[error("out of memory")]
OutOfMemory,
#[error("unexpected CRC-16")]
UnexpectedCrc16,
#[error("unexpected CRC-32")]
UnexpectedCrc32,
#[error("unexpected EOF")]
UnexpectedEof,
#[error("unsupported operation")]
Unsupported,
#[error("write: {0}")]
Write(String),
}
+390
View File
@@ -0,0 +1,390 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2025 Jarkko Sakkinen
//! ZMODEM protocol header, encoding, and frame definitions.
use crate::buffer::Buffer;
use crate::crc::{crc16_xmodem, crc32_iso_hdlc};
use crate::error::Error;
use crate::io::{Read, Write};
use crate::zdle;
use crate::{XON, ZDLE, ZPAD};
use bitflags::bitflags;
/// Buffer size with enough capacity for an escaped header
pub(crate) const HEADER_SIZE: usize = 32;
/// The size of the header payload (frame type + flags).
pub(crate) const HEADER_PAYLOAD_SIZE: usize = 5;
pub(crate) const ZACK_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZACK, &[0; 4]);
pub(crate) const ZDATA_HEADER: Header = Header::new(Encoding::ZBIN32, Frame::ZDATA, &[0; 4]);
pub(crate) const ZEOF_HEADER: Header = Header::new(Encoding::ZBIN32, Frame::ZEOF, &[0; 4]);
pub(crate) const ZFIN_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZFIN, &[0; 4]);
pub(crate) const ZNAK_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZNAK, &[0; 4]);
pub(crate) const ZRPOS_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZRPOS, &[0; 4]);
pub(crate) const ZRQINIT_HEADER: Header = Header::new(Encoding::ZHEX, Frame::ZRQINIT, &[0; 4]);
/// Data structure for holding a ZMODEM protocol header, which begins a frame,
/// and is followed optionally by a variable number of subpackets.
#[repr(C)]
#[derive(Clone, Copy, PartialEq)]
pub struct Header {
encoding: Encoding,
frame: Frame,
flags: [u8; 4],
}
impl Header {
/// Creates a new instance
#[must_use]
pub const fn new(encoding: Encoding, frame: Frame, flags: &[u8; 4]) -> Self {
Self {
encoding,
frame,
flags: *flags,
}
}
/// Returns `Encoding` of the frame
#[must_use]
pub const fn encoding(&self) -> Encoding {
self.encoding
}
/// Returns `Frame`, containing the frame type
#[must_use]
pub const fn frame(&self) -> Frame {
self.frame
}
/// Returns count for the frame types using this field
#[must_use]
pub const fn count(&self) -> u32 {
u32::from_le_bytes(self.flags)
}
/// Encodes and writes the header to the serial port
///
/// # Errors
///
/// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port
/// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port
pub fn write<P>(&self, port: &mut P) -> Result<Option<()>, Error>
where
P: Write + ?Sized,
{
if write_header_start(port, self.encoding)?.is_none() {
return Ok(None);
}
let mut out: Buffer<HEADER_SIZE> = Buffer::new();
out.push(self.frame as u8).map_err(|_| Error::OutOfMemory)?;
out.extend_from_slice(&self.flags)
.map_err(|_| Error::OutOfMemory)?;
let mut crc = [0u8; 4];
let crc_len = make_crc(&out, &mut crc, self.encoding);
out.extend_from_slice(&crc[..crc_len])
.map_err(|_| Error::OutOfMemory)?;
if self.encoding == Encoding::ZHEX {
let mut hex_buf = [0u8; HEADER_SIZE];
let len = out.len() * 2;
let hex = &mut hex_buf.get_mut(..len).ok_or(Error::UnexpectedEof)?;
hex::encode_to_slice(&out, hex).map_err(|_| Error::OutOfMemory)?;
if write_slice_escaped(port, hex)?.is_none() {
return Ok(None);
}
if write_header_end_hex(port, self.frame)?.is_none() {
return Ok(None);
}
} else if write_slice_escaped(port, &out)?.is_none() {
return Ok(None);
}
Ok(Some(()))
}
/// Reads and decodes a header from the serial port, and returns a new
/// instance
///
/// # Errors
///
/// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port
/// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port
/// * [`UnexpectedCrc16`](crate::Error::UnexpectedCrc16) or
/// [`UnexpectedCrc32`](crate::Error::UnexpectedCrc32) when corrupted data has been detected
pub fn read<P>(port: &mut P) -> Result<Option<Header>, Error>
where
P: Read + ?Sized,
{
let Some(encoding_byte) = port.read_byte()? else {
return Ok(None);
};
let encoding = Encoding::try_from(encoding_byte)?;
let mut out_hex: Buffer<HEADER_SIZE> = Buffer::new();
for _ in 0..Header::read_size(encoding) {
let Some(byte) = read_byte_unescaped(port)? else {
return Ok(None);
};
out_hex.push(byte).map_err(|_| Error::OutOfMemory)?;
}
let mut out: Buffer<HEADER_SIZE> = Buffer::new();
if encoding == Encoding::ZHEX {
let mut out_bytes = [0u8; HEADER_SIZE / 2];
let out_len = out_hex.len() / 2;
hex::decode_to_slice(&out_hex, &mut out_bytes[..out_len])
.map_err(|_| Error::MalformedHeader)?;
out.extend_from_slice(&out_bytes[..out_len])
.map_err(|_| Error::OutOfMemory)?;
} else {
out.extend_from_slice(&out_hex)
.map_err(|_| Error::OutOfMemory)?;
}
check_crc(&out[..5], &out[5..], encoding)?;
let frame = Frame::try_from(out[0])?;
let mut header = Header::new(encoding, frame, &[0; 4]);
header.flags.copy_from_slice(&out[1..=4]);
Ok(Some(header))
}
/// Returns a new instance with the flags substitude with a count
/// for the frame types using this field.
#[must_use]
pub const fn with_count(&self, count: u32) -> Self {
Header::new(self.encoding, self.frame, &count.to_le_bytes())
}
/// Returns the serialized size of the header payload (payload + CRC)
pub(crate) const fn read_size(encoding: Encoding) -> usize {
match encoding {
Encoding::ZBIN => HEADER_PAYLOAD_SIZE + 2,
Encoding::ZBIN32 => HEADER_PAYLOAD_SIZE + 4,
Encoding::ZHEX => (HEADER_PAYLOAD_SIZE + 2) * 2,
}
}
}
/// The ZMODEM protocol frame encoding
#[repr(u8)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Encoding {
ZBIN = 0x41,
ZHEX = 0x42,
ZBIN32 = 0x43,
}
impl TryFrom<u8> for Encoding {
type Error = Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0x41 => Ok(Encoding::ZBIN),
0x42 => Ok(Encoding::ZHEX),
0x43 => Ok(Encoding::ZBIN32),
_ => Err(Error::MalformedEncoding(value)),
}
}
}
#[repr(u8)]
#[allow(clippy::upper_case_acronyms)]
#[derive(Clone, Copy, Debug, PartialEq)]
/// Frame types
pub enum Frame {
/// Request receive init
ZRQINIT = 0,
/// Receiver capabilities and packet size
ZRINIT = 1,
/// Send init sequence (optional)
ZSINIT = 2,
/// ACK to above
ZACK = 3,
/// File name from sender
ZFILE = 4,
/// To sender: skip this file
ZSKIP = 5,
/// Last packet was garbled
ZNAK = 6,
/// Abort batch transfers
ZABORT = 7,
/// Finish session
ZFIN = 8,
/// Resume data trans at this position
ZRPOS = 9,
/// Data packet(s) follow
ZDATA = 10,
/// End of file
ZEOF = 11,
/// Fatal Read or Write error Detected
ZFERR = 12,
/// Request for file CRC and response
ZCRC = 13,
/// Receiver's Challenge
ZCHALLENGE = 14,
/// Request is complete
ZCOMPL = 15,
/// Other end canned session with CAN*5
ZCAN = 16,
/// Request for free bytes on filesystem
ZFREECNT = 17,
/// Command from sending program
ZCOMMAND = 18,
/// Output to standard error, data follows
ZSTDERR = 19,
}
impl TryFrom<u8> for Frame {
type Error = Error;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Frame::ZRQINIT),
1 => Ok(Frame::ZRINIT),
2 => Ok(Frame::ZSINIT),
3 => Ok(Frame::ZACK),
4 => Ok(Frame::ZFILE),
5 => Ok(Frame::ZSKIP),
6 => Ok(Frame::ZNAK),
7 => Ok(Frame::ZABORT),
8 => Ok(Frame::ZFIN),
9 => Ok(Frame::ZRPOS),
10 => Ok(Frame::ZDATA),
11 => Ok(Frame::ZEOF),
12 => Ok(Frame::ZFERR),
13 => Ok(Frame::ZCRC),
14 => Ok(Frame::ZCHALLENGE),
15 => Ok(Frame::ZCOMPL),
16 => Ok(Frame::ZCAN),
17 => Ok(Frame::ZFREECNT),
18 => Ok(Frame::ZCOMMAND),
19 => Ok(Frame::ZSTDERR),
_ => Err(Error::MalformedFrame(value)),
}
}
}
bitflags! {
/// `ZRINIT` flags
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Zrinit: u8 {
/// Can send and receive in full-duplex
const CANFDX = 0x01;
/// Can receive data in parallel with disk I/O
const CANOVIO = 0x02;
/// Can send a break signal
const CANBRK = 0x04;
/// Can decrypt
const CANCRY = 0x08;
/// Can uncompress
const CANLZW = 0x10;
/// Can use 32-bit frame check
const CANFC32 = 0x20;
/// Expects control character to be escaped
const ESCCTL = 0x40;
/// Expects 8th bit to be escaped
const ESC8 = 0x80;
}
}
fn write_header_start<P>(port: &mut P, encoding: Encoding) -> Result<Option<()>, Error>
where
P: Write + ?Sized,
{
if port.write_byte(ZPAD)?.is_none() {
return Ok(None);
}
if encoding == Encoding::ZHEX && port.write_byte(ZPAD)?.is_none() {
return Ok(None);
}
if port.write_byte(ZDLE)?.is_none() {
return Ok(None);
}
port.write_byte(encoding as u8)
}
fn write_header_end_hex<P>(port: &mut P, frame: Frame) -> Result<Option<()>, Error>
where
P: Write + ?Sized,
{
if port.write_byte(b'\r')?.is_none() {
return Ok(None);
}
if port.write_byte(b'\n')?.is_none() {
return Ok(None);
}
if frame != Frame::ZACK && frame != Frame::ZFIN && port.write_byte(XON)?.is_none() {
return Ok(None);
}
Ok(Some(()))
}
fn make_crc(data: &[u8], out: &mut [u8], encoding: Encoding) -> usize {
if encoding == Encoding::ZBIN32 {
let crc = crc32_iso_hdlc(data).to_le_bytes();
out[..4].copy_from_slice(&crc[..4]);
4
} else {
let crc = crc16_xmodem(data).to_be_bytes();
out[..2].copy_from_slice(&crc[..2]);
2
}
}
fn check_crc(data: &[u8], crc: &[u8], encoding: Encoding) -> Result<(), Error> {
let mut crc2 = [0u8; 4];
let crc2_len = make_crc(data, &mut crc2, encoding);
if *crc == crc2[..crc2_len] {
Ok(())
} else if encoding == Encoding::ZBIN32 {
Err(Error::UnexpectedCrc32)
} else {
Err(Error::UnexpectedCrc16)
}
}
pub(crate) fn write_slice_escaped<P>(port: &mut P, buf: &[u8]) -> Result<Option<()>, Error>
where
P: Write + ?Sized,
{
for value in buf {
if write_byte_escaped(port, *value)?.is_none() {
return Ok(None);
}
}
Ok(Some(()))
}
pub(crate) fn write_byte_escaped<P>(port: &mut P, value: u8) -> Result<Option<()>, Error>
where
P: Write + ?Sized,
{
let escaped = zdle::ZDLE_TABLE[value as usize];
if escaped != value && port.write_byte(ZDLE)?.is_none() {
return Ok(None);
}
port.write_byte(escaped)
}
pub(crate) fn read_byte_unescaped<P>(port: &mut P) -> Result<Option<u8>, Error>
where
P: Read + ?Sized,
{
let Some(b) = port.read_byte()? else {
return Ok(None);
};
Ok(Some(if b == ZDLE {
let Some(b) = port.read_byte()? else {
return Ok(None);
};
zdle::UNZDLE_TABLE[b as usize]
} else {
b
}))
}
+86
View File
@@ -0,0 +1,86 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2023-2025 Jarkko Sakkinen
//! Traits for I/O non-blocking operations.
use crate::Error;
/// Write operations.
pub trait Write {
/// Writes bytes from a buffer to the I/O port.
///
/// # Errors
///
/// [`Read`](crate::Error::Read) when the read I/O fails with the serial
/// port.
/// [`Write`](crate::Error::Write) when the write I/O fails with the
/// serial port.
fn write(&mut self, buf: &[u8]) -> Result<Option<u32>, Error> {
if self.write_all(buf)?.is_none() {
return Ok(None);
}
u32::try_from(buf.len())
.map(Some)
.map_err(|_| Error::OutOfMemory)
}
/// Writes the entire buffer to the I/O port.
///
/// Returns `Ok(None)` only if no bytes were written.
///
/// # Errors
///
/// [`Read`](crate::Error::Read) when the read I/O fails with the serial
/// port.
/// [`Write`](crate::Error::Write) when the write I/O fails with the
/// serial port.
fn write_all(&mut self, buf: &[u8]) -> Result<Option<()>, Error>;
/// Writes a single byte to the I/O port.
///
/// Returns `Ok(None)` only if no bytes were written.
///
/// # Errors
///
/// [`Read`](crate::Error::Read) when the read I/O fails with the serial
/// port.
/// [`Write`](crate::Error::Write) when the write I/O fails with the
/// serial port.
fn write_byte(&mut self, value: u8) -> Result<Option<()>, Error> {
self.write_all(&[value])
}
}
/// Read operations.
pub trait Read {
/// Read bytes from the I/O port.
///
/// Returns `Ok(None)` only if no bytes were written.
///
/// # Errors
///
/// * [`Read`](crate::Error::Read) when the read I/O fails with the serial port.
/// * [`Write`](crate::Error::Write) when the write I/O fails with the serial port.
fn read(&mut self, buf: &mut [u8]) -> Result<Option<u32>, Error>;
/// Read a byte from the I/O port.
///
/// Returns `Ok(None)` only if no bytes were written.
///
/// # Errors
///
/// [`Read`](crate::Error::Read) when the read I/O fails with the serial port.
/// [`Write`](crate::Error::Write) when the write I/O fails with the serial port.
fn read_byte(&mut self) -> Result<Option<u8>, Error>;
}
/// Seek operations
pub trait Seek {
/// Seeks I/O port to an offset.
///
/// # Errors
///
/// [`Read`](crate::Error::Read) when the read I/O fails with the serial port.
/// [`Write`](crate::Error::Write) when the write I/O fails with the serial port.
fn seek(&mut self, offset: u32) -> Result<Option<u32>, Error>;
}
+44
View File
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2026 Jarkko Sakkinen
//! ZMODEM file transfer protocol library. `zmodem2::Sender` and
//! `zmodem2::Receiver` provide stream alike state machines for sending and
//! receiving files with the ZMODEM protocol.
//!
//! The usage can be described in the high-level with the following flow:
//! 1. Create `zmodem2::Sender` or `zmodem2::Receiver`.
//! 2. Drain `drain_outgoing()` returned bytes into the wire and call
//! `advance_outgoing()` after writing. Then, feed incoming bytes with
//! `feed_incoming()`.
//! 3. In the sender, complete `poll_file()` with `feed_file()` if required
//! and handle events via `poll_event()`.
//! 4. In the receiver, write `drain_file()` returned bytes into storage, and
//! call `advance_file()` after writing. Handle events via `poll_event()`.
#![deny(clippy::all)]
#![deny(clippy::pedantic)]
#![allow(clippy::result_large_err)]
#![cfg_attr(not(feature = "std"), no_std)]
mod buffer;
mod crc;
mod error;
mod header;
mod io;
#[cfg(feature = "std")]
mod std;
mod string;
mod transmission;
mod zdle;
pub use buffer::*;
pub use error::*;
pub use header::*;
pub use io::*;
pub use string::*;
pub use transmission::*;
pub const ZPAD: u8 = b'*';
pub const ZDLE: u8 = 0x18;
pub const XON: u8 = 0x11;
+135
View File
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2025 Jarkko Sakkinen
use super::{Encoding, Error, Frame, Header, Read, Seek, String, SubpacketType, Write};
use std::{fmt, io::SeekFrom};
impl<W> Write for W
where
W: std::io::Write,
{
fn write(&mut self, buf: &[u8]) -> Result<Option<u32>, Error> {
match std::io::Write::write(self, buf) {
Ok(bytes_written) => u32::try_from(bytes_written)
.map(Some)
.map_err(|_| Error::OutOfMemory),
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(Error::Write(String::from(e.to_string().as_str())))
}
}
}
}
fn write_all(&mut self, buf: &[u8]) -> Result<Option<()>, Error> {
match std::io::Write::write_all(self, buf) {
Ok(()) => Ok(Some(())),
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(Error::Write(String::from(e.to_string().as_str())))
}
}
}
}
}
impl<R> Read for R
where
R: std::io::Read,
{
fn read(&mut self, buf: &mut [u8]) -> Result<Option<u32>, Error> {
match std::io::Read::read(self, buf) {
Ok(bytes_read) => u32::try_from(bytes_read)
.map(Some)
.map_err(|_| Error::OutOfMemory),
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(Error::Read(String::from(e.to_string().as_str())))
}
}
}
}
fn read_byte(&mut self) -> Result<Option<u8>, Error> {
let mut buf = [0; 1];
match std::io::Read::read(self, &mut buf) {
Ok(1) => Ok(Some(buf[0])),
Ok(0) => Err(Error::UnexpectedEof),
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(Error::Read(String::from(e.to_string().as_str())))
}
}
Ok(_) => Err(Error::Read(String::from("unknown read error"))),
}
}
}
impl<S> Seek for S
where
S: std::io::Seek,
{
fn seek(&mut self, offset: u32) -> Result<Option<u32>, Error> {
let new_offset = u64::from(offset);
match std::io::Seek::seek(self, SeekFrom::Start(new_offset)) {
Ok(final_offset) => u32::try_from(final_offset)
.map(Some)
.map_err(|_| Error::UnexpectedEof),
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
Ok(None)
} else {
Err(Error::Read(String::from(e.to_string().as_str())))
}
}
}
}
}
impl fmt::Display for Header {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:8} {}", self.encoding(), self.frame())
}
}
impl fmt::Display for Encoding {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:#02x}", *self as u8)
}
}
impl fmt::Display for Frame {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:#02x}", *self as u8)
}
}
impl fmt::Display for SubpacketType {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{:#0x}", *self as u8)
}
}
impl std::fmt::Debug for String {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match core::str::from_utf8(self) {
Ok(s) => f.write_str(s),
Err(_) => f.debug_list().entries(self.iter()).finish(),
}
}
}
impl std::fmt::Display for String {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(core::str::from_utf8(self).unwrap_or(""))
}
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2023-2025 Jarkko Sakkinen
use super::{Buffer, CapacityError};
use core::{
cmp::min,
ops::{Deref, DerefMut},
};
/// The capacity of the fixed-size `String` type.
const STRING_CAP: usize = 256;
/// A stack-allocated, fixed-capacity string.
///
/// This is a newtype wrapper around `Buffer<256>` to provide type safety and
/// string-specific operations.
#[derive(Eq)]
pub struct String(Buffer<STRING_CAP>);
impl Default for String {
fn default() -> Self {
Self::new()
}
}
impl From<&str> for String {
/// Creates a new `String` from a `&str`, truncating if necessary.
fn from(s: &str) -> Self {
let mut string = Self::new();
let bytes = s.as_bytes();
let len = min(bytes.len(), STRING_CAP);
let mut end = len;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
let truncated_bytes = &bytes[..end];
string
.extend_from_slice(truncated_bytes)
.unwrap_or_default();
string
}
}
impl String {
/// Creates a new, empty string.
#[must_use]
pub const fn new() -> Self {
Self(Buffer::<STRING_CAP>::new())
}
/// Resets buffer length back to zero.
pub fn clear(&mut self) {
self.0.clear();
}
/// Returns the capacity of the buffer in bytes.
#[must_use]
pub const fn capacity(&self) -> usize {
self.0.capacity()
}
/// Copies bytes from a slice to the end of the buffer.
///
/// # Errors
///
/// Returns `Err(CapacityError)`, if the capacity would be exceeded.
pub fn extend_from_slice(&mut self, slice: &[u8]) -> Result<(), CapacityError> {
self.0.extend_from_slice(slice)
}
}
impl Deref for String {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for String {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl AsRef<[u8]> for String {
fn as_ref(&self) -> &[u8] {
self.0.as_ref()
}
}
impl<T: ?Sized> PartialEq<T> for String
where
T: AsRef<[u8]>,
{
fn eq(&self, other: &T) -> bool {
self.as_ref() == other.as_ref()
}
}
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2025 Jarkko Sakkinen
/// ZMODEM escape table, a constant array for escaping characters.
pub const ZDLE_TABLE: [u8; 0x100] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x4d, 0x0e, 0x0f,
0x50, 0x51, 0x12, 0x53, 0x14, 0x15, 0x16, 0x17, 0x58, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f,
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f,
0x40, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4a, 0x4b, 0x4c, 0x4d, 0x4e, 0x4f,
0x50, 0x51, 0x52, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, 0x5a, 0x5b, 0x5c, 0x5d, 0x5e, 0x5f,
0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x6c, 0x6d, 0x6e, 0x6f,
0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x6c,
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0xcd, 0x8e, 0x8f,
0xd0, 0xd1, 0x92, 0xd3, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf,
0xc0, 0xc1, 0xc2, 0xc3, 0xc4, 0xc5, 0xc6, 0xc7, 0xc8, 0xc9, 0xca, 0xcb, 0xcc, 0xcd, 0xce, 0xcf,
0xd0, 0xd1, 0xd2, 0xd3, 0xd4, 0xd5, 0xd6, 0xd7, 0xd8, 0xd9, 0xda, 0xdb, 0xdc, 0xdd, 0xde, 0xdf,
0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef,
0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0x6d,
];
/// ZMODEM unescape table, a constant array for converting escaped characters back.
pub const UNZDLE_TABLE: [u8; 0x100] = [
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
0x20, 0x21, 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, 0x2b, 0x2c, 0x2d, 0x2e, 0x2f,
0x30, 0x31, 0x32, 0x33, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3a, 0x3b, 0x3c, 0x3d, 0x3e, 0x3f,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f,
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f,
0x60, 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, 0x6b, 0x7f, 0xff, 0x6e, 0x6f,
0x70, 0x71, 0x72, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7a, 0x7b, 0x7c, 0x7d, 0x7e, 0x7f,
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f,
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
0xa0, 0xa1, 0xa2, 0xa3, 0xa4, 0xa5, 0xa6, 0xa7, 0xa8, 0xa9, 0xaa, 0xab, 0xac, 0xad, 0xae, 0xaf,
0xb0, 0xb1, 0xb2, 0xb3, 0xb4, 0xb5, 0xb6, 0xb7, 0xb8, 0xb9, 0xba, 0xbb, 0xbc, 0xbd, 0xbe, 0xbf,
0x80, 0x81, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, 0x8a, 0x8b, 0x8c, 0x8d, 0x8e, 0x8f,
0x90, 0x91, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9a, 0x9b, 0x9c, 0x9d, 0x9e, 0x9f,
0xe0, 0xe1, 0xe2, 0xe3, 0xe4, 0xe5, 0xe6, 0xe7, 0xe8, 0xe9, 0xea, 0xeb, 0xec, 0xed, 0xee, 0xef,
0xf0, 0xf1, 0xf2, 0xf3, 0xf4, 0xf5, 0xf6, 0xf7, 0xf8, 0xf9, 0xfa, 0xfb, 0xfc, 0xfd, 0xfe, 0xff,
];
+468
View File
@@ -0,0 +1,468 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2026 Jarkko Sakkinen
use nix::fcntl::{self, OFlag};
use std::cmp::{max, min};
use std::collections::HashMap;
use std::fs::File;
use std::io::{BufRead, BufReader, Read, Result, Seek, Write};
use std::os::unix::io::{AsRawFd, RawFd};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::thread::sleep;
use std::time::{Duration, Instant};
use tempfile::TempDir;
const FILE_COUNT: usize = 10;
const FILE_SIZE: usize = 50 * 1024;
const RATE_BPS: u32 = 115200;
const NAME_PREFIX: &[&str] = &[
"Laser",
"Neon",
"Chrome",
"Cosmic",
"Turbo",
"Starlight",
"Future",
];
const NAME_POSTFIX: &[&str] = &[
"Rider", "Funk", "Dream", "Grid", "System", "Dancer", "Midnight",
];
const EXTENSIONS: &[&str] = &["dat", "BIN", "log", "TMP", "txt"];
struct MockPort<R: Read, W: Write> {
r: R,
w: W,
bits_per_second: u32,
next_byte_due: Instant,
}
impl<R: Read, W: Write> MockPort<R, W> {
pub fn new(r: R, w: W, bits_per_second: u32) -> Self {
MockPort {
r,
w,
bits_per_second,
next_byte_due: Instant::now(),
}
}
fn throttle(&mut self, bytes_transferred: usize) {
if self.bits_per_second == 0 {
return;
}
let bits_transferred = (bytes_transferred * 10) as f64;
let duration_needed =
Duration::from_secs_f64(bits_transferred / f64::from(self.bits_per_second));
let now = Instant::now();
if self.next_byte_due > now {
sleep(self.next_byte_due - now);
}
self.next_byte_due = max(now, self.next_byte_due) + duration_needed;
}
}
impl<R: Read, W: Write> Read for MockPort<R, W> {
fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
let bytes_read = self.r.read(buf)?;
if bytes_read > 0 {
self.throttle(bytes_read);
}
Ok(bytes_read)
}
}
impl<R: Read, W: Write> Write for MockPort<R, W> {
fn write(&mut self, buf: &[u8]) -> Result<usize> {
let bytes_written = self.w.write(buf)?;
if bytes_written > 0 {
self.throttle(bytes_written);
}
Ok(bytes_written)
}
fn flush(&mut self) -> Result<()> {
self.w.flush()
}
}
/// Creates a temporary file with a predictable, patterned content.
fn create_test_file(path: &Path, size_bytes: usize) {
let mut file = File::create(path).unwrap();
let mut buffer = [0u8; 1024];
for (i, byte) in buffer.iter_mut().enumerate() {
*byte = (i % 256) as u8;
}
let mut bytes_written = 0;
while bytes_written < size_bytes {
let to_write = min(buffer.len(), size_bytes - bytes_written);
file.write_all(&buffer[..to_write]).unwrap();
bytes_written += to_write;
}
}
/// Asserts that two files have the same size and content.
fn assert_files_equal(path1: &Path, path2: &Path) {
let meta1 = path1.metadata().unwrap();
let meta2 = path2.metadata().unwrap();
assert_eq!(meta1.len(), meta2.len(), "File sizes do not match");
let mut f1 = BufReader::new(File::open(path1).unwrap());
let mut f2 = BufReader::new(File::open(path2).unwrap());
loop {
let buf1 = f1.fill_buf().unwrap();
let buf2 = f2.fill_buf().unwrap();
if buf1.is_empty() && buf2.is_empty() {
break;
}
assert_eq!(buf1, buf2, "File contents do not match");
let len1 = buf1.len();
let len2 = buf2.len();
f1.consume(len1);
f2.consume(len2);
}
}
/// Manages a set of temporary files for a test run.
struct TestFiles {
#[allow(dead_code)]
dir: TempDir,
paths: Vec<PathBuf>,
}
impl TestFiles {
fn new() -> Self {
let dir = tempfile::Builder::new()
.prefix("zmodem_test_src_")
.tempdir()
.unwrap();
let mut paths = Vec::new();
for i in 0..FILE_COUNT {
let prefix = NAME_PREFIX[i % NAME_PREFIX.len()];
let postfix = NAME_POSTFIX[i % NAME_POSTFIX.len()];
let ext = EXTENSIONS[i % EXTENSIONS.len()];
let filename = format!("{prefix}{postfix}_{i}.{ext}");
let path = dir.path().join(filename);
create_test_file(&path, FILE_SIZE);
paths.push(path);
}
Self { dir, paths }
}
}
/// Sets the O_NONBLOCK flag on a raw file descriptor.
fn set_nonblocking(fd: RawFd) {
let flags = fcntl::fcntl(fd, fcntl::FcntlArg::F_GETFL).unwrap();
let mut nonblocking_flags = OFlag::from_bits_truncate(flags);
nonblocking_flags.insert(OFlag::O_NONBLOCK);
fcntl::fcntl(fd, fcntl::FcntlArg::F_SETFL(nonblocking_flags)).unwrap();
}
/// Helper to set up a non-blocking `sz` process.
fn setup_sz(test_files: &TestFiles) -> (Child, MockPort<ChildStdout, ChildStdin>) {
let mut sz_process = Command::new(env!("ZMODEM_SZ_BIN"))
.args(&test_files.paths)
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.spawn()
.unwrap();
let stdin = sz_process.stdin.take().unwrap();
let stdout = sz_process.stdout.take().unwrap();
set_nonblocking(stdin.as_raw_fd());
set_nonblocking(stdout.as_raw_fd());
let port = MockPort::new(stdout, stdin, RATE_BPS);
(sz_process, port)
}
/// Helper to set up a non-blocking `rz` process.
fn setup_rz(dest_dir: &TempDir) -> (Child, MockPort<ChildStdout, ChildStdin>) {
let mut rz_process: Child = Command::new(env!("ZMODEM_RZ_BIN"))
.stdout(Stdio::piped())
.stdin(Stdio::piped())
.current_dir(dest_dir.path())
.spawn()
.unwrap();
let stdin = rz_process.stdin.take().unwrap();
let stdout = rz_process.stdout.take().unwrap();
set_nonblocking(stdin.as_raw_fd());
set_nonblocking(stdout.as_raw_fd());
let port = MockPort::new(stdout, stdin, RATE_BPS);
(rz_process, port)
}
struct MockBlockingFile {
file: File,
counter: usize,
}
impl Write for MockBlockingFile {
fn write(&mut self, buf: &[u8]) -> Result<usize> {
self.counter += 1;
if self.counter % 10 == 0 {
return Err(std::io::Error::from(std::io::ErrorKind::WouldBlock));
}
self.file.write(buf)
}
fn flush(&mut self) -> Result<()> {
self.file.flush()
}
}
#[test]
#[cfg(has_lrzsz)]
fn test_batch_from_sz() {
let test_files = TestFiles::new();
let dest_dir = tempfile::Builder::new()
.prefix("zmodem_test_dest_")
.tempdir()
.unwrap();
let (mut sz_process, mut port) = setup_sz(&test_files);
let mut receiver = zmodem2::Receiver::new().unwrap();
let mut open_files: HashMap<Vec<u8>, MockBlockingFile> = HashMap::new();
let mut sink = std::io::sink();
let mut current_file_name_bytes: Vec<u8> = Vec::new();
let mut wire_buf = [0u8; 4096];
let mut input_buf: Vec<u8> = Vec::new();
let mut input_offset: usize = 0;
let mut session_done = false;
while !session_done || !receiver.drain_outgoing().is_empty() {
let mut progressed = false;
if !receiver.drain_outgoing().is_empty() {
match port.write(receiver.drain_outgoing()) {
Ok(0) => {}
Ok(n) => {
receiver.advance_outgoing(n);
progressed = true;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => panic!("wire write failed: {e}"),
}
}
if !receiver.drain_file().is_empty() {
let file_writer: &mut dyn Write = open_files
.get_mut(&current_file_name_bytes)
.map(|f| f as &mut dyn Write)
.unwrap_or(&mut sink);
match file_writer.write(receiver.drain_file()) {
Ok(0) => {}
Ok(n) => {
receiver.advance_file(n).unwrap();
progressed = true;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => panic!("file write failed: {e}"),
}
}
match port.read(&mut wire_buf) {
Ok(0) => {}
Ok(n) => {
input_buf.extend_from_slice(&wire_buf[..n]);
progressed = true;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => panic!("wire read failed: {e}"),
}
if receiver.drain_outgoing().is_empty()
&& receiver.drain_file().is_empty()
&& input_offset < input_buf.len()
{
let consumed = receiver.feed_incoming(&input_buf[input_offset..]).unwrap();
if consumed > 0 {
input_offset += consumed;
progressed = true;
if input_offset == input_buf.len() {
input_buf.clear();
input_offset = 0;
} else if input_offset > 4096 {
input_buf.drain(..input_offset);
input_offset = 0;
}
}
}
while let Some(event) = receiver.poll_event() {
match event {
zmodem2::ReceiverEvent::FileStart => {
let name = receiver.file_name();
if name != current_file_name_bytes.as_slice() {
let filename_str = std::str::from_utf8(name).unwrap();
let filename = Path::new(filename_str)
.file_name()
.unwrap()
.to_str()
.unwrap();
let file_path = dest_dir.path().join(filename);
let file = File::create(file_path).unwrap();
open_files.insert(name.to_vec(), MockBlockingFile { file, counter: 0 });
current_file_name_bytes = name.to_vec();
}
}
zmodem2::ReceiverEvent::FileComplete => {}
zmodem2::ReceiverEvent::SessionComplete => {
session_done = true;
}
}
}
if !progressed {
sleep(Duration::from_millis(5));
}
}
sz_process.wait().unwrap();
for path in &test_files.paths {
let filename = path.file_name().unwrap();
let received_path = dest_dir.path().join(filename);
assert!(
received_path.exists(),
"File '{}' was not received",
received_path.display()
);
assert_files_equal(path, &received_path);
}
}
#[test]
#[cfg(has_lrzsz)]
fn test_batch_to_rz() {
let test_files = TestFiles::new();
let dest_dir = tempfile::Builder::new()
.prefix("zmodem_test_dest_")
.tempdir()
.unwrap();
let (mut rz_process, mut port) = setup_rz(&dest_dir);
let mut open_files: HashMap<String, File> = HashMap::new();
for path in &test_files.paths {
let filename = path.file_name().unwrap().to_str().unwrap().to_string();
let file = File::open(path).unwrap();
open_files.insert(filename, file);
}
let mut file_iter = test_files.paths.iter();
let first_path = file_iter.next().expect("No test files found");
let first_filename = first_path.file_name().unwrap().to_str().unwrap();
let first_size = first_path.metadata().unwrap().len() as u32;
let mut sender = zmodem2::Sender::new().unwrap();
sender
.start_file(first_filename.as_bytes(), first_size)
.unwrap();
let mut current_filename = first_filename.to_string();
let mut wire_buf = [0u8; 4096];
let mut input_buf: Vec<u8> = Vec::new();
let mut input_offset: usize = 0;
let mut file_buf = [0u8; 1024];
let mut session_done = false;
while !session_done || !sender.drain_outgoing().is_empty() {
let mut progressed = false;
if !sender.drain_outgoing().is_empty() {
match port.write(sender.drain_outgoing()) {
Ok(0) => {}
Ok(n) => {
sender.advance_outgoing(n);
progressed = true;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => panic!("wire write failed: {e}"),
}
}
if let Some(request) = sender.poll_file() {
let file = open_files
.get_mut(&current_filename)
.expect("File not found in map");
file.seek(std::io::SeekFrom::Start(u64::from(request.offset)))
.unwrap();
let n = file.read(&mut file_buf[..request.len]).unwrap();
sender.feed_file(&file_buf[..n]).unwrap();
progressed = true;
}
match port.read(&mut wire_buf) {
Ok(0) => {}
Ok(n) => {
input_buf.extend_from_slice(&wire_buf[..n]);
progressed = true;
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
Err(e) => panic!("wire read failed: {e}"),
}
if sender.drain_outgoing().is_empty() && input_offset < input_buf.len() {
let consumed = sender.feed_incoming(&input_buf[input_offset..]).unwrap();
if consumed > 0 {
input_offset += consumed;
progressed = true;
if input_offset == input_buf.len() {
input_buf.clear();
input_offset = 0;
} else if input_offset > 4096 {
input_buf.drain(..input_offset);
input_offset = 0;
}
}
}
if let Some(event) = sender.poll_event() {
match event {
zmodem2::SenderEvent::FileComplete => {
if let Some(next_path) = file_iter.next() {
let next_filename = next_path.file_name().unwrap().to_str().unwrap();
let next_size = next_path.metadata().unwrap().len() as u32;
sender
.start_file(next_filename.as_bytes(), next_size)
.unwrap();
current_filename = next_filename.to_string();
} else {
sender.finish_session().unwrap();
}
}
zmodem2::SenderEvent::SessionComplete => {
session_done = true;
}
}
}
if !progressed {
sleep(Duration::from_millis(5));
}
}
rz_process.wait().unwrap();
for path in &test_files.paths {
let filename = path.file_name().unwrap();
let received_path = dest_dir.path().join(filename);
assert!(
received_path.exists(),
"File '{}' was not sent",
received_path.display()
);
assert_files_equal(path, &received_path);
}
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: MIT OR Apache-2.0
// Copyright (c) 2017-2020 Alexey Arbuzov
// Copyright (c) 2023-2026 Jarkko Sakkinen
use rstest::rstest;
use zmodem2::{Encoding, Frame, Header, Receiver, ReceiverEvent, Sender, XON, ZDLE, ZPAD};
#[rstest]
#[case(Encoding::ZBIN, Frame::ZRQINIT, &[0; 4], &[ZPAD, ZDLE, Encoding::ZBIN as u8, 0, 0, 0, 0, 0, 0, 0])]
#[case(Encoding::ZBIN32, Frame::ZRQINIT, &[0; 4], &[ZPAD, ZDLE, Encoding::ZBIN32 as u8, 0, 0, 0, 0, 0, 29, 247, 34, 198])]
#[case(Encoding::ZBIN, Frame::ZRQINIT, &[1; 4], &[ZPAD, ZDLE, Encoding::ZBIN as u8, 0, 1, 1, 1, 1, 98, 148])]
#[case(Encoding::ZHEX, Frame::ZRQINIT, &[1; 4], &[ZPAD, ZPAD, ZDLE, Encoding::ZHEX as u8, b'0', b'0', b'0', b'1', b'0', b'1', b'0', b'1', b'0', b'1', 54, 50, 57, 52, b'\r', b'\n', XON])]
pub fn test_header_write(
#[case] encoding: Encoding,
#[case] frame: Frame,
#[case] flags: &[u8; 4],
#[case] expected: &[u8],
) {
let header = Header::new(encoding, frame, flags);
let mut port = vec![];
assert!(header.write(&mut port) == Ok(Some(())));
assert_eq!(port, expected);
}
#[rstest]
#[case(&[Encoding::ZHEX as u8, b'0', b'1', b'0', b'1', b'0', b'2', b'0', b'3', b'0', b'4', b'a', b'7', b'5', b'2'], Encoding::ZHEX, Frame::ZRINIT, &[0x1, 0x2, 0x3, 0x4])]
#[case(&[Encoding::ZBIN as u8, Frame::ZRINIT as u8, 0xa, 0xb, 0xc, 0xd, 0xa6, 0xcb], Encoding::ZBIN, Frame::ZRINIT, &[0xa, 0xb, 0xc, 0xd])]
#[case(&[Encoding::ZBIN32 as u8, Frame::ZRINIT as u8, 0xa, 0xb, 0xc, 0xd, 0x99, 0xe2, 0xae, 0x4a], Encoding::ZBIN32, Frame::ZRINIT, &[0xa, 0xb, 0xc, 0xd])]
#[case(&[Encoding::ZBIN as u8, Frame::ZRINIT as u8, 0xa, ZDLE, b'l', 0xd, ZDLE, b'm', 0x5e, 0x6f], Encoding::ZBIN, Frame::ZRINIT, &[0xa, 0x7f, 0xd, 0xff])]
pub fn test_header_read(
#[case] port: &[u8],
#[case] encoding: Encoding,
#[case] frame: Frame,
#[case] flags: &[u8; 4],
) {
let port = &mut port.to_vec();
let port = &mut port.as_slice();
assert!(Header::read(port) == Ok(Some(Header::new(encoding, frame, flags))));
}
#[test]
fn test_receive_malformed_header() {
let mut receiver = Receiver::new().unwrap();
receiver.advance_outgoing(receiver.drain_outgoing().len());
let input = b"malformed data";
let consumed = receiver.feed_incoming(input).unwrap();
assert_eq!(consumed, input.len());
assert!(receiver.drain_outgoing().is_empty());
assert!(receiver.drain_file().is_empty());
assert!(receiver.poll_event().is_none());
}
#[test]
fn test_receive_zfile_with_non_utf8_name() {
let file_name = b"bad\x80name";
let file_size = 123;
let mut sender = Sender::new().unwrap();
sender.start_file(file_name, file_size).unwrap();
sender.advance_outgoing(sender.drain_outgoing().len());
let zrinit = Header::new(Encoding::ZHEX, Frame::ZRINIT, &[0; 4]);
let mut zrinit_bytes = Vec::new();
zrinit.write(&mut zrinit_bytes).unwrap();
let consumed = sender.feed_incoming(&zrinit_bytes).unwrap();
assert!(consumed > 0 && consumed <= zrinit_bytes.len());
let wire = sender.drain_outgoing().to_vec();
sender.advance_outgoing(wire.len());
let mut receiver = Receiver::new().unwrap();
receiver.advance_outgoing(receiver.drain_outgoing().len());
let mut input = wire.as_slice();
let mut got_start = false;
for _ in 0..(wire.len() * 4) {
if input.is_empty() {
break;
}
let consumed = receiver.feed_incoming(input).unwrap();
if consumed == 0 {
if !receiver.drain_outgoing().is_empty() {
receiver.advance_outgoing(receiver.drain_outgoing().len());
}
} else {
input = &input[consumed..];
}
if let Some(ReceiverEvent::FileStart) = receiver.poll_event() {
got_start = true;
break;
}
}
assert!(got_start);
assert_eq!(receiver.file_name(), file_name);
assert_eq!(receiver.file_size(), file_size);
}
+23
View File
@@ -60,6 +60,7 @@ import {
type TerminalWindowNode,
updateTerminalWindowSplitRatio,
} from "./lib/tabWindows";
import { setBackendTransferDuplicatePrompt } from "./lib/transferDuplicatePrompt";
import { checkForUpdate, type UpdateInfo } from "./lib/updater";
import {
getOwnerMainWindowLabel,
@@ -388,6 +389,28 @@ function App() {
}),
);
unsubs.push(
listen<{
requestId: string;
sessionId: string;
remotePath: string;
fileName: string;
isDirectory: boolean;
targetWindowLabel?: string | null;
}>("transfer-duplicate-request", (event) => {
if (!eventTargetsCurrentWindow(event.payload.targetWindowLabel)) return;
setBackendTransferDuplicatePrompt({
requestId: event.payload.requestId,
sessionId: event.payload.sessionId,
remotePath: event.payload.remotePath,
fileName: event.payload.fileName,
isDirectory: event.payload.isDirectory,
targetWindowLabel: event.payload.targetWindowLabel,
respondViaBackend: true,
});
}),
);
unsubs.push(
listen<{
connectionId: string;
@@ -0,0 +1,41 @@
interface ExternalFileDropOverlayProps {
title: string;
hint: string;
insetClassName?: string;
}
export default function ExternalFileDropOverlay({
title,
hint,
insetClassName = "inset-2",
}: ExternalFileDropOverlayProps) {
return (
<div
className={`pointer-events-none absolute ${insetClassName} z-10 flex items-center justify-center rounded-lg border-2 border-dashed`}
style={{
borderColor: "var(--df-primary)",
backgroundColor: "rgba(59, 130, 246, 0.14)",
}}
>
<div
className="max-w-sm rounded-lg border px-6 py-4 text-center shadow-xl"
style={{
borderColor: "var(--df-primary)",
backgroundColor: "var(--df-bg-panel)",
color: "var(--df-text)",
boxShadow: "0 12px 40px rgba(0, 0, 0, 0.28)",
}}
>
<div className="flex flex-col items-center gap-1.5">
<div className="text-base font-semibold leading-snug">{title}</div>
<div
className="text-xs font-normal leading-relaxed"
style={{ color: "var(--df-text-dimmed)" }}
>
{hint}
</div>
</div>
</div>
</div>
);
}
+2
View File
@@ -10,6 +10,7 @@ import type { HostKeyVerifyRequest } from "@/components/dialog/connections/HostK
import { HostKeyVerifyDialog } from "@/components/dialog/connections/HostKeyVerifyDialog";
import type { OtpRequest } from "@/components/dialog/connections/OtpDialog";
import { OtpDialog } from "@/components/dialog/connections/OtpDialog";
import { TransferDuplicateDialog } from "@/components/dialog/file-explorer/TransferDuplicateDialog";
import SyncGroupDialog from "@/components/dialog/terminal/SyncGroupDialog";
import ActivityBar from "@/components/layout/ActivityBar";
import Header from "@/components/layout/Header";
@@ -461,6 +462,7 @@ export default function AppLayout({
request={dialogs.hostKeyVerifyRequest}
onDone={dialogs.onHostKeyVerifyDone}
/>
<TransferDuplicateDialog />
{dialogs.modalChildWindowCount > 0 && (
<div
@@ -2,15 +2,16 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { MdRefresh } from "react-icons/md";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { invoke } from "@/lib/invoke";
export interface DeleteDialogData {
@@ -72,16 +73,32 @@ export default function DeleteDialog({ data, onClose, onSuccess }: DeleteDialogP
};
return (
<Dialog open onOpenChange={(v) => !v && !isSubmitting && onClose()}>
<DialogContent className="w-[min(20rem,calc(100vw-2rem))] sm:max-w-80">
<DialogHeader className="min-w-0 pr-8">
<DialogTitle className="text-sm break-all leading-relaxed">
<AlertDialog
open
onOpenChange={(open) => {
if (!open && !isSubmitting) {
onClose();
}
}}
>
<AlertDialogContent
size="sm"
className="w-[min(20rem,calc(100vw-2rem))] sm:max-w-80"
onKeyDown={(event) => {
if (event.key === "Enter" && !isSubmitting && data.items.length > 0) {
event.preventDefault();
void handleDeleteSubmit();
}
}}
>
<AlertDialogHeader className="min-w-0 text-left">
<AlertDialogTitle className="text-sm break-all leading-relaxed">
{data.items.length === 1
? t("fileExplorer.sureDelete", { name: data.items[0]?.name ?? "" })
: t("fileExplorer.sureDeleteMultiple", { count: data.items.length })}
</DialogTitle>
<DialogDescription className="sr-only">{t("fileExplorer.cmDelete")}</DialogDescription>
</DialogHeader>
</AlertDialogTitle>
<AlertDialogDescription>{t("fileExplorer.deleteConfirmHint")}</AlertDialogDescription>
</AlertDialogHeader>
{data.items.length > 1 && (
<div
@@ -101,22 +118,22 @@ export default function DeleteDialog({ data, onClose, onSuccess }: DeleteDialogP
</div>
)}
<DialogFooter className="mt-4 min-w-0">
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
{t("dialog.cancel")}
</Button>
<Button
<AlertDialogFooter className="mt-2 min-w-0">
<AlertDialogCancel disabled={isSubmitting}>{t("dialog.cancel")}</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
size="sm"
onClick={handleDeleteSubmit}
disabled={isSubmitting || data.items.length === 0}
autoFocus
disabled={isSubmitting || data.items.length === 0}
onClick={(event) => {
event.preventDefault();
void handleDeleteSubmit();
}}
>
{isSubmitting && <MdRefresh className="mr-1 text-[0.875rem] animate-spin" />}
{t("fileExplorer.cmDelete")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { TransferItem } from "@/context/TransferContext";
interface DeleteTransferDialogProps {
transfer: TransferItem | null;
onCancel: () => void;
onConfirm: () => void;
}
export default function DeleteTransferDialog({
transfer,
onCancel,
onConfirm,
}: DeleteTransferDialogProps) {
const { t } = useTranslation();
return (
<AlertDialog
open={!!transfer}
onOpenChange={(open) => {
if (!open) onCancel();
}}
>
<AlertDialogContent
size="sm"
onKeyDown={(event) => {
if (event.key === "Enter" && transfer) {
event.preventDefault();
onConfirm();
}
}}
>
<AlertDialogHeader className="text-left">
<AlertDialogTitle>{t("fileTransfer.deleteConfirmTitle")}</AlertDialogTitle>
<AlertDialogDescription>
{t("fileTransfer.deleteConfirmDesc", { name: transfer?.fileName ?? "" })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("dialog.cancel")}</AlertDialogCancel>
<AlertDialogAction variant="destructive" autoFocus onClick={onConfirm}>
{t("fileTransfer.delete")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { invoke } from "@/lib/invoke";
import { logger } from "@/lib/logger";
import {
getActiveTransferDuplicatePrompt,
resolveTransferDuplicatePrompt,
setBackendTransferDuplicatePrompt,
subscribeTransferDuplicatePrompt,
type TransferDuplicateChoice,
type TransferDuplicateRequest,
} from "@/lib/transferDuplicatePrompt";
export function TransferDuplicateDialog() {
const { t } = useTranslation();
const [request, setRequest] = useState<TransferDuplicateRequest | null>(() =>
getActiveTransferDuplicatePrompt(),
);
const [submitting, setSubmitting] = useState(false);
useEffect(() => subscribeTransferDuplicatePrompt(setRequest), []);
const handleChoice = async (choice: TransferDuplicateChoice) => {
if (!request || submitting) return;
setSubmitting(true);
try {
if (request.respondViaBackend) {
await invoke("respond_transfer_duplicate", {
requestId: request.requestId,
action: choice,
});
} else {
resolveTransferDuplicatePrompt(choice);
setSubmitting(false);
return;
}
} catch (error) {
logger.error({
domain: "transfer.lifecycle",
event: "duplicate.response_failed",
message: "Failed to send duplicate resolution to backend",
ids: { request_id: request.requestId },
error,
});
if (request.respondViaBackend) {
await invoke("respond_transfer_duplicate", {
requestId: request.requestId,
action: "skip",
}).catch(() => {});
} else {
resolveTransferDuplicatePrompt("skip");
}
}
setSubmitting(false);
if (request.respondViaBackend) {
setBackendTransferDuplicatePrompt(null);
}
};
const kindLabel = request?.isDirectory
? t("fileTransfer.duplicateKindFolder")
: t("fileTransfer.duplicateKindFile");
return (
<AlertDialog
open={!!request}
onOpenChange={(open) => {
if (!open && request && !submitting) {
void handleChoice("skip");
}
}}
>
<AlertDialogContent
size="sm"
className="w-[min(22rem,calc(100vw-2rem))] sm:max-w-md"
onKeyDown={(event) => {
if (event.key === "Enter" && !submitting && request) {
event.preventDefault();
void handleChoice("overwrite");
}
}}
>
<AlertDialogHeader className="min-w-0 text-left">
<AlertDialogTitle className="text-sm">
{t("fileTransfer.duplicateTitle")}
</AlertDialogTitle>
<AlertDialogDescription className="text-xs leading-relaxed">
{t("fileTransfer.duplicateDescription", {
kind: kindLabel,
name: request?.fileName ?? "",
})}
</AlertDialogDescription>
</AlertDialogHeader>
{request?.remotePath && (
<div
className="rounded-md border px-2 py-1.5 font-mono text-[0.6875rem] break-all"
style={{ borderColor: "var(--df-border)", color: "var(--df-text-dimmed)" }}
>
{request.remotePath}
</div>
)}
<AlertDialogFooter className="gap-3">
<AlertDialogCancel
className="text-xs"
disabled={submitting}
onClick={(event) => {
event.preventDefault();
void handleChoice("skip");
}}
>
{t("fileTransfer.duplicateSkip")}
</AlertDialogCancel>
<AlertDialogAction
className="text-xs"
disabled={submitting}
onClick={(event) => {
event.preventDefault();
void handleChoice("overwrite");
}}
>
{t("fileTransfer.duplicateOverwrite")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -39,6 +39,7 @@ import type { MoveDialogData } from "@/components/dialog/file-explorer/MoveDialo
import type { NewItemDialogData } from "@/components/dialog/file-explorer/NewItemDialog";
import type { NewSymlinkDialogData } from "@/components/dialog/file-explorer/NewSymlinkDialog";
import type { PropertiesDialogData } from "@/components/dialog/file-explorer/PropertiesDialog";
import ExternalFileDropOverlay from "@/components/ExternalFileDropOverlay";
import PanelHeader from "@/components/layout/PanelHeader";
import { Button } from "@/components/ui/button";
import {
@@ -192,8 +193,9 @@ function FileExplorer({
const autoSyncCwd = !!activeConnectionId && autoSyncConnectionIds.includes(activeConnectionId);
const favoriteDirectoriesByConnection =
appSettings.ui.file_explorer_favorite_dirs_by_connection_id ?? {};
const favoriteDirectories =
activeConnectionId ? (favoriteDirectoriesByConnection[activeConnectionId] ?? []) : [];
const favoriteDirectories = activeConnectionId
? (favoriteDirectoriesByConnection[activeConnectionId] ?? [])
: [];
const listScrollResetKey = `${activeSessionId ?? ""}:${currentPath}`;
const listFilterResetKey = `${fileSearchQuery}:${fileSortMode.column}:${fileSortMode.direction}`;
@@ -1702,16 +1704,11 @@ function FileExplorer({
<ContextMenuTrigger asChild>
<div className="relative min-h-0 flex-1">
{isExternalDropActive && canBrowseFiles && (
<div
className="pointer-events-none absolute inset-3 z-10 flex items-center justify-center rounded-lg border-2 border-dashed px-4 text-center text-xs font-medium"
style={{
borderColor: "var(--df-primary)",
backgroundColor: "rgba(59, 130, 246, 0.12)",
color: "var(--df-text)",
}}
>
{t("fileExplorer.externalDropOverlay")}
</div>
<ExternalFileDropOverlay
insetClassName="inset-3"
title={t("fileExplorer.externalDropOverlayTitle")}
hint={t("fileExplorer.externalDropOverlayHint")}
/>
)}
<div
ref={listContainerRef}
@@ -1,5 +1,13 @@
import { downloadDir } from "@tauri-apps/api/path";
import { type ElementType, useCallback, useEffect, useMemo, useState } from "react";
import {
type ElementType,
type KeyboardEvent as ReactKeyboardEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import {
MdBlock,
@@ -16,6 +24,7 @@ import {
MdUpload,
} from "react-icons/md";
import { toast } from "sonner";
import DeleteTransferDialog from "@/components/dialog/file-explorer/DeleteTransferDialog";
import PanelHeader from "@/components/layout/PanelHeader";
import { Button } from "@/components/ui/button";
import {
@@ -88,6 +97,8 @@ function HeaderActionButton({
function TransferRow({
item,
selected,
onSelect,
onPause,
onResume,
onRetry,
@@ -96,11 +107,13 @@ function TransferRow({
onOpenTargetDirectory,
}: {
item: TransferItem;
selected: boolean;
onSelect: (id: string) => void;
onPause: (id: string) => void;
onResume: (id: string) => void;
onRetry: (item: TransferItem) => void;
onCancel: (id: string) => void;
onDelete: (id: string) => void;
onDelete: (item: TransferItem) => void;
onOpenTargetDirectory: (id: string) => void;
}) {
const { t } = useTranslation();
@@ -156,9 +169,21 @@ function TransferRow({
<ContextMenuTrigger asChild>
<div
className="rounded transition-colors px-2 py-1.5"
style={{ backgroundColor: "var(--df-bg-panel)" }}
onMouseEnter={(e) => (e.currentTarget.style.backgroundColor = "var(--df-bg-hover)")}
onMouseLeave={(e) => (e.currentTarget.style.backgroundColor = "var(--df-bg-panel)")}
style={{
backgroundColor: selected ? "var(--df-bg-hover)" : "var(--df-bg-panel)",
outline: selected ? "1px solid var(--df-primary)" : undefined,
}}
onMouseDown={() => onSelect(item.id)}
onMouseEnter={(e) => {
if (!selected) {
e.currentTarget.style.backgroundColor = "var(--df-bg-hover)";
}
}}
onMouseLeave={(e) => {
if (!selected) {
e.currentTarget.style.backgroundColor = "var(--df-bg-panel)";
}
}}
title={item.error || `${item.fileName} — ${statusText}`}
>
<div className="flex items-center gap-2">
@@ -276,11 +301,7 @@ function TransferRow({
</>
)}
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
onClick={() => onDelete(item.id)}
disabled={!canDelete}
>
<ContextMenuItem variant="destructive" onClick={() => onDelete(item)} disabled={!canDelete}>
<MdDelete className="mr-2 text-[0.875rem]" />
{t("fileTransfer.delete")}
</ContextMenuItem>
@@ -302,6 +323,9 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) {
} = useTransfer();
const { appSettings } = useApp();
const [resolvedDownloadDir, setResolvedDownloadDir] = useState("");
const [selectedTransferId, setSelectedTransferId] = useState<string | null>(null);
const [pendingDeleteTransfer, setPendingDeleteTransfer] = useState<TransferItem | null>(null);
const listContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
downloadDir()
@@ -329,6 +353,92 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) {
});
}, [activeSessionId, transfers]);
useEffect(() => {
setSelectedTransferId(null);
setPendingDeleteTransfer(null);
}, [activeSessionId]);
useEffect(() => {
if (
selectedTransferId &&
!visibleTransfers.some((transfer) => transfer.id === selectedTransferId)
) {
setSelectedTransferId(null);
}
}, [selectedTransferId, visibleTransfers]);
const selectedTransfer = useMemo(
() => visibleTransfers.find((transfer) => transfer.id === selectedTransferId) ?? null,
[selectedTransferId, visibleTransfers],
);
const canDeleteTransfer = useCallback((transfer: TransferItem) => {
const canCancel =
transfer.status === "queued" ||
transfer.status === "transferring" ||
transfer.status === "paused";
return !canCancel || transfer.status === "queued" || transfer.queueState === "pending";
}, []);
const requestDeleteTransfer = useCallback(
(transfer: TransferItem) => {
if (!canDeleteTransfer(transfer)) return;
setSelectedTransferId(transfer.id);
setPendingDeleteTransfer(transfer);
},
[canDeleteTransfer],
);
const handleConfirmDeleteTransfer = useCallback(() => {
if (!pendingDeleteTransfer) return;
removeTransfer(pendingDeleteTransfer.id);
setPendingDeleteTransfer(null);
setSelectedTransferId(null);
}, [pendingDeleteTransfer, removeTransfer]);
const handleListKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>) => {
if (pendingDeleteTransfer) return;
if (
event.key !== "Delete" ||
event.altKey ||
event.ctrlKey ||
event.metaKey ||
event.shiftKey
) {
return;
}
const target = event.target;
if (
target instanceof HTMLElement &&
(target.isContentEditable ||
target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.tagName === "SELECT")
) {
return;
}
const transfer = selectedTransfer ?? visibleTransfers[0];
if (!transfer || !canDeleteTransfer(transfer)) {
return;
}
event.preventDefault();
event.stopPropagation();
requestDeleteTransfer(transfer);
},
[
canDeleteTransfer,
pendingDeleteTransfer,
requestDeleteTransfer,
selectedTransfer,
visibleTransfers,
],
);
const hasRunning = visibleTransfers.some(
(transfer) => transfer.status === "transferring" || transfer.status === "queued",
);
@@ -449,7 +559,17 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) {
}
/>
<div className="flex-1 overflow-y-auto p-1 text-sm terminal-scroll">
<div
ref={listContainerRef}
className="flex-1 overflow-y-auto p-1 text-sm terminal-scroll outline-none"
tabIndex={activeSessionId ? 0 : -1}
onMouseDown={() => {
if (activeSessionId) {
listContainerRef.current?.focus();
}
}}
onKeyDown={handleListKeyDown}
>
{!activeSessionId ? (
<div className="text-center py-8 text-xs" style={{ color: "var(--df-text-dimmed)" }}>
<MdFolderOff className="text-xl block mx-auto mb-2" />
@@ -466,11 +586,13 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) {
<TransferRow
key={item.id}
item={item}
selected={selectedTransferId === item.id}
onSelect={setSelectedTransferId}
onPause={(id) => void pauseTransfer(id)}
onResume={(id) => void resumeTransfer(id)}
onRetry={(transfer) => void retryTransfer(transfer)}
onCancel={(id) => void cancelTransfer(id)}
onDelete={removeTransfer}
onDelete={requestDeleteTransfer}
onOpenTargetDirectory={(id) => void handleOpenTargetDirectory(id)}
/>
))}
@@ -478,6 +600,12 @@ export default function FileTransfer({ activeSessionId }: FileTransferProps) {
)}
</div>
<DeleteTransferDialog
transfer={pendingDeleteTransfer}
onCancel={() => setPendingDeleteTransfer(null)}
onConfirm={handleConfirmDeleteTransfer}
/>
{displayPath && (
<Tooltip>
<TooltipTrigger asChild>
@@ -3,130 +3,22 @@ import { getCurrentWebview } from "@tauri-apps/api/webview";
import { useEffect } from "react";
import { toast } from "sonner";
import { logger } from "@/lib/logger";
import {
collectExternalDropAdditionalObjects,
createExternalFileDropBridgeMessage,
DRAG_EVENT_CAPTURE_OPTIONS,
getDragEventPosition,
getExternalFileDropBridge,
isDropPositionInsideElement,
isExternalFileDragEvent,
type NativeFileDropEventPayload,
} from "@/lib/nativeFileDrop";
import { normalizeDirectoryPath } from "./model";
interface ExternalFileDropEventPayload {
kind: "enter" | "over" | "leave" | "drop";
paths: string[];
position: {
x: number;
y: number;
};
}
const EXTERNAL_FILE_DROP_MESSAGE_KIND = "external-file-drop";
const DRAG_EVENT_CAPTURE_OPTIONS = true;
type WebView2Bridge = {
postMessageWithAdditionalObjects: (
message: unknown,
additionalObjects: ArrayLike<unknown>,
) => void;
};
type DataTransferItemWithFileSystemHandle = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<unknown>;
};
type CurrentRef<T> = {
current: T;
};
declare global {
interface Window {
chrome?: {
webview?: WebView2Bridge;
};
}
}
function isDropPositionInsideElement(
position: { x: number; y: number },
element: HTMLElement | null,
) {
if (!element) {
return false;
}
const rect = element.getBoundingClientRect();
const scale = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
const candidates =
scale === 1 ? [position] : [position, { x: position.x / scale, y: position.y / scale }];
return candidates.some(
({ x, y }) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom,
);
}
function isExternalFileDragEvent(event: DragEvent) {
const dataTransfer = event.dataTransfer;
if (!dataTransfer) {
return false;
}
if (Array.from(dataTransfer.types ?? []).includes("Files")) {
return true;
}
if (dataTransfer.files.length > 0) {
return true;
}
return Array.from(dataTransfer.items ?? []).some((item) => item.kind === "file");
}
function getDragEventPosition(event: DragEvent) {
return {
x: event.clientX,
y: event.clientY,
};
}
function getExternalFileDropBridge() {
return window.chrome?.webview;
}
function createExternalFileDropBridgeMessage(position: { x: number; y: number }) {
return JSON.stringify({
kind: EXTERNAL_FILE_DROP_MESSAGE_KIND,
position,
});
}
async function collectExternalDropAdditionalObjects(dataTransfer: DataTransfer | null) {
if (!dataTransfer) {
return [];
}
const fileItems = Array.from(dataTransfer.items ?? []).filter((item) => item.kind === "file");
if (fileItems.length === 0 && dataTransfer.files.length > 0) {
return Array.from(dataTransfer.files);
}
const additionalObjects: unknown[] = [];
for (const item of fileItems) {
const file = item.getAsFile();
if (file) {
additionalObjects.push(file);
continue;
}
const itemWithHandle = item as DataTransferItemWithFileSystemHandle;
if (typeof itemWithHandle.getAsFileSystemHandle === "function") {
try {
const handle = await itemWithHandle.getAsFileSystemHandle();
if (handle) {
additionalObjects.push(handle);
}
} catch {
// Fall back to File objects if the runtime cannot expose FileSystemHandle.
}
}
}
return additionalObjects;
}
interface UseExternalFileDropParams {
activeSessionIdRef: CurrentRef<string | null>;
canBrowseFilesRef: CurrentRef<boolean>;
@@ -164,17 +56,23 @@ export function useExternalFileDrop({
return;
}
event.preventDefault();
const isOverDropTarget = isDropPositionInsideElement(
getDragEventPosition(event),
listContainerRef.current,
);
if (!isOverDropTarget) {
resetExternalDropHover();
return;
}
event.preventDefault();
const isActive =
canBrowseFilesRef.current && !!activeSessionIdRef.current && isOverDropTarget;
setIsExternalDropActive(isActive);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = event.type === "dragenter" || isActive ? "copy" : "none";
event.dataTransfer.dropEffect = "copy";
}
};
@@ -204,7 +102,6 @@ export function useExternalFileDrop({
return;
}
event.preventDefault();
const dropPosition = getDragEventPosition(event);
const isOverDropTarget = isDropPositionInsideElement(dropPosition, listContainerRef.current);
resetExternalDropHover();
@@ -214,6 +111,8 @@ export function useExternalFileDrop({
return;
}
event.preventDefault();
const dataTransfer = event.dataTransfer;
if (dataTransfer?.files && dataTransfer.files.length > 0) {
try {
@@ -318,7 +217,7 @@ export function useExternalFileDrop({
let cancelled = false;
const unlistenPromise = listen<ExternalFileDropEventPayload>("external-file-drop", (event) => {
const unlistenPromise = listen<NativeFileDropEventPayload>("external-file-drop", (event) => {
if (cancelled) {
return;
}
+283 -267
View File
@@ -6,19 +6,23 @@ import {
useMemo,
useRef,
useState,
type WheelEvent,
} from "react";
import { useTranslation } from "react-i18next";
import {
MdAdd,
MdCellTower,
MdChevronLeft,
MdChevronRight,
MdClose,
MdContentCopy,
MdDns,
MdErrorOutline,
MdFolder,
MdHistory,
MdTerminal,
} from "react-icons/md";
import { RiExpandDiagonalLine } from "react-icons/ri";
import { toast } from "sonner";
import { getActiveGroupForSession, isSessionPausedInGroup } from "@/lib/syncInputGroups";
import { getActivePane, getTabDisplayName } from "@/lib/workspaceTabs";
import type { Group, PaneSplitDirection, SavedConnection, Tab } from "@/types/global";
@@ -70,64 +74,49 @@ interface ConnectionGroupNode {
totalCount: number;
}
const TAB_WIDTH_FALLBACK = 220;
const TAB_OVERFLOW_TRIGGER_WIDTH = 36;
const TAB_STRIP_SCROLL_DURATION_MS = 180;
const TAB_STRIP_SCROLL_PADDING = 12;
function areStringArraysEqual(left: string[], right: string[]) {
return left.length === right.length && left.every((value, index) => value === right[index]);
function easeOutCubic(progress: number): number {
return 1 - (1 - progress) ** 3;
}
function computeVisibleTabIds(
tabs: Tab[],
tabWidths: Map<string, number>,
availableWidth: number,
activeTabId: string | null,
) {
if (tabs.length === 0) return [];
if (availableWidth <= 0) {
return [tabs.find((tab) => tab.id === activeTabId)?.id ?? tabs[0].id];
function clampTabStripScrollLeft(strip: HTMLElement, value: number): number {
const maxScrollLeft = Math.max(0, strip.scrollWidth - strip.clientWidth);
return Math.max(0, Math.min(maxScrollLeft, value));
}
function animateTabStripScroll(
strip: HTMLElement,
targetScrollLeft: number,
onComplete?: () => void,
): () => void {
const startScrollLeft = strip.scrollLeft;
const clampedTarget = clampTabStripScrollLeft(strip, targetScrollLeft);
const distance = clampedTarget - startScrollLeft;
if (Math.abs(distance) < 0.5) {
strip.scrollLeft = clampedTarget;
onComplete?.();
return () => {};
}
const widths = tabs.map((tab) => Math.ceil(tabWidths.get(tab.id) ?? TAB_WIDTH_FALLBACK));
const totalWidth = widths.reduce((sum, width) => sum + width, 0);
if (totalWidth <= availableWidth) return tabs.map((tab) => tab.id);
const startTime = performance.now();
let rafId = 0;
const selectedIndexes: number[] = [];
let usedWidth = 0;
for (let index = tabs.length - 1; index >= 0; index -= 1) {
const width = widths[index];
if (usedWidth + width > availableWidth) break;
selectedIndexes.push(index);
usedWidth += width;
}
const activeIndex = activeTabId ? tabs.findIndex((tab) => tab.id === activeTabId) : -1;
const activeWidth = activeIndex >= 0 ? widths[activeIndex] : 0;
if (activeIndex >= 0 && !selectedIndexes.includes(activeIndex)) {
while (selectedIndexes.length > 0 && usedWidth + activeWidth > availableWidth) {
const leftmostSelectedIndex = Math.min(...selectedIndexes);
selectedIndexes.splice(selectedIndexes.indexOf(leftmostSelectedIndex), 1);
usedWidth -= widths[leftmostSelectedIndex];
const step = (now: number) => {
const progress = Math.min(1, (now - startTime) / TAB_STRIP_SCROLL_DURATION_MS);
strip.scrollLeft = startScrollLeft + distance * easeOutCubic(progress);
if (progress < 1) {
rafId = requestAnimationFrame(step);
return;
}
strip.scrollLeft = clampedTarget;
onComplete?.();
};
if (activeWidth <= availableWidth || selectedIndexes.length === 0) {
selectedIndexes.push(activeIndex);
usedWidth += activeWidth;
}
}
for (let index = tabs.length - 1; index >= 0; index -= 1) {
if (selectedIndexes.includes(index)) continue;
const width = widths[index];
if (usedWidth + width <= availableWidth) {
selectedIndexes.push(index);
usedWidth += width;
}
}
const selectedSet = new Set(selectedIndexes);
return tabs.filter((_, index) => selectedSet.has(index)).map((tab) => tab.id);
rafId = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafId);
}
function compareSortOrder(left: { sort_order?: number }, right: { sort_order?: number }) {
@@ -197,14 +186,16 @@ function TabBar({
const { appSettings, savedConnections, savedGroups, syncGroups, broadcastToAll } = useApp();
const [draggedTabId, setDraggedTabId] = useState<string | null>(null);
const [dropIndex, setDropIndex] = useState<number | null>(null);
const [overflowMenuOpen, setOverflowMenuOpen] = useState(false);
const [overflowTooltipOpen, setOverflowTooltipOpen] = useState(false);
const [suppressOverflowTooltip, setSuppressOverflowTooltip] = useState(false);
const tabStripRef = useRef<HTMLDivElement | null>(null);
const tabMeasureRefs = useRef(new Map<string, HTMLDivElement>());
const tabButtonRefs = useRef(new Map<string, HTMLDivElement>());
const draggedTabIdRef = useRef<string | null>(null);
const [visibleTabIds, setVisibleTabIds] = useState<string[]>(() => tabs.map((tab) => tab.id));
const tabStripAnimatingRef = useRef(false);
const tabStripScrollAnimationRef = useRef<(() => void) | null>(null);
const [tabStripScroll, setTabStripScroll] = useState({
canScrollLeft: false,
canScrollRight: false,
hasOverflow: false,
});
const groupsById = useMemo(
() => new Map(savedGroups.map((group) => [group.id, group])),
@@ -283,55 +274,162 @@ function TabBar({
.slice(0, 10);
}, [appSettings.ui.recent_connection_ids, savedConnections]);
const updateVisibleTabs = useCallback(() => {
const updateTabStripScrollState = useCallback(() => {
const strip = tabStripRef.current;
if (!strip) return;
const tabWidths = new Map<string, number>();
for (const tab of tabs) {
const measuredWidth = tabMeasureRefs.current.get(tab.id)?.offsetWidth;
if (measuredWidth) tabWidths.set(tab.id, measuredWidth);
const maxScrollLeft = Math.max(0, strip.scrollWidth - strip.clientWidth);
const nextState = {
canScrollLeft: strip.scrollLeft > 1,
canScrollRight: strip.scrollLeft < maxScrollLeft - 1,
hasOverflow: maxScrollLeft > 1,
};
setTabStripScroll((current) =>
current.canScrollLeft === nextState.canScrollLeft &&
current.canScrollRight === nextState.canScrollRight &&
current.hasOverflow === nextState.hasOverflow
? current
: nextState,
);
}, []);
const runTabStripScrollAnimation = useCallback(
(targetScrollLeft: number) => {
const strip = tabStripRef.current;
if (!strip) return;
tabStripScrollAnimationRef.current?.();
tabStripAnimatingRef.current = true;
tabStripScrollAnimationRef.current = animateTabStripScroll(strip, targetScrollLeft, () => {
tabStripScrollAnimationRef.current = null;
tabStripAnimatingRef.current = false;
updateTabStripScrollState();
});
},
[updateTabStripScrollState],
);
const getTabStripStepTarget = useCallback((direction: -1 | 1): number | null => {
const strip = tabStripRef.current;
if (!strip) return null;
const pageWidth = strip.clientWidth;
const targetScrollLeft =
direction === 1 ? strip.scrollLeft + pageWidth : strip.scrollLeft - pageWidth;
const clampedTarget = clampTabStripScrollLeft(strip, targetScrollLeft);
if (Math.abs(clampedTarget - strip.scrollLeft) < 1) return null;
return clampedTarget;
}, []);
const scrollTabStripPage = useCallback(
(direction: -1 | 1) => {
if (tabStripAnimatingRef.current) return;
const targetScrollLeft = getTabStripStepTarget(direction);
if (targetScrollLeft === null) return;
runTabStripScrollAnimation(targetScrollLeft);
},
[getTabStripStepTarget, runTabStripScrollAnimation],
);
const getActiveTabScrollTarget = useCallback((): number | null => {
if (!activeTabId) return null;
const strip = tabStripRef.current;
const tabElement = tabButtonRefs.current.get(activeTabId);
if (!strip || !tabElement) return null;
const scrollLeft = strip.scrollLeft;
const viewportRight = scrollLeft + strip.clientWidth;
const padding = TAB_STRIP_SCROLL_PADDING;
const tabLeft = tabElement.offsetLeft;
const tabRight = tabLeft + tabElement.offsetWidth;
if (tabLeft >= scrollLeft + padding && tabRight <= viewportRight - padding) {
return null;
}
const totalTabWidth = tabs.reduce(
(sum, tab) => sum + Math.ceil(tabWidths.get(tab.id) ?? TAB_WIDTH_FALLBACK),
0,
);
const hasOverflowTrigger = visibleTabIds.length < tabs.length;
const availableWithoutOverflowTrigger =
strip.clientWidth + (hasOverflowTrigger ? TAB_OVERFLOW_TRIGGER_WIDTH : 0);
const availableWidth =
totalTabWidth <= availableWithoutOverflowTrigger
? availableWithoutOverflowTrigger
: Math.max(0, availableWithoutOverflowTrigger - TAB_OVERFLOW_TRIGGER_WIDTH);
if (tabLeft < scrollLeft + padding) {
return clampTabStripScrollLeft(strip, tabLeft - padding);
}
const nextVisibleTabIds = computeVisibleTabIds(tabs, tabWidths, availableWidth, activeTabId);
return clampTabStripScrollLeft(strip, tabRight - strip.clientWidth + padding);
}, [activeTabId]);
setVisibleTabIds((current) =>
areStringArraysEqual(current, nextVisibleTabIds) ? current : nextVisibleTabIds,
);
}, [activeTabId, tabs, visibleTabIds.length]);
const scrollActiveTabIntoView = useCallback(
(smooth: boolean) => {
const targetScrollLeft = getActiveTabScrollTarget();
if (targetScrollLeft === null) return;
if (smooth) {
runTabStripScrollAnimation(targetScrollLeft);
return;
}
const strip = tabStripRef.current;
if (!strip) return;
strip.scrollLeft = targetScrollLeft;
updateTabStripScrollState();
},
[getActiveTabScrollTarget, runTabStripScrollAnimation, updateTabStripScrollState],
);
const handleTabStripScroll = useCallback(() => {
if (tabStripAnimatingRef.current) return;
updateTabStripScrollState();
}, [updateTabStripScrollState]);
useLayoutEffect(() => {
updateVisibleTabs();
updateTabStripScrollState();
}, [tabs, updateTabStripScrollState]);
useLayoutEffect(() => {
const strip = tabStripRef.current;
if (!strip) return;
const observer = new ResizeObserver(() => updateVisibleTabs());
const observer = new ResizeObserver(() => {
updateTabStripScrollState();
scrollActiveTabIntoView(false);
});
observer.observe(strip);
return () => observer.disconnect();
}, [updateVisibleTabs]);
}, [scrollActiveTabIntoView, tabs, updateTabStripScrollState]);
const visibleTabIdSet = useMemo(() => new Set(visibleTabIds), [visibleTabIds]);
const visibleTabs = useMemo(
() => tabs.filter((tab) => visibleTabIdSet.has(tab.id)),
[tabs, visibleTabIdSet],
useLayoutEffect(() => {
scrollActiveTabIntoView(true);
}, [activeTabId, scrollActiveTabIntoView]);
useLayoutEffect(
() => () => {
tabStripScrollAnimationRef.current?.();
tabStripScrollAnimationRef.current = null;
tabStripAnimatingRef.current = false;
},
[],
);
const overflowTabs = useMemo(
() => tabs.filter((tab) => !visibleTabIdSet.has(tab.id)),
[tabs, visibleTabIdSet],
const handleTabStripWheel = useCallback(
(event: WheelEvent<HTMLDivElement>) => {
const strip = tabStripRef.current;
if (!strip || strip.scrollWidth <= strip.clientWidth + 1) return;
event.preventDefault();
event.stopPropagation();
// Wheel: continuous proportional scroll (natural for trackpad / mouse wheel).
tabStripScrollAnimationRef.current?.();
tabStripScrollAnimationRef.current = null;
tabStripAnimatingRef.current = false;
const delta = Math.abs(event.deltaX) > Math.abs(event.deltaY) ? event.deltaX : event.deltaY;
if (Math.abs(delta) < 0.5) return;
strip.scrollLeft = clampTabStripScrollLeft(strip, strip.scrollLeft + delta);
updateTabStripScrollState();
},
[updateTabStripScrollState],
);
const getInsertionIndex = useCallback((event: DragEvent<HTMLDivElement>, index: number) => {
@@ -345,14 +443,6 @@ function TabBar({
setDropIndex(null);
}, []);
const setMeasureTabRef = useCallback((tabId: string, element: HTMLDivElement | null) => {
if (element) {
tabMeasureRefs.current.set(tabId, element);
} else {
tabMeasureRefs.current.delete(tabId);
}
}, []);
const setVisibleTabRef = useCallback((tabId: string, element: HTMLDivElement | null) => {
if (element) {
tabButtonRefs.current.set(tabId, element);
@@ -363,7 +453,7 @@ function TabBar({
const getInsertionIndexFromClientX = useCallback(
(clientX: number) => {
for (const tab of visibleTabs) {
for (const tab of tabs) {
const element = tabButtonRefs.current.get(tab.id);
if (!element) continue;
const rect = element.getBoundingClientRect();
@@ -374,7 +464,7 @@ function TabBar({
}
return tabs.length;
},
[tabs, visibleTabs],
[tabs],
);
const handleDropAtIndex = useCallback(
@@ -535,7 +625,7 @@ function TabBar({
return <MdDns className="text-sm shrink-0 text-emerald-500/70" />;
};
const renderTabItem = (tab: Tab, index: number, measureOnly = false) => {
const renderTabItem = (tab: Tab, index: number) => {
const isActive = activeTabId === tab.id;
const isFocused = focusedTabId === tab.id;
const showUnreadIndicator = !isFocused && unreadTabIds?.has(tab.id);
@@ -544,12 +634,7 @@ function TabBar({
const tabButton = (
<div
ref={
measureOnly
? (element) => setMeasureTabRef(tab.id, element)
: (element) => setVisibleTabRef(tab.id, element)
}
draggable={!measureOnly}
draggable
className={`group relative flex items-center gap-2 border-r pl-3 pr-2 text-xs transition-[color,background-color,opacity] duration-200 ${
isActive ? "font-semibold" : "font-medium df-hover"
} ${draggedTabId === tab.id ? "opacity-60" : ""}`}
@@ -564,34 +649,22 @@ function TabBar({
: "transparent",
color: isActive ? "var(--df-text)" : "var(--df-text-muted)",
}}
onClick={measureOnly ? undefined : () => onTabChange(tab.id)}
onContextMenu={measureOnly ? undefined : () => onTabChange(tab.id)}
onDragStart={measureOnly ? undefined : (event) => handleDragStart(event, tab.id)}
onDragEnd={
measureOnly
? undefined
: (event) => {
handleDragEnd(event);
}
}
onDragOver={
measureOnly
? undefined
: (event) => {
if (!draggedTabId && !event.dataTransfer.types.includes("application/nyaterm-tab"))
return;
event.preventDefault();
setDropIndex(getInsertionIndex(event, index));
}
}
onDrop={
measureOnly
? undefined
: (event) => {
event.preventDefault();
handleDropAtIndex(getInsertionIndex(event, index), event);
}
}
onClick={() => onTabChange(tab.id)}
onContextMenu={() => onTabChange(tab.id)}
onDragStart={(event) => handleDragStart(event, tab.id)}
onDragEnd={(event) => {
handleDragEnd(event);
}}
onDragOver={(event) => {
if (!draggedTabId && !event.dataTransfer.types.includes("application/nyaterm-tab"))
return;
event.preventDefault();
setDropIndex(getInsertionIndex(event, index));
}}
onDrop={(event) => {
event.preventDefault();
handleDropAtIndex(getInsertionIndex(event, index), event);
}}
>
{isActive && (
<div
@@ -612,21 +685,49 @@ function TabBar({
{renderTabIcon(tab)}
{measureOnly ? (
<span className="max-w-[160px] truncate whitespace-nowrap">{displayName}</span>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-[160px] truncate whitespace-nowrap">{displayName}</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} showArrow className="max-w-xs truncate">
{displayName}
</TooltipContent>
</Tooltip>
)}
<span
className="shrink-0 min-w-[1.25em] text-xs font-semibold tabular-nums leading-none"
style={{ color: isActive ? "var(--df-text-muted)" : "var(--df-text-dimmed)" }}
aria-hidden="true"
>
{index + 1}
</span>
<Tooltip>
<TooltipTrigger asChild>
<span className="max-w-[160px] truncate whitespace-nowrap">{displayName}</span>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} showArrow className="max-w-xs truncate">
{index + 1}. {displayName}
</TooltipContent>
</Tooltip>
<SyncIndicator tab={tab} syncGroups={syncGroups} broadcastToAll={broadcastToAll} />
{(() => {
const pane = getActivePane(tab);
const conn = pane?.connectionId
? savedConnections.find((c) => c.id === pane.connectionId)
: undefined;
const host = conn?.host;
if (host) {
return (
<div
className="ml-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center rounded text-[var(--df-text-dimmed)] opacity-0 transition-all duration-200 hover:text-[var(--df-primary)] active:scale-90 group-hover:opacity-100"
onClick={(event) => {
event.stopPropagation();
navigator.clipboard.writeText(host).catch(() => {});
toast.success(t("tabCtx.ipCopied"));
}}
title={host}
>
<MdContentCopy className="text-[10px]" />
</div>
);
}
return null;
})()}
<div className="relative ml-0.5 flex h-[18px] w-[18px] shrink-0 items-center justify-center">
{showUnreadIndicator ? (
<span className="h-2 w-2 rounded-full bg-green-500 animate-breathing" />
@@ -637,14 +738,10 @@ function TabBar({
? "text-[var(--df-text-muted)]"
: "text-[var(--df-text-dimmed)] opacity-0 group-hover:opacity-100"
} hover:!bg-red-500/10 hover:!text-red-500 active:scale-90 active:!bg-red-500/20`}
onClick={
measureOnly
? undefined
: (event) => {
event.stopPropagation();
void onTabClose(tab);
}
}
onClick={(event) => {
event.stopPropagation();
void onTabClose(tab);
}}
>
<MdClose className="text-[12px]" />
</div>
@@ -653,16 +750,12 @@ function TabBar({
</div>
);
if (measureOnly) {
return (
<div key={tab.id} className="relative flex shrink-0">
{tabButton}
</div>
);
}
return (
<div key={tab.id} className="relative flex shrink-0">
<div
key={tab.id}
ref={(element) => setVisibleTabRef(tab.id, element)}
className="relative flex shrink-0"
>
{dropIndex === index && (draggedTabId || dropIndex !== null) && (
<div
className="pointer-events-none absolute inset-y-1 left-0 z-20 w-0.5 rounded-full"
@@ -692,51 +785,6 @@ function TabBar({
);
};
const renderOverflowTabItem = (tab: Tab) => {
const isActive = activeTabId === tab.id;
const displayName = getTabDisplayName(tab);
return (
<DropdownMenuItem
key={tab.id}
className="max-w-[320px] pr-1"
onSelect={(event) => {
event.preventDefault();
onTabChange(tab.id);
setOverflowMenuOpen(false);
}}
>
{renderTabIcon(tab)}
<span
className={`min-w-0 flex-1 truncate ${isActive ? "font-semibold" : ""}`}
style={{ color: isActive ? "var(--df-text)" : undefined }}
>
{displayName}
</span>
<button
type="button"
tabIndex={-1}
aria-label={t("tabCtx.close")}
className="ml-2 flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground hover:bg-red-500/10 hover:text-red-500"
onPointerDown={(event) => {
event.stopPropagation();
}}
onPointerUp={(event) => {
event.stopPropagation();
}}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
void onTabClose(tab);
setOverflowMenuOpen(false);
}}
>
<MdClose className="text-[12px]" />
</button>
</DropdownMenuItem>
);
};
const renderConnectionMenuItem = (connection: SavedConnection, label = connection.name) => (
<DropdownMenuItem
key={connection.id}
@@ -771,21 +819,32 @@ function TabBar({
return (
<div
className="flex h-9 shrink-0 overflow-hidden"
className="flex h-9 shrink-0"
style={{
backgroundColor: "var(--df-bg-panel)",
boxShadow: "inset 0 -1px 0 var(--df-border)",
}}
>
<div ref={tabStripRef} className="relative flex min-w-0 flex-1 overflow-hidden">
<div
className="pointer-events-none absolute -left-[10000px] top-0 flex h-9 opacity-0"
aria-hidden="true"
{tabStripScroll.hasOverflow && (
<button
type="button"
className="flex h-full w-7 shrink-0 items-center justify-center border-r transition-colors df-hover disabled:opacity-30"
style={{ color: "var(--df-text-muted)", borderColor: "var(--df-border)" }}
aria-label={t("terminal.scrollTabsLeft")}
disabled={!tabStripScroll.canScrollLeft}
onClick={() => scrollTabStripPage(-1)}
>
{tabs.map((tab, index) => renderTabItem(tab, index, true))}
</div>
<MdChevronLeft className="text-base" />
</button>
)}
{visibleTabs.map((tab) =>
<div
ref={tabStripRef}
className="tab-strip-scroll relative flex min-w-0 flex-1 overflow-x-auto overflow-y-hidden"
onScroll={handleTabStripScroll}
onWheel={handleTabStripWheel}
>
{tabs.map((tab) =>
renderTabItem(
tab,
tabs.findIndex((item) => item.id === tab.id),
@@ -814,60 +873,17 @@ function TabBar({
</div>
</div>
{overflowTabs.length > 0 && (
<DropdownMenu
open={overflowMenuOpen}
onOpenChange={(open) => {
setOverflowMenuOpen(open);
if (open) {
setOverflowTooltipOpen(false);
setSuppressOverflowTooltip(true);
} else {
setSuppressOverflowTooltip(true);
}
}}
{tabStripScroll.hasOverflow && (
<button
type="button"
className="flex h-full w-7 shrink-0 items-center justify-center border-l transition-colors df-hover disabled:opacity-30"
style={{ color: "var(--df-text-muted)", borderColor: "var(--df-border)" }}
aria-label={t("terminal.scrollTabsRight")}
disabled={!tabStripScroll.canScrollRight}
onClick={() => scrollTabStripPage(1)}
>
<Tooltip
open={!overflowMenuOpen && !suppressOverflowTooltip && overflowTooltipOpen}
onOpenChange={(open) =>
setOverflowTooltipOpen(open && !overflowMenuOpen && !suppressOverflowTooltip)
}
>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
className="flex h-full w-9 shrink-0 items-center justify-center border-l transition-colors df-hover"
style={{ color: "var(--df-text-muted)", borderColor: "var(--df-border)" }}
aria-label={t("terminal.showHiddenSessions")}
onPointerEnter={() => {
if (!overflowMenuOpen && !suppressOverflowTooltip) {
setOverflowTooltipOpen(true);
}
}}
onPointerLeave={() => {
setOverflowTooltipOpen(false);
setSuppressOverflowTooltip(false);
}}
onClick={() => {
setOverflowTooltipOpen(false);
setSuppressOverflowTooltip(true);
}}
>
<RiExpandDiagonalLine className="text-base" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6} showArrow>
{t("terminal.showHiddenSessions")}
</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end" className="min-w-[240px] max-w-[360px]">
<DropdownMenuLabel className="text-muted-foreground">
{t("terminal.hiddenSessions")}
</DropdownMenuLabel>
{overflowTabs.map(renderOverflowTabItem)}
</DropdownMenuContent>
</DropdownMenu>
<MdChevronRight className="text-base" />
</button>
)}
<DropdownMenu>
+23 -5
View File
@@ -96,7 +96,7 @@ export default function TabContextMenu({
onActivateTab,
}: TabContextMenuProps) {
const { t } = useTranslation();
const { updateTab } = useApp();
const { updateTab, savedConnections } = useApp();
const [renameOpen, setRenameOpen] = useState(false);
const [renameValue, setRenameValue] = useState("");
@@ -163,6 +163,22 @@ export default function TabContextMenu({
}
}, [displayName, t]);
const conn = activePane?.connectionId
? savedConnections.find((c) => c.id === activePane.connectionId)
: undefined;
const host = conn?.host;
const canCopyIp = !!host;
const handleCopyIp = useCallback(async () => {
if (!host) return;
try {
await navigator.clipboard.writeText(host);
toast.success(t("tabCtx.ipCopied"));
} catch {
toast.error(t("tabCtx.copyFailed"));
}
}, [host, t]);
const handleOpenAI = useCallback(
(action: "explain_output" | "analyze_error") => {
onActivateTab(tab.id);
@@ -232,6 +248,11 @@ export default function TabContextMenu({
{t("tabCtx.copyName")}
</ContextMenuItem>
<ContextMenuItem disabled={!canCopyIp} onClick={() => void handleCopyIp()}>
<MdContentCopy className={iconClass} />
{t("tabCtx.copyIp")}
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuItem disabled={!canSpawnSession} onClick={() => void onDuplicateSession(tab)}>
@@ -252,10 +273,7 @@ export default function TabContextMenu({
{t("tabCtx.reconnect")}
</ContextMenuItem>
<ContextMenuItem
disabled={!canDisconnect}
onClick={() => void onDisconnectSession(tab)}
>
<ContextMenuItem disabled={!canDisconnect} onClick={() => void onDisconnectSession(tab)}>
<MdLinkOff className={iconClass} />
{t("tabCtx.disconnect")}
</ContextMenuItem>
+261 -15
View File
@@ -4,7 +4,10 @@ import { SearchAddon } from "@xterm/addon-search";
import { Terminal } from "@xterm/xterm";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import MultiLinePasteDialog from "@/components/dialog/terminal/MultiLinePasteDialog";
import ExternalFileDropOverlay from "@/components/ExternalFileDropOverlay";
import type { ResolvedLocalDropPathEntry } from "@/components/panel/file-explorer/model";
import { useTerminalAppSettings } from "@/context/AppContext";
import { useTheme } from "@/context/ThemeContext";
import { useActionLinks } from "@/hooks/useActionLinks";
@@ -13,6 +16,7 @@ import { useCredentialAutofill } from "@/hooks/useCredentialAutofill";
import { useKeywordHighlighter } from "@/hooks/useKeywordHighlighter";
import { useShellIntegration } from "@/hooks/useShellIntegration";
import { resolveShortcutKeys } from "@/hooks/useShortcutMap";
import { useTerminalFileDrop } from "@/hooks/useTerminalFileDrop";
import { useTerminalSearch } from "@/hooks/useTerminalSearch";
import { useTerminalSettings } from "@/hooks/useTerminalSettings";
import { emitAIErrorDetected } from "@/lib/aiEvents";
@@ -22,6 +26,7 @@ import { readClipboardText } from "@/lib/clipboard";
import { detectCredentialPromptKind } from "@/lib/credentialAutofill";
import { invoke } from "@/lib/invoke";
import { hexLuminance } from "@/lib/keywordHighlightPresets";
import { logger } from "@/lib/logger";
import { openSendCommandPanel } from "@/lib/sendCommandPanelEvents";
import {
buildTerminalCommandInput,
@@ -33,6 +38,7 @@ import {
} from "@/lib/sessionInput";
import { matchesKeyEvent, resolveIndexedKeys } from "@/lib/shortcutRegistry";
import { registerTerminalContextProvider } from "@/lib/terminalContext";
import { getTerminalDropOverlayCopy, handleTerminalFileDrop } from "@/lib/terminalFileDrop";
import {
applyTerminalInputData,
applyTerminalInputPreview,
@@ -115,6 +121,7 @@ export default function XTerminal({
const [performanceOverlay, setPerformanceOverlay] = useState<PerformanceOverlayState>(null);
const [skippedOutputChars, setSkippedOutputChars] = useState(0);
const [multiLinePasteText, setMultiLinePasteText] = useState<string | null>(null);
const [isExternalDropActive, setIsExternalDropActive] = useState(false);
const aiCapturingRef = useRef(false);
const { terminalTheme } = useTheme();
@@ -366,7 +373,12 @@ export default function XTerminal({
removePopup: removeLinkPopup,
} = createTerminalLinkHandlers(terminal, tRef);
const searchAddon = new SearchAddon();
const zmodemHandler = createZmodemEventHandler(terminal, sessionId, () => tRef.current);
const zmodemHandler = createZmodemEventHandler(
terminal,
sessionId,
() => tRef.current,
() => terminalAppSettingsRef.current?.transfer.duplicate_strategy ?? "ask",
);
terminal.options.linkHandler = oscLinkHandler;
terminal.loadAddon(fitAddon);
@@ -711,6 +723,7 @@ export default function XTerminal({
return false;
}
// Smart cursor selection: preserve editing behavior for command-line input
if (
(e.key === "ArrowLeft" || e.key === "ArrowRight") &&
!e.ctrlKey &&
@@ -745,6 +758,155 @@ export default function XTerminal({
}
}
// Normal selection: preserve selection for ALL key presses - only clear on mouse click
// If there's a selection that's NOT a smart cursor selection,
// send the key input without triggering xterm's clearSelection
if (terminal.hasSelection() && !getSmartCursorSelectedInputRange()) {
if (directInputData) {
e.preventDefault();
terminal.input(directInputData, false);
return false;
}
// Handle Backspace/Delete - send control char without clearing selection
if (
(e.key === "Backspace" || e.key === "Delete") &&
!e.ctrlKey &&
!e.metaKey &&
!e.altKey
) {
e.preventDefault();
terminal.input("\x7f", false);
return false;
}
// Handle Enter - send newline without clearing selection
if (e.key === "Enter" && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault();
terminal.input("\r", false);
return false;
}
// Handle arrow keys - send ANSI escape sequences without clearing selection
if (e.key === "ArrowLeft" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
terminal.input("\x1b[D", false);
return false;
}
if (e.key === "ArrowRight" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
terminal.input("\x1b[C", false);
return false;
}
if (e.key === "ArrowUp" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
terminal.input("\x1b[A", false);
return false;
}
if (e.key === "ArrowDown" && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
e.preventDefault();
terminal.input("\x1b[B", false);
return false;
}
// Handle Ctrl key combinations - send control characters without clearing selection
if (e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) {
const ctrlCharMap: Record<string, string> = {
a: "\x01",
b: "\x02",
c: "\x03",
d: "\x04",
e: "\x05",
f: "\x06",
g: "\x07",
h: "\x08",
i: "\x09",
j: "\x0a",
k: "\x0b",
l: "\x0c",
m: "\x0d",
n: "\x0e",
o: "\x0f",
p: "\x10",
q: "\x11",
r: "\x12",
s: "\x13",
t: "\x14",
u: "\x15",
v: "\x16",
w: "\x17",
x: "\x18",
y: "\x19",
z: "\x1a",
};
const keyLower = e.key.toLowerCase();
if (ctrlCharMap[keyLower]) {
e.preventDefault();
terminal.input(ctrlCharMap[keyLower], false);
return false;
}
// Ctrl+Arrow keys (word navigation)
if (e.key === "ArrowLeft") {
e.preventDefault();
terminal.input("\x1b[1;5D", false);
return false;
}
if (e.key === "ArrowRight") {
e.preventDefault();
terminal.input("\x1b[1;5C", false);
return false;
}
if (e.key === "ArrowUp") {
e.preventDefault();
terminal.input("\x1b[1;5A", false);
return false;
}
if (e.key === "ArrowDown") {
e.preventDefault();
terminal.input("\x1b[1;5B", false);
return false;
}
}
// Handle Alt/Meta key combinations
if ((e.altKey || e.metaKey) && !e.ctrlKey && !e.shiftKey) {
// Alt+Arrow keys (word navigation on some shells)
if (e.key === "ArrowLeft") {
e.preventDefault();
terminal.input("\x1b[1;3D", false);
return false;
}
if (e.key === "ArrowRight") {
e.preventDefault();
terminal.input("\x1b[1;3C", false);
return false;
}
if (e.key === "ArrowUp") {
e.preventDefault();
terminal.input("\x1b[1;3A", false);
return false;
}
if (e.key === "ArrowDown") {
e.preventDefault();
terminal.input("\x1b[1;3B", false);
return false;
}
// Alt+b, Alt+f (word navigation shortcuts in bash)
const keyLower = e.key.toLowerCase();
if (keyLower === "b") {
e.preventDefault();
terminal.input("\x1bb", false);
return false;
}
if (keyLower === "f") {
e.preventDefault();
terminal.input("\x1bf", false);
return false;
}
// Alt+d (delete word forward)
if (keyLower === "d") {
e.preventDefault();
terminal.input("\x1bd", false);
return false;
}
}
}
if (matchesKeyEvent(resolveShortcutKeys("terminal.copy", kb), e)) {
e.preventDefault();
const sel = terminal.getSelection();
@@ -1618,25 +1780,105 @@ export default function XTerminal({
});
}, []);
const handleSendMultiLinePasteByLine = useCallback((text: string) => {
if (!text) return;
openSendCommandPanel({
text,
sourceSessionId: sessionId,
sourceSessionType: sessionType,
dataType: "text",
sendMode: "line",
count: 1,
intervalSeconds: 1,
target: "current",
});
setMultiLinePasteText(null);
}, [sessionId, sessionType]);
const handleSendMultiLinePasteByLine = useCallback(
(text: string) => {
if (!text) return;
openSendCommandPanel({
text,
sourceSessionId: sessionId,
sourceSessionType: sessionType,
dataType: "text",
sendMode: "line",
count: 1,
intervalSeconds: 1,
target: "current",
});
setMultiLinePasteText(null);
},
[sessionId, sessionType],
);
useEffect(() => {
doFindRef.current = doFind;
}, [doFind]);
const resetExternalDropHover = useCallback(() => {
setIsExternalDropActive(false);
}, []);
const resolveLocalDropPaths = useCallback(async (paths: string[]) => {
const uniquePaths = Array.from(
new Set(paths.map((path) => path.trim()).filter((path) => !!path)),
);
if (uniquePaths.length === 0) {
return [];
}
return invoke<ResolvedLocalDropPathEntry[]>("resolve_local_drop_paths", {
paths: uniquePaths,
});
}, []);
const processTerminalDropPaths = useCallback(
async (dropPaths: string[]) => {
try {
const resolvedLocalEntries = await resolveLocalDropPaths(dropPaths);
if (resolvedLocalEntries.length === 0) {
logger.warn({
domain: "ui.error",
event: "terminal.external_drop_paths_unresolved",
message: "Native terminal drop did not resolve to usable local paths",
ids: { session_id: sessionId },
data: { path_count: dropPaths.length },
});
toast.error(t("terminal.dropPathsRequired"));
return;
}
await handleTerminalFileDrop({
sessionId,
sessionType,
entries: resolvedLocalEntries,
t,
duplicateStrategy: terminalAppSettings.transfer.duplicate_strategy,
});
} catch (error) {
logger.error({
domain: "ui.error",
event: "terminal.external_drop_failed",
message: "Failed to process terminal file drop",
ids: { session_id: sessionId },
data: { path_count: dropPaths.length },
error,
});
toast.error(String(error));
}
},
[
resolveLocalDropPaths,
sessionId,
sessionType,
t,
terminalAppSettings.transfer.duplicate_strategy,
],
);
useTerminalFileDrop({
sessionId,
sessionType,
enabled: visible,
containerRef,
resetExternalDropHover,
setIsExternalDropActive,
processDropPaths: processTerminalDropPaths,
externalDropPathsRequiredMessage: t("terminal.dropPathsRequired"),
});
const dropOverlayCopy = useMemo(
() => getTerminalDropOverlayCopy(sessionType, t),
[sessionType, t],
);
return (
<div className="h-full w-full relative flex" style={{ display: visible ? "flex" : "none" }}>
{showGutter && terminalReady && (
@@ -1659,6 +1901,10 @@ export default function XTerminal({
<div ref={containerRef} className="h-full w-full" />
</TerminalContextMenu>
{isExternalDropActive && (
<ExternalFileDropOverlay title={dropOverlayCopy.title} hint={dropOverlayCopy.hint} />
)}
{performanceOverlay && (
<div className="pointer-events-none absolute inset-x-3 top-3 z-20 flex justify-end">
<div
+136 -17
View File
@@ -1,5 +1,15 @@
import type { Terminal } from "@xterm/xterm";
import { toast } from "sonner";
import { getLocalPathName } from "@/components/panel/file-explorer/model";
import { invoke } from "@/lib/invoke";
import {
completePendingZmodemUpload,
failPendingZmodemUpload,
getPendingZmodemUploadPaths,
hasPendingZmodemUpload,
markPendingZmodemUploadActive,
probeAndResolveRemoteConflicts,
} from "@/lib/terminalZmodemUpload";
const PROGRESS_RENDER_INTERVAL_MS = 100;
@@ -29,11 +39,15 @@ export function createZmodemEventHandler(
terminal: Terminal,
sessionId: string,
getT: () => Translate,
getDuplicateStrategy: () => string = () => "ask",
): ZmodemEventHandler {
let pendingProgress: Extract<ZmodemEventPayload, { type: "progress" }> | null = null;
let progressRaf: number | null = null;
let progressTimer: number | null = null;
let lastProgressWriteAt = 0;
let uploadStarted = false;
let uploadFileName = "";
let uploadToastId: string | number | null = null;
let disposed = false;
const clearProgressTimer = () => {
@@ -59,16 +73,26 @@ export function createZmodemEventHandler(
pendingProgress = null;
lastProgressWriteAt = Date.now();
if (payload.direction === "upload") {
const fileName = payload.fileName ?? payload.file_name ?? uploadFileName;
if (fileName) {
uploadFileName = fileName;
}
if (!uploadStarted) {
uploadStarted = true;
uploadToastId = toast.message(
getT()("fileTransfer.uploadStarted", { name: uploadFileName || fileName }),
);
}
return;
}
const fileName = payload.fileName ?? payload.file_name ?? "";
const bytesTransferred = payload.bytesTransferred ?? payload.bytes_transferred ?? 0;
const totalSize = payload.totalSize ?? payload.total_size ?? 0;
const percent = totalSize > 0 ? Math.round((bytesTransferred / totalSize) * 100) : 0;
const t = getT();
const msg =
payload.direction === "download"
? t("zmodem.downloading", { fileName, percent })
: t("zmodem.uploading", { fileName, percent });
terminal.write(`\r\x1b[36m[ZMODEM] ${msg}\x1b[K`);
terminal.write(`\r\x1b[36m[ZMODEM] ${t("zmodem.downloading", { fileName, percent })}\x1b[K`);
};
const scheduleProgressRender = () => {
@@ -93,6 +117,20 @@ export function createZmodemEventHandler(
renderProgress();
};
const dismissUploadToast = () => {
if (uploadToastId !== null) {
toast.dismiss(uploadToastId);
uploadToastId = null;
}
};
const showUploadCompletedToast = () => {
dismissUploadToast();
const t = getT();
const description = uploadFileName;
toast.success(t("fileTransfer.uploadCompleted"), { description });
};
const handleDetected = async (payload: Extract<ZmodemEventPayload, { type: "detected" }>) => {
const t = getT();
const { open: openDialog } = await import("@tauri-apps/plugin-dialog");
@@ -114,14 +152,58 @@ export function createZmodemEventHandler(
return;
}
terminal.write(`\r\n\x1b[36m[ZMODEM] ${t("zmodem.selectFiles")}\x1b[0m\r\n`);
const files = await openDialog({ directory: false, multiple: true });
if (disposed) return;
if (files && files.length > 0) {
const filePaths = Array.isArray(files) ? files.map(String) : [String(files)];
uploadStarted = false;
uploadFileName = "";
uploadToastId = null;
const pendingPaths = getPendingZmodemUploadPaths(sessionId);
if (pendingPaths) {
markPendingZmodemUploadActive(sessionId);
// Files were already resolved in uploadFilesViaZmodem before rz was sent —
// accept immediately so the remote rz won't time out.
if (pendingPaths.length === 1) {
uploadFileName = getLocalPathName(pendingPaths[0] ?? "", "uploaded_file");
uploadToastId = toast.message(
t("fileTransfer.uploadStarted", { name: uploadFileName }),
);
uploadStarted = true;
}
await invoke("zmodem_accept_upload", {
sessionId,
filePaths: pendingPaths,
});
return;
}
const selected = await openDialog({ multiple: true });
if (disposed) return;
if (selected && selected.length > 0) {
const filePaths = selected.map(String);
// Probe remote directory and delete conflicting files so plain rz
// never sees an existing file to prompt about.
const { paths: resolvedPaths } = await probeAndResolveRemoteConflicts(
sessionId,
filePaths,
getDuplicateStrategy(),
);
if (resolvedPaths.length === 0) {
await invoke("zmodem_cancel", { sessionId });
terminal.write(`\r\n\x1b[33m[ZMODEM] ${t("zmodem.cancelled")}\x1b[0m\r\n`);
return;
}
if (resolvedPaths.length === 1) {
uploadFileName = getLocalPathName(resolvedPaths[0] ?? "", "uploaded_file");
uploadToastId = toast.message(
t("fileTransfer.uploadStarted", { name: uploadFileName }),
);
uploadStarted = true;
}
await invoke("zmodem_accept_upload", {
sessionId,
filePaths: resolvedPaths,
});
} else {
await invoke("zmodem_cancel", { sessionId });
@@ -135,30 +217,67 @@ export function createZmodemEventHandler(
switch (payload.type) {
case "detected":
if (payload.direction === "upload" && hasPendingZmodemUpload(sessionId)) {
markPendingZmodemUploadActive(sessionId);
}
void handleDetected(payload);
break;
case "progress":
if (payload.direction === "upload") {
markPendingZmodemUploadActive(sessionId);
}
pendingProgress = payload;
scheduleProgressRender();
break;
case "complete": {
flushProgress();
terminal.write(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`);
if (payload.direction === "upload") {
showUploadCompletedToast();
completePendingZmodemUpload(sessionId);
} else {
terminal.write(`\r\n\x1b[32m[ZMODEM] ${getT()("zmodem.complete")}\x1b[0m\r\n`);
}
uploadStarted = false;
uploadFileName = "";
uploadToastId = null;
break;
}
case "failed":
case "failed": {
flushProgress();
terminal.write(
`\r\n\x1b[31m[ZMODEM] ${getT()("zmodem.failed", {
reason: payload.reason,
})}\x1b[0m\r\n`,
);
const isUpload = hasPendingZmodemUpload(sessionId) || uploadStarted;
if (isUpload) {
dismissUploadToast();
if (payload.reason !== "cancelled") {
toast.error(
getT()("fileTransfer.uploadFailed", {
name: uploadFileName || "file",
}),
{
description: payload.reason,
},
);
}
failPendingZmodemUpload(sessionId, payload.reason);
} else {
terminal.write(
`\r\n\x1b[31m[ZMODEM] ${getT()("zmodem.failed", {
reason: payload.reason,
})}\x1b[0m\r\n`,
);
}
uploadStarted = false;
uploadFileName = "";
uploadToastId = null;
break;
}
}
},
dispose() {
disposed = true;
pendingProgress = null;
uploadStarted = false;
uploadFileName = "";
uploadToastId = null;
clearProgressRaf();
clearProgressTimer();
},
+3 -1
View File
@@ -139,14 +139,16 @@ function AlertDialogAction({
className,
variant = "default",
size = "default",
autoFocus,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action> &
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
Pick<React.ComponentProps<typeof Button>, "variant" | "size"> & { autoFocus?: boolean }) {
return (
<Button variant={variant} size={size} asChild>
<AlertDialogPrimitive.Action
data-slot="alert-dialog-action"
className={cn(className)}
autoFocus={autoFocus}
{...props}
/>
</Button>
+11 -2
View File
@@ -160,7 +160,14 @@ function createSessionRequestId() {
export type TerminalAppSettings = Pick<
AppSettings,
"appearance" | "interaction" | "terminal" | "translation" | "search" | "ai" | "keybindings"
| "appearance"
| "interaction"
| "terminal"
| "translation"
| "search"
| "ai"
| "keybindings"
| "transfer"
>;
/**
@@ -253,7 +260,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
transfer: {
download_threads: 3,
upload_threads: 3,
duplicate_strategy: "overwrite",
duplicate_strategy: "ask",
preserve_timestamps: true,
resume_broken_transfer: true,
default_file_permissions: "644",
@@ -1255,6 +1262,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
search: appSettings.search,
ai: appSettings.ai,
keybindings: appSettings.keybindings,
transfer: appSettings.transfer,
}),
[
appSettings.appearance,
@@ -1264,6 +1272,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
appSettings.search,
appSettings.ai,
appSettings.keybindings,
appSettings.transfer,
],
);
+1 -1
View File
@@ -86,7 +86,7 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
transfer: {
download_threads: 3,
upload_threads: 3,
duplicate_strategy: "overwrite",
duplicate_strategy: "ask",
preserve_timestamps: true,
resume_broken_transfer: true,
default_file_permissions: "644",
+74 -8
View File
@@ -13,6 +13,7 @@ import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { useApp } from "@/context/AppContext";
import { invoke } from "@/lib/invoke";
import { filterEnqueueUploadRequests } from "@/lib/transferDuplicateResolution";
export type TransferDirection = "upload" | "download";
export type TransferKind = "file" | "directory";
@@ -30,6 +31,7 @@ export interface EnqueueUploadRequest {
localPath: string;
remotePath: string;
kind: TransferKind;
duplicateStrategyOverride?: string;
}
export interface EnqueueDownloadRequest {
@@ -47,6 +49,7 @@ interface QueuedTransferRequest {
remotePath: string;
kind: TransferKind;
direction: TransferDirection;
duplicateStrategyOverride?: string;
}
export interface TransferItem {
@@ -127,6 +130,7 @@ export function TransferProvider({ children }: { children: ReactNode }) {
const transferMapRef = useRef(transferMap);
const queuedTransfersRef = useRef<Map<string, QueuedTransferRequest>>(new Map());
const parkedTransferIdsRef = useRef<Set<string>>(new Set());
const uploadFolderToastIdsRef = useRef<Map<string, string | number>>(new Map());
const [queueRevision, setQueueRevision] = useState(0);
const transfers = useMemo(() => Array.from(transferMap.values()), [transferMap]);
@@ -249,13 +253,62 @@ export function TransferProvider({ children }: { children: ReactNode }) {
setQueueRevision((revision) => revision + 1);
}
if (p.status === "completed" && p.direction === "download" && !p.parent_id) {
toast.success(
if (p.status === "completed" && !p.parent_id) {
if (p.direction === "download") {
toast.success(
kind === "directory"
? t("fileTransfer.downloadFolderCompleted")
: t("fileTransfer.downloadCompleted"),
{
description: p.local_path,
},
);
return;
}
if (p.direction === "upload") {
const folderToastId = uploadFolderToastIdsRef.current.get(p.id);
if (folderToastId !== undefined) {
toast.dismiss(folderToastId);
uploadFolderToastIdsRef.current.delete(p.id);
}
toast.success(
kind === "directory"
? t("fileTransfer.uploadFolderCompleted")
: t("fileTransfer.uploadCompleted"),
{
description: p.remote_path,
},
);
}
return;
}
if (
p.status === "started" &&
p.direction === "upload" &&
!p.parent_id &&
kind === "directory"
) {
const toastId = toast.message(
t("fileTransfer.uploadFolderStarted", { name: p.file_name }),
);
uploadFolderToastIdsRef.current.set(p.id, toastId);
return;
}
if (p.status === "error" && p.direction === "upload" && !p.parent_id) {
const folderToastId = uploadFolderToastIdsRef.current.get(p.id);
if (folderToastId !== undefined) {
toast.dismiss(folderToastId);
uploadFolderToastIdsRef.current.delete(p.id);
}
toast.error(
kind === "directory"
? t("fileTransfer.downloadFolderCompleted")
: t("fileTransfer.downloadCompleted"),
? t("fileTransfer.uploadFolderFailed", { name: p.file_name })
: t("fileTransfer.uploadFailed", { name: p.file_name }),
{
description: p.local_path,
description: p.error_msg,
},
);
}
@@ -366,6 +419,7 @@ export function TransferProvider({ children }: { children: ReactNode }) {
localPath: request.localPath,
remotePath: request.remotePath,
transferId: nextQueued.id,
duplicateStrategyOverride: request.duplicateStrategyOverride,
});
} else if (request.direction === "upload") {
await invoke("upload_local_file", {
@@ -373,6 +427,7 @@ export function TransferProvider({ children }: { children: ReactNode }) {
localPath: request.localPath,
remotePath: request.remotePath,
transferId: nextQueued.id,
duplicateStrategyOverride: request.duplicateStrategyOverride,
});
} else if (request.kind === "directory") {
await invoke("download_remote_directory", {
@@ -652,9 +707,20 @@ export function TransferProvider({ children }: { children: ReactNode }) {
}, []);
const enqueueUploads = useCallback(
(uploads: EnqueueUploadRequest[]) =>
enqueueTransfers(uploads.map((upload) => ({ ...upload, direction: "upload" as const }))),
[enqueueTransfers],
(uploads: EnqueueUploadRequest[]) => {
void (async () => {
const filtered = await filterEnqueueUploadRequests(
uploads,
appSettings.transfer.duplicate_strategy,
);
if (filtered.length === 0) {
return;
}
enqueueTransfers(filtered.map((upload) => ({ ...upload, direction: "upload" as const })));
})();
return [];
},
[appSettings.transfer.duplicate_strategy, enqueueTransfers],
);
const enqueueDownloads = useCallback(
+289
View File
@@ -0,0 +1,289 @@
import { listen } from "@tauri-apps/api/event";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { useEffect, useRef } from "react";
import { toast } from "sonner";
import {
collectExternalDropAdditionalObjects,
createExternalFileDropBridgeMessage,
DRAG_EVENT_CAPTURE_OPTIONS,
getDragEventPosition,
getExternalFileDropBridge,
isDropPositionInsideElement,
isExternalFileDragEvent,
logExternalDropBridgeFailure,
type NativeFileDropEventPayload,
} from "@/lib/nativeFileDrop";
import type { SessionType } from "@/types/global";
interface UseTerminalFileDropParams {
sessionId: string;
sessionType: SessionType;
enabled: boolean;
containerRef: React.RefObject<HTMLDivElement | null>;
resetExternalDropHover: () => void;
setIsExternalDropActive: (active: boolean) => void;
processDropPaths: (dropPaths: string[]) => void | Promise<void>;
externalDropPathsRequiredMessage: string;
}
export function useTerminalFileDrop({
sessionId,
sessionType,
enabled,
containerRef,
resetExternalDropHover,
setIsExternalDropActive,
processDropPaths,
externalDropPathsRequiredMessage,
}: UseTerminalFileDropParams) {
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const sessionIdRef = useRef(sessionId);
sessionIdRef.current = sessionId;
const sessionTypeRef = useRef(sessionType);
sessionTypeRef.current = sessionType;
useEffect(() => {
if (!enabled) {
resetExternalDropHover();
}
}, [enabled, resetExternalDropHover]);
useEffect(() => {
const bridge = getExternalFileDropBridge();
if (!bridge?.postMessageWithAdditionalObjects) {
return;
}
const updateExternalDropState = (event: DragEvent) => {
if (!enabledRef.current || !isExternalFileDragEvent(event)) {
return;
}
const isOverDropTarget = isDropPositionInsideElement(
getDragEventPosition(event),
containerRef.current,
);
if (!isOverDropTarget) {
resetExternalDropHover();
return;
}
event.preventDefault();
setIsExternalDropActive(true);
if (event.dataTransfer) {
event.dataTransfer.dropEffect = "copy";
}
};
const handleWindowDragLeave = (event: DragEvent) => {
if (!enabledRef.current || !isExternalFileDragEvent(event)) {
return;
}
event.preventDefault();
const leftWindow =
event.clientX <= 0 ||
event.clientY <= 0 ||
event.clientX >= window.innerWidth ||
event.clientY >= window.innerHeight;
if (
leftWindow ||
!isDropPositionInsideElement(getDragEventPosition(event), containerRef.current)
) {
resetExternalDropHover();
}
};
const handleWindowDrop = (event: DragEvent) => {
if (!enabledRef.current || !isExternalFileDragEvent(event)) {
return;
}
const dropPosition = getDragEventPosition(event);
const isOverDropTarget = isDropPositionInsideElement(dropPosition, containerRef.current);
resetExternalDropHover();
if (!isOverDropTarget) {
return;
}
event.preventDefault();
const dataTransfer = event.dataTransfer;
if (dataTransfer?.files && dataTransfer.files.length > 0) {
try {
bridge.postMessageWithAdditionalObjects(
createExternalFileDropBridgeMessage(dropPosition),
dataTransfer.files,
);
} catch (error) {
logExternalDropBridgeFailure(
"terminal.external_drop_filelist_bridge_failed",
"Failed to bridge terminal file drop FileList through WebView2 additional objects",
error,
{
session_id: sessionIdRef.current,
session_type: sessionTypeRef.current,
file_count: dataTransfer.files.length,
},
);
toast.error(String(error));
}
return;
}
void (async () => {
try {
const additionalObjects = await collectExternalDropAdditionalObjects(dataTransfer);
if (additionalObjects.length === 0) {
toast.error(externalDropPathsRequiredMessage);
return;
}
bridge.postMessageWithAdditionalObjects(
createExternalFileDropBridgeMessage(dropPosition),
additionalObjects,
);
} catch (error) {
logExternalDropBridgeFailure(
"terminal.external_drop_bridge_failed",
"Failed to bridge terminal file drop through WebView2 additional objects",
error,
{
session_id: sessionIdRef.current,
session_type: sessionTypeRef.current,
},
);
toast.error(String(error));
}
})();
};
const handleWindowBlur = () => {
resetExternalDropHover();
};
window.addEventListener("dragenter", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS);
window.addEventListener("dragover", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS);
window.addEventListener("dragleave", handleWindowDragLeave, DRAG_EVENT_CAPTURE_OPTIONS);
window.addEventListener("drop", handleWindowDrop, DRAG_EVENT_CAPTURE_OPTIONS);
window.addEventListener("blur", handleWindowBlur);
return () => {
resetExternalDropHover();
window.removeEventListener("dragenter", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS);
window.removeEventListener("dragover", updateExternalDropState, DRAG_EVENT_CAPTURE_OPTIONS);
window.removeEventListener("dragleave", handleWindowDragLeave, DRAG_EVENT_CAPTURE_OPTIONS);
window.removeEventListener("drop", handleWindowDrop, DRAG_EVENT_CAPTURE_OPTIONS);
window.removeEventListener("blur", handleWindowBlur);
};
}, [
containerRef,
externalDropPathsRequiredMessage,
resetExternalDropHover,
setIsExternalDropActive,
]);
useEffect(() => {
const bridge = getExternalFileDropBridge();
if (!bridge?.postMessageWithAdditionalObjects) {
return;
}
let cancelled = false;
const unlistenPromise = listen<NativeFileDropEventPayload>("external-file-drop", (event) => {
if (cancelled) {
return;
}
const payload = event.payload;
if (payload.kind === "leave") {
resetExternalDropHover();
return;
}
const isOverDropTarget = isDropPositionInsideElement(payload.position, containerRef.current);
const isActive = enabledRef.current && isOverDropTarget;
if (payload.kind === "enter" || payload.kind === "over") {
setIsExternalDropActive(isActive);
return;
}
if (payload.kind !== "drop") {
return;
}
resetExternalDropHover();
if (!isActive) {
return;
}
void processDropPaths(payload.paths);
});
return () => {
cancelled = true;
resetExternalDropHover();
unlistenPromise.then((unlisten) => unlisten());
};
}, [containerRef, processDropPaths, resetExternalDropHover, setIsExternalDropActive]);
useEffect(() => {
const bridge = getExternalFileDropBridge();
if (bridge?.postMessageWithAdditionalObjects) {
return;
}
let cancelled = false;
const handleWindowBlur = () => {
resetExternalDropHover();
};
window.addEventListener("blur", handleWindowBlur);
const unlistenPromise = getCurrentWebview().onDragDropEvent((event) => {
if (cancelled) {
return;
}
const payload = event.payload;
if (payload.type === "leave") {
resetExternalDropHover();
return;
}
const isOverDropTarget = isDropPositionInsideElement(payload.position, containerRef.current);
const isActive = enabledRef.current && isOverDropTarget;
if (payload.type === "enter" || payload.type === "over") {
setIsExternalDropActive(isActive);
return;
}
resetExternalDropHover();
if (!isActive) {
return;
}
void processDropPaths(payload.paths);
});
return () => {
cancelled = true;
resetExternalDropHover();
window.removeEventListener("blur", handleWindowBlur);
unlistenPromise.then((unlisten) => unlisten());
};
}, [containerRef, processDropPaths, resetExternalDropHover, setIsExternalDropActive]);
}
+36 -3
View File
@@ -395,7 +395,8 @@
"downloadSelected": "Download",
"editPath": "Click to edit path",
"emptyDirectory": "Empty",
"externalDropOverlay": "Drop to upload",
"externalDropOverlayHint": "Files and folders will be uploaded via SFTP",
"externalDropOverlayTitle": "Drop to upload",
"externalDropPathsRequired": "No local paths from this drag source. Use Upload Files or Upload Folder.",
"externalDropTimeout": "Windows did not provide local paths. Use Upload Files or Upload Folder.",
"favoriteAdded": "Added to favorites: {{path}}",
@@ -457,6 +458,7 @@
"special": "Special",
"sureDelete": "Delete '{{name}}'?",
"sureDeleteMultiple": "Delete {{count}} selected items?",
"deleteConfirmHint": "Press Enter to confirm, or click Delete.",
"symlinkName": "Symlink Name",
"symlinkTarget": "Symlink Target",
"syncFailed": "Path sync failed",
@@ -479,6 +481,8 @@
"clearCompleted": "Clear Done",
"completed": "Completed",
"delete": "Delete",
"deleteConfirmDesc": "Remove \"{{name}}\" from the transfer list?",
"deleteConfirmTitle": "Delete transfer record?",
"directoryProgress": "{{completed}} / {{total}} files",
"downloadCompleted": "Download done",
"downloadFolderCompleted": "Folder done",
@@ -495,7 +499,19 @@
"resumeAll": "Resume All",
"retry": "Retry",
"savePath": "Save to: {{path}}",
"transferring": "Transferring"
"transferring": "Transferring",
"uploadCompleted": "Upload complete",
"uploadFailed": "Upload failed: {{name}}",
"uploadFolderCompleted": "Folder upload complete",
"uploadFolderFailed": "Folder upload failed: {{name}}",
"uploadFolderStarted": "Uploading folder \"{{name}}\"...",
"uploadStarted": "Uploading file \"{{name}}\"...",
"duplicateTitle": "Target already exists",
"duplicateDescription": "A {{kind}} named \"{{name}}\" already exists at the destination. Choose how to proceed.",
"duplicateKindFile": "file",
"duplicateKindFolder": "folder",
"duplicateSkip": "Skip",
"duplicateOverwrite": "Overwrite"
},
"lockScreen": {
"message": "Locked due to inactivity.",
@@ -1466,6 +1482,7 @@
"closeRight": "Close Right",
"colorFailed": "Failed to set tab color",
"copyFailed": "Copy failed, please retry",
"copyIp": "Copy IP Address",
"copyName": "Copy Tab Name",
"disconnect": "Disconnect",
"disconnectFailed": "Disconnect failed",
@@ -1475,6 +1492,7 @@
"multiplexSsh": "Reuse SSH Channel",
"multiplexSshFailed": "Failed to reuse SSH channel",
"nameCopied": "Tab name copied",
"ipCopied": "IP address copied",
"reconnect": "Reconnect Session",
"reconnectFailed": "Reconnect failed",
"reconnectSuccess": "Reconnected",
@@ -1505,6 +1523,16 @@
"allSessions": "All Sessions (A)",
"click": "Click",
"connectionFailed": "Connection failed",
"dropFoldersNotSupportedLocal": "Folders cannot be inserted as paths in a local terminal.",
"dropFoldersZmodemOnly": "Folders cannot be uploaded via ZMODEM. Use the file explorer for SFTP uploads.",
"dropOverlayHintLocal": "File paths will be inserted into the command line",
"dropOverlayHintSsh": "Single files prefer ZMODEM; folders or failures use SFTP",
"dropOverlayHintZmodem": "Files only, uploaded via ZMODEM",
"dropOverlayTitleLocal": "Drop to insert file paths",
"dropOverlayTitleSsh": "Drop files or folders to upload",
"dropOverlayTitleUpload": "Drop files to upload",
"dropPathsRequired": "No local paths from this drag source.",
"dropUploadFailed": "File upload failed.",
"hiddenSessions": "Hidden Sessions",
"largeOutputProtectionActive": "Large-output protection active",
"largeOutputProtectionActiveDetail": "Rendering is prioritizing responsiveness. Skipped {{skipped}} queued characters.",
@@ -1524,6 +1552,8 @@
"recentSessions": "Recent Sessions",
"reconnectFailed": "Reconnect failed",
"reconnecting": "Reconnecting...",
"scrollTabsLeft": "Scroll tabs left",
"scrollTabsRight": "Scroll tabs right",
"sessionDisconnected": "Disconnected",
"shellSessions": "Shell Sessions",
"showHiddenSessions": "Show Hidden Sessions",
@@ -1584,8 +1614,11 @@
"complete": "ZMODEM transfer complete",
"downloading": "Receiving: {{fileName}} ({{percent}}%)",
"failed": "ZMODEM transfer failed: {{reason}}",
"preparingUpload": "Preparing to upload {{count}} file(s)...",
"selectFiles": "Select files to upload via ZMODEM",
"selectSaveDir": "Select folder to save ZMODEM downloads",
"uploading": "Sending: {{fileName}} ({{percent}}%)"
"dragFilesHint": "Alternatively, drag files onto the terminal to upload",
"uploading": "Sending: {{fileName}} ({{percent}}%)",
"sftpProbeSkipped": "SFTP channel busy, skipping conflict detection; uploading via ZMODEM"
}
}
+36 -3
View File
@@ -395,7 +395,8 @@
"downloadSelected": "下载选中",
"editPath": "点击编辑路径",
"emptyDirectory": "空目录",
"externalDropOverlay": "将文件或文件夹拖放到此处上传",
"externalDropOverlayHint": "文件或文件夹将以 SFTP 方式上传",
"externalDropOverlayTitle": "拖动以上传",
"externalDropPathsRequired": "此次拖拽没有暴露本机文件路径,请改用“上传文件”或“上传文件夹”。",
"externalDropTimeout": "Windows 没有提供此次拖拽的本机文件路径,请改用“上传文件”或“上传文件夹”。",
"favoriteAdded": "已添加到收藏夹:{{path}}",
@@ -457,6 +458,7 @@
"special": "特殊",
"sureDelete": "确定要删除“{{name}}”吗?",
"sureDeleteMultiple": "确定要删除这 {{count}} 个已选项目吗?",
"deleteConfirmHint": "按 Enter 确认,或点击删除按钮。",
"symlinkName": "符号链接名称",
"symlinkTarget": "符号链接目标",
"syncFailed": "同步终端路径失败",
@@ -479,6 +481,8 @@
"clearCompleted": "清除已完成",
"completed": "已完成",
"delete": "删除",
"deleteConfirmDesc": "确定从传输列表中删除「{{name}}」吗?",
"deleteConfirmTitle": "删除传输记录",
"directoryProgress": "{{completed}} / {{total}} 个文件",
"downloadCompleted": "下载完成",
"downloadFolderCompleted": "文件夹下载完成",
@@ -495,7 +499,19 @@
"resumeAll": "全部继续",
"retry": "重试",
"savePath": "保存至:{{path}}",
"transferring": "传输中"
"transferring": "传输中",
"uploadCompleted": "文件上传完成",
"uploadFailed": "文件上传失败:{{name}}",
"uploadFolderCompleted": "文件夹上传完成",
"uploadFolderFailed": "文件夹上传失败:{{name}}",
"uploadFolderStarted": "正在上传文件夹「{{name}}」…",
"uploadStarted": "正在上传文件「{{name}}」…",
"duplicateTitle": "目标已存在",
"duplicateDescription": "远程路径已有同名{{kind}}「{{name}}」,请选择处理方式。",
"duplicateKindFile": "文件",
"duplicateKindFolder": "文件夹",
"duplicateSkip": "跳过",
"duplicateOverwrite": "覆盖"
},
"lockScreen": {
"message": "由于长时间无操作,应用程序已自动锁定。",
@@ -1466,6 +1482,7 @@
"closeRight": "关闭右侧所有会话",
"colorFailed": "标签颜色设置失败",
"copyFailed": "复制失败,请重试",
"copyIp": "复制服务器 IP",
"copyName": "复制标签名称",
"disconnect": "断开连接",
"disconnectFailed": "断开连接失败",
@@ -1475,6 +1492,7 @@
"multiplexSsh": "复用 SSH 通道",
"multiplexSshFailed": "SSH 通道复用失败",
"nameCopied": "标签名称已复制",
"ipCopied": "服务器 IP 已复制",
"reconnect": "重新连接会话",
"reconnectFailed": "重新连接失败",
"reconnectSuccess": "重新连接成功",
@@ -1505,6 +1523,16 @@
"allSessions": "所有会话(A)",
"click": "单击",
"connectionFailed": "连接失败",
"dropFoldersNotSupportedLocal": "本地终端不支持拖入文件夹,请只拖入文件。",
"dropFoldersZmodemOnly": "文件夹无法通过 ZMODEM 上传,请使用文件浏览器进行 SFTP 上传。",
"dropOverlayHintLocal": "文件路径将插入到命令行",
"dropOverlayHintSsh": "单文件优先 ZMODEM,文件夹或失败时使用 SFTP",
"dropOverlayHintZmodem": "仅支持文件,将以 ZMODEM 方式上传",
"dropOverlayTitleLocal": "拖动以插入文件路径",
"dropOverlayTitleSsh": "拖动以上传文件或文件夹",
"dropOverlayTitleUpload": "拖动以上传文件",
"dropPathsRequired": "此次拖拽没有暴露本机文件路径。",
"dropUploadFailed": "文件上传失败。",
"hiddenSessions": "被隐藏的会话",
"largeOutputProtectionActive": "大流量输出保护已启用",
"largeOutputProtectionActiveDetail": "终端正在优先保证响应性,已跳过 {{skipped}} 个排队字符。",
@@ -1524,6 +1552,8 @@
"recentSessions": "最近的会话",
"reconnectFailed": "重新连接失败",
"reconnecting": "正在重新连接...",
"scrollTabsLeft": "向左滚动标签",
"scrollTabsRight": "向右滚动标签",
"sessionDisconnected": "会话已断开",
"shellSessions": "Shell会话",
"showHiddenSessions": "显示被隐藏的会话",
@@ -1584,8 +1614,11 @@
"complete": "ZMODEM 传输完成",
"downloading": "接收中: {{fileName}} ({{percent}}%)",
"failed": "ZMODEM 传输失败: {{reason}}",
"preparingUpload": "正在准备上传 {{count}} 个文件…",
"selectFiles": "选择要通过 ZMODEM 上传的文件",
"selectSaveDir": "选择 ZMODEM 下载保存文件夹",
"uploading": "发送中: {{fileName}} ({{percent}}%)"
"dragFilesHint": "或拖拽文件到终端以通过 ZMODEM 上传",
"uploading": "发送中: {{fileName}} ({{percent}}%)",
"sftpProbeSkipped": "SFTP 通道繁忙,跳过同名文件检测,直接上传"
}
}
+13
View File
@@ -132,6 +132,19 @@
}
}
.tab-strip-scroll {
scrollbar-width: none;
-ms-overflow-style: none;
scroll-behavior: auto;
overscroll-behavior-x: contain;
}
.tab-strip-scroll::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
}
.terminal-scroll::-webkit-scrollbar,
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
+134
View File
@@ -0,0 +1,134 @@
import { logger } from "@/lib/logger";
export interface NativeFileDropEventPayload {
kind: "enter" | "over" | "leave" | "drop";
paths: string[];
position: {
x: number;
y: number;
};
}
export const EXTERNAL_FILE_DROP_MESSAGE_KIND = "external-file-drop";
export const DRAG_EVENT_CAPTURE_OPTIONS = true;
type WebView2Bridge = {
postMessageWithAdditionalObjects: (
message: unknown,
additionalObjects: ArrayLike<unknown>,
) => void;
};
type DataTransferItemWithFileSystemHandle = DataTransferItem & {
getAsFileSystemHandle?: () => Promise<unknown>;
};
declare global {
interface Window {
chrome?: {
webview?: WebView2Bridge;
};
}
}
export function isDropPositionInsideElement(
position: { x: number; y: number },
element: HTMLElement | null,
) {
if (!element) {
return false;
}
const rect = element.getBoundingClientRect();
const scale = typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
const candidates =
scale === 1 ? [position] : [position, { x: position.x / scale, y: position.y / scale }];
return candidates.some(
({ x, y }) => x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom,
);
}
export function isExternalFileDragEvent(event: DragEvent) {
const dataTransfer = event.dataTransfer;
if (!dataTransfer) {
return false;
}
if (Array.from(dataTransfer.types ?? []).includes("Files")) {
return true;
}
if (dataTransfer.files.length > 0) {
return true;
}
return Array.from(dataTransfer.items ?? []).some((item) => item.kind === "file");
}
export function getDragEventPosition(event: DragEvent) {
return {
x: event.clientX,
y: event.clientY,
};
}
export function getExternalFileDropBridge() {
return window.chrome?.webview;
}
export function createExternalFileDropBridgeMessage(position: { x: number; y: number }) {
return JSON.stringify({
kind: EXTERNAL_FILE_DROP_MESSAGE_KIND,
position,
});
}
export async function collectExternalDropAdditionalObjects(dataTransfer: DataTransfer | null) {
if (!dataTransfer) {
return [];
}
const fileItems = Array.from(dataTransfer.items ?? []).filter((item) => item.kind === "file");
if (fileItems.length === 0 && dataTransfer.files.length > 0) {
return Array.from(dataTransfer.files);
}
const additionalObjects: unknown[] = [];
for (const item of fileItems) {
const file = item.getAsFile();
if (file) {
additionalObjects.push(file);
continue;
}
const itemWithHandle = item as DataTransferItemWithFileSystemHandle;
if (typeof itemWithHandle.getAsFileSystemHandle === "function") {
try {
const handle = await itemWithHandle.getAsFileSystemHandle();
if (handle) {
additionalObjects.push(handle);
}
} catch {
// Fall back to File objects if the runtime cannot expose FileSystemHandle.
}
}
}
return additionalObjects;
}
export function logExternalDropBridgeFailure(
event: string,
message: string,
error: unknown,
data?: Record<string, unknown>,
) {
logger.error({
domain: "ui.error",
event,
message,
data,
error,
});
}
+86
View File
@@ -0,0 +1,86 @@
import { toast } from "sonner";
import type { ResolvedLocalDropPathEntry } from "@/components/panel/file-explorer/model";
import { sendSessionInput } from "@/lib/sessionInput";
import { uploadFilesViaZmodem } from "@/lib/terminalZmodemUpload";
import type { SessionType } from "@/types/global";
function quoteLocalPath(path: string): string {
if (!/[\s'"\\]/.test(path)) {
return path;
}
if (path.includes("\\")) {
return `"${path.replace(/"/g, '\\"')}"`;
}
return `'${path.replace(/'/g, `'\\''`)}'`;
}
export function formatLocalTerminalDropInput(paths: string[]): string {
return paths.map(quoteLocalPath).join(" ");
}
export function getTerminalDropOverlayCopy(
sessionType: SessionType,
t: (key: string) => string,
): { title: string; hint: string } {
switch (sessionType) {
case "Local":
return {
title: t("terminal.dropOverlayTitleLocal"),
hint: t("terminal.dropOverlayHintLocal"),
};
case "SSH":
case "Telnet":
case "Serial":
return {
title: t("terminal.dropOverlayTitleUpload"),
hint: t("terminal.dropOverlayHintZmodem"),
};
}
}
export async function handleTerminalFileDrop(params: {
sessionId: string;
sessionType: SessionType;
entries: ResolvedLocalDropPathEntry[];
t: (key: string) => string;
duplicateStrategy?: string;
}): Promise<void> {
const { sessionId, sessionType, entries, t, duplicateStrategy = "ask" } = params;
if (entries.length === 0) {
return;
}
const fileEntries = entries.filter((entry) => !entry.isDir);
const hasDirectories = entries.some((entry) => entry.isDir);
if (sessionType === "Local") {
if (fileEntries.length === 0) {
toast.message(t("terminal.dropFoldersNotSupportedLocal"));
return;
}
await sendSessionInput(
sessionId,
formatLocalTerminalDropInput(fileEntries.map((entry) => entry.path)),
);
return;
}
// SSH / Telnet / Serial: ZMODEM only, no folder support.
if (hasDirectories) {
toast.error(t("terminal.dropFoldersZmodemOnly"));
return;
}
if (fileEntries.length === 0) {
return;
}
await uploadFilesViaZmodem(
sessionId,
fileEntries.map((entry) => entry.path),
duplicateStrategy,
).catch((error) => {
toast.error(t("terminal.dropUploadFailed"));
throw error;
});
}
+287
View File
@@ -0,0 +1,287 @@
import { invoke } from "@/lib/invoke";
import { buildTerminalCommandInput, sendSessionInput } from "@/lib/sessionInput";
import { showTransferDuplicatePrompt } from "@/lib/transferDuplicatePrompt";
import { getLocalPathName } from "@/components/panel/file-explorer/model";
import { toast } from "sonner";
import i18n from "@/i18n";
const ZMODEM_UPLOAD_TIMEOUT_MS = 60_000;
type PendingUploadPhase = "waiting" | "active";
type PendingUpload = {
sessionId: string;
filePaths: string[];
resolve: () => void;
reject: (error: Error) => void;
timeoutId: number | null;
phase: PendingUploadPhase;
};
let pendingUpload: PendingUpload | null = null;
let preparingUploadToastId: string | number | null = null;
function clearWaitingTimeout() {
if (pendingUpload?.timeoutId !== null && pendingUpload?.timeoutId !== undefined) {
window.clearTimeout(pendingUpload.timeoutId);
pendingUpload.timeoutId = null;
}
}
function clearPendingUpload() {
clearWaitingTimeout();
pendingUpload = null;
if (preparingUploadToastId !== null) {
toast.dismiss(preparingUploadToastId);
preparingUploadToastId = null;
}
}
export function getPendingZmodemUploadPaths(sessionId: string): string[] | null {
if (!pendingUpload || pendingUpload.sessionId !== sessionId) {
return null;
}
return pendingUpload.filePaths;
}
export function hasPendingZmodemUpload(sessionId: string): boolean {
return pendingUpload?.sessionId === sessionId;
}
export function markPendingZmodemUploadActive(sessionId: string) {
if (!pendingUpload || pendingUpload.sessionId !== sessionId) {
return;
}
clearWaitingTimeout();
pendingUpload.phase = "active";
if (preparingUploadToastId !== null) {
toast.dismiss(preparingUploadToastId);
preparingUploadToastId = null;
}
}
export function completePendingZmodemUpload(sessionId: string) {
if (!pendingUpload || pendingUpload.sessionId !== sessionId) {
return;
}
const { resolve } = pendingUpload;
clearPendingUpload();
resolve();
}
export function failPendingZmodemUpload(sessionId: string, reason: string) {
if (!pendingUpload || pendingUpload.sessionId !== sessionId) {
return;
}
const { reject } = pendingUpload;
clearPendingUpload();
reject(new Error(reason));
}
export function cancelPendingZmodemUpload(sessionId?: string) {
if (!pendingUpload) return;
if (sessionId && pendingUpload.sessionId !== sessionId) return;
failPendingZmodemUpload(pendingUpload.sessionId, "cancelled");
}
const SFTP_PROBE_TIMEOUT_MS = 3_000;
function withTimeout<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T> {
return Promise.race([
promise,
new Promise<T>((resolve) => setTimeout(() => resolve(fallback), ms)),
]);
}
export interface ConflictProbeResult {
paths: string[];
probeSkipped: boolean;
}
/**
* Probe the remote working directory for files that match the local file
* names. Resolves conflicts according to the configured duplicate strategy.
*
* - "overwrite" / "rename": delete remote files silently, include all local paths
* - "skip": silently exclude conflicting local paths
* - "ask" (default): prompt the user per conflict
*
* SFTP operations are time-boxed — if the SFTP channel is busy (e.g. the
* file explorer panel is open), the probe is skipped, all files are uploaded
* without conflict detection, and `probeSkipped` is set to true.
*/
export async function probeAndResolveRemoteConflicts(
sessionId: string,
filePaths: string[],
duplicateStrategy = "ask",
): Promise<ConflictProbeResult> {
if (filePaths.length === 0) return { paths: [], probeSkipped: false };
// Resolve remote upload directory (terminal CWD is a shell operation, not SFTP).
const cwd = await withTimeout(
invoke<string>("get_terminal_cwd", { sessionId }).catch(() => null),
SFTP_PROBE_TIMEOUT_MS,
null,
);
// If CWD fails (non-SSH or timeout), try home dir via SFTP with timeout.
const home = !cwd
? await withTimeout(
invoke<string>("get_home_dir", { sessionId }).catch(() => "/"),
SFTP_PROBE_TIMEOUT_MS,
null as string | null,
)
: null;
const remoteDir = (cwd || home || "/").replace(/\/+$/, "") || "/";
// List remote directory (SFTP, time-boxed).
const entries = await withTimeout(
invoke<{ name: string; is_dir: boolean }[] | null>(
"list_remote_dir",
{ sessionId, path: remoteDir },
).catch(() => null),
SFTP_PROBE_TIMEOUT_MS,
null as { name: string; is_dir: boolean }[] | null,
);
// If the SFTP probe timed out, skip conflict detection entirely.
if (entries === null) {
toast.message(i18n.t("zmodem.sftpProbeSkipped"));
return { paths: filePaths, probeSkipped: true };
}
const existingNames = new Set(entries.map((e) => e.name));
// Separate conflicting and clean files.
const conflicts: string[] = [];
const clean: string[] = [];
for (const fp of filePaths) {
const name = getLocalPathName(fp, "file");
if (existingNames.has(name)) {
conflicts.push(fp);
} else {
clean.push(fp);
}
}
if (conflicts.length === 0) return { paths: filePaths, probeSkipped: false };
// Auto-resolve based on configured strategy.
if (duplicateStrategy === "skip") {
return { paths: clean, probeSkipped: false };
}
if (duplicateStrategy === "overwrite" || duplicateStrategy === "rename") {
for (const fp of conflicts) {
const name = getLocalPathName(fp, "file");
const remotePath =
remoteDir === "/" ? `/${name}` : `${remoteDir}/${name}`;
await withTimeout(
invoke("delete_remote_file", { sessionId, path: remotePath }).catch(() => {}),
SFTP_PROBE_TIMEOUT_MS,
undefined,
);
}
return { paths: filePaths, probeSkipped: false };
}
// "ask" — prompt the user for each conflict.
const resolved: string[] = [...clean];
for (const fp of conflicts) {
const name = getLocalPathName(fp, "file");
const remotePath =
remoteDir === "/" ? `/${name}` : `${remoteDir}/${name}`;
const choice = await showTransferDuplicatePrompt({
requestId: crypto.randomUUID(),
sessionId,
remotePath,
fileName: name,
isDirectory: false,
});
if (choice === "overwrite") {
await withTimeout(
invoke("delete_remote_file", { sessionId, path: remotePath }).catch(() => {}),
SFTP_PROBE_TIMEOUT_MS,
undefined,
);
resolved.push(fp);
}
// "skip" → don't add.
}
return { paths: resolved, probeSkipped: false };
}
export async function uploadFilesViaZmodem(
sessionId: string,
filePaths: string[],
duplicateStrategy = "ask",
): Promise<void> {
if (filePaths.length === 0) {
return Promise.resolve();
}
// Cancel any previous pending upload.
if (pendingUpload?.sessionId === sessionId && pendingUpload.phase === "waiting") {
clearPendingUpload();
await invoke("zmodem_cancel", { sessionId }).catch(() => {});
} else {
cancelPendingZmodemUpload(sessionId);
}
// Show immediate feedback so the user knows the drop was received,
// even while the SFTP probe may be slow (e.g. file explorer panel open).
if (preparingUploadToastId !== null) {
toast.dismiss(preparingUploadToastId);
}
preparingUploadToastId = toast.message(
i18n.t("zmodem.preparingUpload", { count: filePaths.length }),
);
// Netcatty approach: probe remote directory and delete conflicting files
// BEFORE sending `rz`, so the remote rz never sees an existing file to
// prompt about. If the SFTP channel is busy (e.g. file explorer panel is
// open), the probe is time-boxed and we fall through.
const conflict = await probeAndResolveRemoteConflicts(
sessionId,
filePaths,
duplicateStrategy,
);
if (conflict.paths.length === 0) {
if (preparingUploadToastId !== null) {
toast.dismiss(preparingUploadToastId);
preparingUploadToastId = null;
}
throw new Error("All files were skipped or probe failed");
}
return new Promise((resolve, reject) => {
const timeoutId = window.setTimeout(() => {
if (pendingUpload?.sessionId !== sessionId || pendingUpload.phase !== "waiting") {
return;
}
clearPendingUpload();
void invoke("zmodem_cancel", { sessionId }).catch(() => {});
reject(new Error("zmodem timeout"));
}, ZMODEM_UPLOAD_TIMEOUT_MS);
pendingUpload = {
sessionId,
filePaths: conflict.paths,
resolve,
reject,
timeoutId,
phase: "waiting",
};
sendSessionInput(sessionId, buildTerminalCommandInput("rz", true)).catch((error) => {
if (pendingUpload?.sessionId !== sessionId) return;
clearPendingUpload();
reject(error instanceof Error ? error : new Error(String(error)));
});
});
}
+70
View File
@@ -0,0 +1,70 @@
export type TransferDuplicateChoice = "skip" | "overwrite";
export interface TransferDuplicateRequest {
requestId: string;
sessionId: string;
remotePath: string;
fileName: string;
isDirectory: boolean;
targetWindowLabel?: string | null;
respondViaBackend?: boolean;
}
type TransferDuplicateListener = (request: TransferDuplicateRequest | null) => void;
let activeRequest: TransferDuplicateRequest | null = null;
let localResolver: ((choice: TransferDuplicateChoice) => void) | null = null;
const listeners = new Set<TransferDuplicateListener>();
function notifyListeners() {
for (const listener of listeners) {
listener(activeRequest);
}
}
export function subscribeTransferDuplicatePrompt(listener: TransferDuplicateListener) {
listeners.add(listener);
listener(activeRequest);
return () => {
listeners.delete(listener);
};
}
export function showTransferDuplicatePrompt(
request: TransferDuplicateRequest,
): Promise<TransferDuplicateChoice> {
if (localResolver) {
localResolver("skip");
}
return new Promise((resolve) => {
localResolver = resolve;
activeRequest = { ...request, respondViaBackend: request.respondViaBackend ?? false };
notifyListeners();
});
}
export function resolveTransferDuplicatePrompt(choice: TransferDuplicateChoice) {
const resolver = localResolver;
localResolver = null;
activeRequest = null;
notifyListeners();
resolver?.(choice);
}
export function setBackendTransferDuplicatePrompt(request: TransferDuplicateRequest | null) {
if (request) {
activeRequest = { ...request, respondViaBackend: true };
notifyListeners();
return;
}
if (activeRequest?.respondViaBackend) {
activeRequest = null;
notifyListeners();
}
}
export function getActiveTransferDuplicatePrompt() {
return activeRequest;
}
+130
View File
@@ -0,0 +1,130 @@
import { getRemoteParentDirectory } from "@/components/panel/file-explorer/model";
import type { EnqueueUploadRequest } from "@/context/TransferContext";
import { invoke } from "@/lib/invoke";
import {
showTransferDuplicatePrompt,
type TransferDuplicateChoice,
} from "@/lib/transferDuplicatePrompt";
import type { FileEntry, FileProperties } from "@/types/global";
async function remotePathExists(
sessionId: string,
path: string,
): Promise<{ exists: boolean; isDirectory: boolean }> {
try {
const props = await invoke<FileProperties>("get_file_properties", {
sessionId,
path,
});
return { exists: true, isDirectory: props.is_dir };
} catch {
const parentDir = getRemoteParentDirectory(path);
const fileName = path.split("/").filter(Boolean).pop() ?? "";
if (!fileName) {
return { exists: false, isDirectory: false };
}
try {
const entries = await invoke<FileEntry[]>("list_remote_dir", {
sessionId,
path: parentDir,
});
const entry = entries.find((item) => item.name === fileName);
if (entry) {
return { exists: true, isDirectory: entry.is_dir };
}
} catch {
// Fall through to "does not exist".
}
return { exists: false, isDirectory: false };
}
}
async function resolveDuplicateChoice(params: {
sessionId: string;
remotePath: string;
fileName: string;
isDirectory: boolean;
duplicateStrategy: string;
}): Promise<TransferDuplicateChoice | "proceed" | "skip"> {
const { sessionId, remotePath, fileName, isDirectory, duplicateStrategy } = params;
switch (duplicateStrategy) {
case "skip":
return "skip";
case "overwrite":
case "rename":
return "proceed";
case "ask":
return showTransferDuplicatePrompt({
requestId: crypto.randomUUID(),
sessionId,
remotePath,
fileName,
isDirectory,
});
default:
return "proceed";
}
}
async function resolveRemoteUploadConflict(params: {
sessionId: string;
remotePath: string;
fileName: string;
isDirectory: boolean;
duplicateStrategy: string;
}): Promise<"include" | "skip"> {
const { exists, isDirectory } = await remotePathExists(params.sessionId, params.remotePath);
if (!exists) {
return "include";
}
const choice = await resolveDuplicateChoice({
sessionId: params.sessionId,
remotePath: params.remotePath,
fileName: params.fileName,
isDirectory: exists ? isDirectory : params.isDirectory,
duplicateStrategy: params.duplicateStrategy,
});
if (choice === "skip") {
return "skip";
}
return "include";
}
export async function filterEnqueueUploadRequests(
requests: EnqueueUploadRequest[],
duplicateStrategy: string,
): Promise<EnqueueUploadRequest[]> {
if (duplicateStrategy !== "ask" && duplicateStrategy !== "skip") {
return requests;
}
const filtered: EnqueueUploadRequest[] = [];
for (const request of requests) {
const decision = await resolveRemoteUploadConflict({
sessionId: request.sessionId,
remotePath: request.remotePath,
fileName: request.fileName,
isDirectory: request.kind === "directory",
duplicateStrategy,
});
if (decision === "skip") {
continue;
}
filtered.push(
duplicateStrategy === "ask"
? { ...request, duplicateStrategyOverride: "overwrite" }
: request,
);
}
return filtered;
}