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:
1 parent
61fdd11fae
commit
46db1104eb
68 files changed
+10289
-595
No files matched your search
Binary file not shown.
Generated
-2
@@ -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",
|
||||
|
||||
@@ -157,3 +157,6 @@ windows = { version = "0.61", features = [
|
||||
"Win32_UI_Shell",
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
] }
|
||||
|
||||
[patch.crates-io]
|
||||
zmodem2 = { path = "vendor/zmodem2" }
|
||||
@@ -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}'"
|
||||
)))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<()>;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"v":1}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"git": {
|
||||
"sha1": "9635055dbc74652765e4066d18e1cfaac880e58c"
|
||||
},
|
||||
"path_in_vcs": ""
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
+433
@@ -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",
|
||||
]
|
||||
Vendored
+65
@@ -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
@@ -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
@@ -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.
|
||||
Vendored
+26
@@ -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.
|
||||
Vendored
+16
@@ -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/).
|
||||
Vendored
+45
@@ -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
|
||||
}
|
||||
+3488
File diff suppressed because it is too large.
Load diff
+136
@@ -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> {}
|
||||
Vendored
+118
@@ -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
@@ -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
@@ -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
|
||||
}))
|
||||
}
|
||||
Vendored
+86
@@ -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>;
|
||||
}
|
||||
Vendored
+44
@@ -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;
|
||||
Vendored
+135
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
+1450
File diff suppressed because it is too large.
Load diff
Vendored
+43
@@ -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
@@ -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(¤t_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(¤t_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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 通道繁忙,跳过同名文件检测,直接上传"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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)));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user