From 03b76c44dca803efa71b842dbe4e2b50c35749f2 Mon Sep 17 00:00:00 2001 From: Kang Date: Fri, 17 Jul 2026 17:31:17 +0800 Subject: [PATCH] feat(zmodem): enhance file upload conflict handling and user prompts (#270) - Introduced a new conflict mode for Zmodem uploads, allowing users to choose between "overwrite" and "skip" options when handling duplicate files. - Updated the `TransferDuplicateDialog` to display different titles and descriptions based on the verification status of the upload request. - Enhanced the backend logic to support conflict mode during file uploads, ensuring proper handling of user choices. - Added new functions to resolve unverified uploads and manage conflict modes effectively. --- src-tauri/src/cmd/session.rs | 3 + src-tauri/src/cmd/stats.rs | 16 +++ src-tauri/src/core/session.rs | 10 +- src-tauri/src/core/ssh/io.rs | 7 +- .../core/terminal_session/local/session.rs | 7 +- .../core/terminal_session/serial/session.rs | 7 +- .../core/terminal_session/telnet/session.rs | 7 +- src-tauri/src/core/zmodem/transfer.rs | 9 +- src-tauri/src/core/zmodem/types.rs | 35 ++++- src-tauri/src/lib.rs | 1 + src-tauri/vendor/zmodem2/src/error.rs | 2 + src-tauri/vendor/zmodem2/src/transmission.rs | 29 ++++- src-tauri/vendor/zmodem2/tests/protocol.rs | 79 +++++++++++- .../file-explorer/TransferDuplicateDialog.tsx | 18 ++- .../terminal/zmodemTerminalEvents.ts | 5 +- src/lib/terminalZmodemUpload.ts | 120 +++++++++++++++--- src/lib/transferDuplicatePrompt.ts | 1 + 17 files changed, 310 insertions(+), 46 deletions(-) diff --git a/src-tauri/src/cmd/session.rs b/src-tauri/src/cmd/session.rs index d67cbf06..02a16044 100644 --- a/src-tauri/src/cmd/session.rs +++ b/src-tauri/src/cmd/session.rs @@ -2,6 +2,7 @@ use crate::config; use crate::core::ssh::{ self, HostKeyVerifyManager, PendingAuthManager, PendingSshAuthManager, SshAuthResponse, }; +use crate::core::zmodem::ZmodemUploadConflictMode; use crate::core::{ self, QuickCommandsStore, RecordingManager, SessionCommand, SessionInfo, SessionManager, TerminalHistorySearchRequest, TerminalHistorySearchResponse, @@ -732,6 +733,7 @@ pub async fn zmodem_accept_upload( state: tauri::State<'_, Arc>, session_id: String, file_paths: Vec, + conflict_mode: Option, ) -> AppResult<()> { state .send_command( @@ -741,6 +743,7 @@ pub async fn zmodem_accept_upload( .into_iter() .map(std::path::PathBuf::from) .collect(), + conflict_mode: ZmodemUploadConflictMode::from_wire(conflict_mode.as_deref()), }, ) .await diff --git a/src-tauri/src/cmd/stats.rs b/src-tauri/src/cmd/stats.rs index 35350f06..c42ea506 100644 --- a/src-tauri/src/cmd/stats.rs +++ b/src-tauri/src/cmd/stats.rs @@ -44,3 +44,19 @@ pub async fn get_terminal_cwd( "Working directory is not available for this session. Terminal path sync is only available when the backend receives directory updates from the session.".to_string(), )) } + +#[tauri::command] +pub async fn try_get_terminal_cwd( + state: tauri::State<'_, Arc>, + session_id: String, +) -> AppResult> { + let cwd_arc = { + let sessions = state.sessions.lock().await; + let session = sessions.get(&session_id).ok_or_else(|| { + AppError::SessionNotFound(format!("Session '{}' not found", session_id)) + })?; + session.cwd.clone() + }; + + Ok(cwd_arc.lock().await.clone()) +} diff --git a/src-tauri/src/core/session.rs b/src-tauri/src/core/session.rs index afb668e3..42679cf3 100644 --- a/src-tauri/src/core/session.rs +++ b/src-tauri/src/core/session.rs @@ -6,6 +6,7 @@ use super::history::{CommandHistoryStore, sanitize_history_command}; use crate::config::AiExecutionProfile; use crate::core::capture::CapturedOutput; +use crate::core::zmodem::{ZmodemPreparedUpload, ZmodemUploadConflictMode}; use crate::error::{AppError, AppResult}; use crate::utils::fuzzy::{FuzzyResult, fuzzy_search_items}; use serde::{Deserialize, Serialize}; @@ -121,7 +122,10 @@ pub enum SessionCommand { /// ZMODEM: user accepted a download — save to this directory. ZmodemAcceptDownload { save_dir: std::path::PathBuf }, /// ZMODEM: user accepted an upload — send these files. - ZmodemAcceptUpload { files: Vec }, + ZmodemAcceptUpload { + files: Vec, + conflict_mode: ZmodemUploadConflictMode, + }, /// ZMODEM: user cancelled the ZMODEM transfer. ZmodemCancel, } @@ -168,7 +172,7 @@ pub struct SessionManager { pub sessions: Arc>>, pub history_store: Arc>, command_submissions: Arc>>, - pending_zmodem_uploads: Arc>>>, + pending_zmodem_uploads: Arc>>, history_save_notify: Arc, history_save_worker_started: AtomicBool, history_event_notify: Arc, @@ -293,7 +297,7 @@ impl SessionManager { pub async fn take_pending_zmodem_upload( &self, session_id: &str, - ) -> Option> { + ) -> Option { self.pending_zmodem_uploads.lock().await.remove(session_id) } diff --git a/src-tauri/src/core/ssh/io.rs b/src-tauri/src/core/ssh/io.rs index b37de219..ea15313e 100644 --- a/src-tauri/src/core/ssh/io.rs +++ b/src-tauri/src/core/ssh/io.rs @@ -672,9 +672,12 @@ pub(super) async fn ssh_io_loop( } } } - Some(SessionCommand::ZmodemAcceptUpload { files }) => { + Some(SessionCommand::ZmodemAcceptUpload { + files, + conflict_mode, + }) => { if let Some(ref mut transfer) = zmodem_transfer { - let actions = transfer.accept_upload(files); + let actions = transfer.accept_upload(files, conflict_mode); handle_zmodem_actions(&app, &zmodem_event_name, &mut channel, actions).await; if transfer.is_done() { zmodem_transfer = None; diff --git a/src-tauri/src/core/terminal_session/local/session.rs b/src-tauri/src/core/terminal_session/local/session.rs index 236d3ba7..680b9491 100644 --- a/src-tauri/src/core/terminal_session/local/session.rs +++ b/src-tauri/src/core/terminal_session/local/session.rs @@ -511,10 +511,13 @@ fn pty_session_thread( } } } - SessionCommand::ZmodemAcceptUpload { files } => { + SessionCommand::ZmodemAcceptUpload { + files, + conflict_mode, + } => { let mut zm = zmodem_state.lock().unwrap(); if let Some(ref mut transfer) = *zm { - let actions = transfer.accept_upload(files); + let actions = transfer.accept_upload(files, conflict_mode); for action in actions { match action { ZmodemAction::SendToRemote(data) => { diff --git a/src-tauri/src/core/terminal_session/serial/session.rs b/src-tauri/src/core/terminal_session/serial/session.rs index 84f7bd5e..ceb04dd6 100644 --- a/src-tauri/src/core/terminal_session/serial/session.rs +++ b/src-tauri/src/core/terminal_session/serial/session.rs @@ -259,10 +259,13 @@ fn serial_session_thread( } } } - SessionCommand::ZmodemAcceptUpload { files } => { + SessionCommand::ZmodemAcceptUpload { + files, + conflict_mode, + } => { let mut zm = zmodem_state.lock().unwrap(); if let Some(ref mut transfer) = *zm { - let actions = transfer.accept_upload(files); + let actions = transfer.accept_upload(files, conflict_mode); for action in actions { match action { ZmodemAction::SendToRemote(data) => { diff --git a/src-tauri/src/core/terminal_session/telnet/session.rs b/src-tauri/src/core/terminal_session/telnet/session.rs index 7386f290..952ed604 100644 --- a/src-tauri/src/core/terminal_session/telnet/session.rs +++ b/src-tauri/src/core/terminal_session/telnet/session.rs @@ -523,10 +523,13 @@ async fn telnet_session_task( if transfer.is_done() { *zm = None; } } } - Some(SessionCommand::ZmodemAcceptUpload { files }) => { + Some(SessionCommand::ZmodemAcceptUpload { + files, + conflict_mode, + }) => { let mut zm = zmodem_state.lock().await; if let Some(ref mut transfer) = *zm { - let actions = transfer.accept_upload(files); + let actions = transfer.accept_upload(files, conflict_mode); for action in actions { match action { ZmodemAction::SendToRemote(data) => { let _ = writer.write_all(&data).await; } diff --git a/src-tauri/src/core/zmodem/transfer.rs b/src-tauri/src/core/zmodem/transfer.rs index 7f1c08ed..fd5f1315 100644 --- a/src-tauri/src/core/zmodem/transfer.rs +++ b/src-tauri/src/core/zmodem/transfer.rs @@ -287,13 +287,17 @@ impl ZmodemTransfer { } /// Called when the user accepts an **upload** and provides file paths. - pub fn accept_upload(&mut self, files: Vec) -> Vec { + pub fn accept_upload( + &mut self, + files: Vec, + conflict_mode: ZmodemUploadConflictMode, + ) -> Vec { let buffered = match &mut self.state { TransferState::WaitingForUser { buffered } => std::mem::take(buffered), _ => return vec![], }; - let sender = match zmodem2::Sender::new() { + let mut sender = match zmodem2::Sender::new() { Ok(s) => s, Err(e) => { self.state = TransferState::Done; @@ -302,6 +306,7 @@ impl ZmodemTransfer { })]; } }; + sender.set_file_options(conflict_mode.file_options()); self.state = TransferState::Sending { sender, diff --git a/src-tauri/src/core/zmodem/types.rs b/src-tauri/src/core/zmodem/types.rs index 7e65724f..ceea71fc 100644 --- a/src-tauri/src/core/zmodem/types.rs +++ b/src-tauri/src/core/zmodem/types.rs @@ -8,6 +8,33 @@ pub enum ZmodemDirection { Upload, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ZmodemUploadConflictMode { + Overwrite, + Skip, +} + +impl ZmodemUploadConflictMode { + pub fn from_wire(value: Option<&str>) -> Self { + match value { + Some(value) if value.eq_ignore_ascii_case("skip") => Self::Skip, + _ => Self::Overwrite, + } + } + + fn file_options(self) -> zmodem2::ZfileManagementOption { + match self { + Self::Overwrite => zmodem2::ZfileManagementOption::ZMCLOB, + Self::Skip => zmodem2::ZfileManagementOption::ZMSKNOLOC, + } + } +} + +pub struct ZmodemPreparedUpload { + pub files: Vec, + pub conflict_mode: ZmodemUploadConflictMode, +} + /// Events emitted to the frontend via Tauri events. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "camelCase")] @@ -38,11 +65,13 @@ pub enum ZmodemEvent { pub fn start_zmodem_transfer( direction: ZmodemDirection, initial_bytes: &[u8], - prepared_upload_files: Option>, + prepared_upload: Option, ) -> (ZmodemTransfer, Vec) { let mut transfer = ZmodemTransfer::new(direction, initial_bytes); - let bootstrap_actions = match (direction, prepared_upload_files) { - (ZmodemDirection::Upload, Some(files)) => transfer.accept_upload(files), + let bootstrap_actions = match (direction, prepared_upload) { + (ZmodemDirection::Upload, Some(upload)) => { + transfer.accept_upload(upload.files, upload.conflict_mode) + } _ => Vec::new(), }; (transfer, bootstrap_actions) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index db34f562..e8ec0190 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -226,6 +226,7 @@ pub fn run() { cmd::backup::import_config, cmd::stats::get_remote_stats, cmd::stats::get_terminal_cwd, + cmd::stats::try_get_terminal_cwd, cmd::process::get_remote_processes, cmd::process::signal_remote_process, cmd::process::renice_remote_process, diff --git a/src-tauri/vendor/zmodem2/src/error.rs b/src-tauri/vendor/zmodem2/src/error.rs index 7adfb9a8..f1f916f8 100644 --- a/src-tauri/vendor/zmodem2/src/error.rs +++ b/src-tauri/vendor/zmodem2/src/error.rs @@ -25,6 +25,8 @@ pub enum Error { Read(String), #[error("out of memory")] OutOfMemory, + #[error("remote aborted transfer")] + RemoteAborted, #[error("unexpected CRC-16")] UnexpectedCrc16, #[error("unexpected CRC-32")] diff --git a/src-tauri/vendor/zmodem2/src/transmission.rs b/src-tauri/vendor/zmodem2/src/transmission.rs index f28c0999..b5b338c9 100644 --- a/src-tauri/vendor/zmodem2/src/transmission.rs +++ b/src-tauri/vendor/zmodem2/src/transmission.rs @@ -672,8 +672,9 @@ impl Sender { fn queue_zfile(&mut self) -> Result<(), Error> { let file_size = self.file_size; let file_name = &self.file_name; + let file_options = self.file_options; let mut writer = BufferWriter::new(&mut self.outgoing); - if write_zfile(&mut writer, &mut self.buf, file_name, file_size)?.is_none() { + if write_zfile(&mut writer, &mut self.buf, file_name, file_size, file_options)?.is_none() { return Err(Error::OutOfMemory); } Ok(()) @@ -740,6 +741,8 @@ impl Sender { match header.frame() { Frame::ZRINIT => self.on_zrinit(header), Frame::ZRPOS | Frame::ZACK => self.on_zrpos(header.count()), + Frame::ZSKIP => self.on_zskip(), + Frame::ZFERR | Frame::ZABORT | Frame::ZCAN => Err(Error::RemoteAborted), Frame::ZFIN => self.on_zfin(), _ => { if self.state == SendState::WaitReceiverInit { @@ -832,6 +835,27 @@ impl Sender { Ok(()) } + fn on_zskip(&mut self) -> Result<(), Error> { + if matches!( + self.state, + SendState::WaitFilePos + | SendState::NeedFileData + | SendState::WaitFileAck + | SendState::WaitFileDone + ) { + self.pending_request = None; + self.pending_event = Some(SenderEvent::FileComplete); + self.has_file = false; + if self.finish_requested { + self.queue_zfin()?; + self.state = SendState::WaitFinish; + } else { + self.state = SendState::ReadyForFile; + } + } + Ok(()) + } + fn on_zfin(&mut self) -> Result<(), Error> { if self.state == SendState::WaitFinish { self.queue_oo()?; @@ -1419,6 +1443,7 @@ fn write_zfile

( buf: &mut Buffer, name: &[u8], size: u32, + file_options: [u8; 4], ) -> Result, Error> where P: Write + ?Sized, @@ -1430,7 +1455,7 @@ where write!(buf, "{size}\0").map_err(|_| Error::OutOfMemory)?; - if Header::new(Encoding::ZBIN32, Frame::ZFILE, &[0; 4]) + if Header::new(Encoding::ZBIN32, Frame::ZFILE, &file_options) .write(port)? .is_none() { diff --git a/src-tauri/vendor/zmodem2/tests/protocol.rs b/src-tauri/vendor/zmodem2/tests/protocol.rs index 12bd4b25..96502859 100644 --- a/src-tauri/vendor/zmodem2/tests/protocol.rs +++ b/src-tauri/vendor/zmodem2/tests/protocol.rs @@ -4,8 +4,8 @@ use rstest::rstest; use zmodem2::{ - Encoding, Frame, Header, Receiver, ReceiverEvent, Sender, SubpacketType, XON, ZDLE, ZPAD, - Zrinit, + Encoding, Frame, Header, Receiver, ReceiverEvent, Sender, SenderEvent, SubpacketType, XON, + ZDLE, ZPAD, ZfileManagementOption, Zrinit, }; #[rstest] @@ -70,6 +70,35 @@ fn write_zrpos(offset: u32) -> Vec { wire } +fn write_zskip() -> Vec { + let mut wire = Vec::new(); + Header::new(Encoding::ZHEX, Frame::ZSKIP, &[0; 4]) + .write(&mut wire) + .expect("write zskip") + .expect("complete zskip"); + wire +} + +fn write_zabort() -> Vec { + let mut wire = Vec::new(); + Header::new(Encoding::ZHEX, Frame::ZABORT, &[0; 4]) + .write(&mut wire) + .expect("write zabort") + .expect("complete zabort"); + wire +} + +fn zbin32_header_from_wire(wire: &[u8]) -> Header { + assert!( + wire.starts_with(&[ZPAD, ZDLE, Encoding::ZBIN32 as u8]), + "expected ZBIN32 header, got {wire:?}" + ); + let mut port = &wire[2..]; + Header::read(&mut port) + .expect("read header") + .expect("header") +} + fn has_subpacket_kind(wire: &[u8], kind: SubpacketType) -> bool { wire.windows(2).any(|window| window == [ZDLE, kind as u8]) } @@ -106,6 +135,52 @@ fn test_sender_requests_8k_file_chunks_after_large_zrinit() { assert_eq!(request.len, 8 * 1024); } +#[test] +fn test_sender_writes_zfile_management_options() { + let mut sender = Sender::new().unwrap(); + sender.set_file_options(ZfileManagementOption::ZMCLOB); + sender.start_file(b"overwrite.bin", 1).unwrap(); + sender.advance_outgoing(sender.drain_outgoing().len()); + + sender.feed_incoming(&write_zrinit(&[0; 4])).unwrap(); + + let header = zbin32_header_from_wire(sender.drain_outgoing()); + assert_eq!(header.frame(), Frame::ZFILE); + assert_eq!( + header.count().to_le_bytes(), + [1, ZfileManagementOption::ZMCLOB.bits(), 0, 0] + ); +} + +#[test] +fn test_sender_treats_zskip_as_file_complete() { + let mut sender = Sender::new().unwrap(); + sender.set_file_options(ZfileManagementOption::ZMSKNOLOC); + sender.start_file(b"existing.bin", 1).unwrap(); + sender.advance_outgoing(sender.drain_outgoing().len()); + sender.feed_incoming(&write_zrinit(&[0; 4])).unwrap(); + sender.advance_outgoing(sender.drain_outgoing().len()); + + sender.feed_incoming(&write_zskip()).unwrap(); + + assert_eq!(sender.poll_event(), Some(SenderEvent::FileComplete)); + assert!(sender.poll_file().is_none()); +} + +#[test] +fn test_sender_reports_remote_abort() { + let mut sender = Sender::new().unwrap(); + sender.start_file(b"blocked.bin", 1).unwrap(); + sender.advance_outgoing(sender.drain_outgoing().len()); + sender.feed_incoming(&write_zrinit(&[0; 4])).unwrap(); + sender.advance_outgoing(sender.drain_outgoing().len()); + + assert_eq!( + sender.feed_incoming(&write_zabort()), + Err(zmodem2::Error::RemoteAborted) + ); +} + #[test] fn test_sender_uses_streaming_subpackets_until_ack_boundary() { let mut sender = Sender::new().unwrap(); diff --git a/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx b/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx index 6fdd24d2..ebdf89fc 100644 --- a/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx +++ b/src/components/dialog/file-explorer/TransferDuplicateDialog.tsx @@ -100,19 +100,25 @@ export function TransferDuplicateDialog() { onKeyDown={(event) => { if (event.key === "Enter" && !submitting && request) { event.preventDefault(); - void handleChoice("overwrite"); + void handleChoice(request.unverified ? "skip" : "overwrite"); } }} > - {t("fileTransfer.duplicateTitle")} + {request?.unverified + ? t("fileTransfer.duplicateUnverifiedTitle") + : t("fileTransfer.duplicateTitle")} - {t("fileTransfer.duplicateDescription", { - kind: kindLabel, - name: request?.fileName ?? "", - })} + {request?.unverified + ? t("fileTransfer.duplicateUnverifiedDescription", { + name: request?.fileName ?? "", + }) + : t("fileTransfer.duplicateDescription", { + kind: kindLabel, + name: request?.fileName ?? "", + })} diff --git a/src/components/terminal/zmodemTerminalEvents.ts b/src/components/terminal/zmodemTerminalEvents.ts index d126f738..38f5c381 100644 --- a/src/components/terminal/zmodemTerminalEvents.ts +++ b/src/components/terminal/zmodemTerminalEvents.ts @@ -5,6 +5,7 @@ import { invoke } from "@/lib/invoke"; import { completePendingZmodemUpload, failPendingZmodemUpload, + getPendingZmodemUploadConflictMode, getPendingZmodemUploadPaths, hasPendingZmodemUpload, markPendingZmodemUploadActive, @@ -275,6 +276,7 @@ export function createZmodemEventHandler( await invoke("zmodem_accept_upload", { sessionId, filePaths: pendingPaths, + conflictMode: getPendingZmodemUploadConflictMode(sessionId), }); return; } @@ -286,7 +288,7 @@ export function createZmodemEventHandler( // Probe remote directory and delete conflicting files so plain rz // never sees an existing file to prompt about. - const { paths: resolvedPaths } = await probeAndResolveRemoteConflicts( + const { paths: resolvedPaths, conflictMode } = await probeAndResolveRemoteConflicts( sessionId, filePaths, getDuplicateStrategy(), @@ -306,6 +308,7 @@ export function createZmodemEventHandler( await invoke("zmodem_accept_upload", { sessionId, filePaths: resolvedPaths, + conflictMode, }); } else { await invoke("zmodem_cancel", { sessionId }); diff --git a/src/lib/terminalZmodemUpload.ts b/src/lib/terminalZmodemUpload.ts index 0ea5ee79..598033a9 100644 --- a/src/lib/terminalZmodemUpload.ts +++ b/src/lib/terminalZmodemUpload.ts @@ -6,12 +6,15 @@ import { buildTerminalCommandInput, sendSessionInput } from "@/lib/sessionInput" import { showTransferDuplicatePrompt } from "@/lib/transferDuplicatePrompt"; const ZMODEM_UPLOAD_TIMEOUT_MS = 60_000; +const ZMODEM_ACTIVE_STALL_TIMEOUT_MS = 60_000; type PendingUploadPhase = "waiting" | "active"; +export type ZmodemUploadConflictMode = "overwrite" | "skip"; type PendingUpload = { sessionId: string; filePaths: string[]; + conflictMode: ZmodemUploadConflictMode; resolve: () => void; reject: (error: Error) => void; timeoutId: number | null; @@ -44,16 +47,44 @@ export function getPendingZmodemUploadPaths(sessionId: string): string[] | null return pendingUpload.filePaths; } +export function getPendingZmodemUploadConflictMode(sessionId: string): ZmodemUploadConflictMode { + if (!pendingUpload || pendingUpload.sessionId !== sessionId) { + return "overwrite"; + } + return pendingUpload.conflictMode; +} + export function hasPendingZmodemUpload(sessionId: string): boolean { return pendingUpload?.sessionId === sessionId; } +function armActiveUploadTimeout(sessionId: string) { + if (!pendingUpload || pendingUpload.sessionId !== sessionId) { + return; + } + clearWaitingTimeout(); + pendingUpload.timeoutId = window.setTimeout(() => { + if ( + !pendingUpload || + pendingUpload.sessionId !== sessionId || + pendingUpload.phase !== "active" + ) { + return; + } + const { reject } = pendingUpload; + clearPendingUpload(); + void invoke("zmodem_cancel", { sessionId }).catch(() => {}); + reject(new Error("zmodem stalled")); + }, ZMODEM_ACTIVE_STALL_TIMEOUT_MS); +} + export function markPendingZmodemUploadActive(sessionId: string) { if (!pendingUpload || pendingUpload.sessionId !== sessionId) { return; } clearWaitingTimeout(); pendingUpload.phase = "active"; + armActiveUploadTimeout(sessionId); if (preparingUploadToastId !== null) { toast.dismiss(preparingUploadToastId); preparingUploadToastId = null; @@ -96,6 +127,7 @@ function withTimeout(promise: Promise, ms: number, fallback: T): Promise { - if (filePaths.length === 0) return { paths: [], probeSkipped: false }; + if (filePaths.length === 0) { + return { paths: [], probeSkipped: false, conflictMode: "overwrite" }; + } // Resolve remote upload directory (terminal CWD is a shell operation, not SFTP). const cwd = await withTimeout( - invoke("get_terminal_cwd", { sessionId }).catch(() => null), + invoke("try_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("get_home_dir", { sessionId }).catch(() => "/"), - SFTP_PROBE_TIMEOUT_MS, - null as string | null, - ) - : null; - const remoteDir = (cwd || home || "/").replace(/\/+$/, "") || "/"; + if (!cwd) { + return resolveUnverifiedZmodemUpload(sessionId, filePaths, duplicateStrategy); + } + + const remoteDir = cwd.replace(/\/+$/, "") || "/"; // List remote directory (SFTP, time-boxed). const entries = await withTimeout( @@ -146,8 +176,7 @@ export async function probeAndResolveRemoteConflicts( // If the SFTP probe timed out, skip conflict detection entirely. if (entries === null) { - toast.message(i18n.t("zmodem.sftpProbeSkipped")); - return { paths: filePaths, probeSkipped: true }; + return resolveUnverifiedZmodemUpload(sessionId, filePaths, duplicateStrategy); } const existingNames = new Set(entries.map((e) => e.name)); @@ -164,11 +193,13 @@ export async function probeAndResolveRemoteConflicts( } } - if (conflicts.length === 0) return { paths: filePaths, probeSkipped: false }; + if (conflicts.length === 0) { + return { paths: filePaths, probeSkipped: false, conflictMode: "overwrite" }; + } // Auto-resolve based on configured strategy. if (duplicateStrategy === "skip") { - return { paths: clean, probeSkipped: false }; + return { paths: clean, probeSkipped: false, conflictMode: "overwrite" }; } if (duplicateStrategy === "overwrite" || duplicateStrategy === "rename") { @@ -181,7 +212,7 @@ export async function probeAndResolveRemoteConflicts( undefined, ); } - return { paths: filePaths, probeSkipped: false }; + return { paths: filePaths, probeSkipped: false, conflictMode: "overwrite" }; } // "ask" — prompt the user for each conflict. @@ -197,9 +228,10 @@ export async function probeAndResolveRemoteConflicts( remotePath, fileName: name, isDirectory: false, + allowApplyToTask: conflicts.length > 1, }); - if (choice === "overwrite") { + if (choice === "overwrite" || choice === "overwriteAllForTask") { await withTimeout( invoke("delete_remote_file", { sessionId, path: remotePath }).catch(() => {}), SFTP_PROBE_TIMEOUT_MS, @@ -207,10 +239,59 @@ export async function probeAndResolveRemoteConflicts( ); resolved.push(fp); } + if (choice === "overwriteAllForTask") { + const remaining = conflicts.slice(conflicts.indexOf(fp) + 1); + for (const remainingPath of remaining) { + const remainingName = getLocalPathName(remainingPath, "file"); + const remainingRemotePath = + remoteDir === "/" ? `/${remainingName}` : `${remoteDir}/${remainingName}`; + await withTimeout( + invoke("delete_remote_file", { sessionId, path: remainingRemotePath }).catch(() => {}), + SFTP_PROBE_TIMEOUT_MS, + undefined, + ); + resolved.push(remainingPath); + } + break; + } // "skip" → don't add. } - return { paths: resolved, probeSkipped: false }; + return { paths: resolved, probeSkipped: false, conflictMode: "overwrite" }; +} + +async function resolveUnverifiedZmodemUpload( + sessionId: string, + filePaths: string[], + duplicateStrategy: string, +): Promise { + if (duplicateStrategy === "skip") { + return { paths: [], probeSkipped: true, conflictMode: "skip" }; + } + + if (duplicateStrategy === "overwrite" || duplicateStrategy === "rename") { + toast.message(i18n.t("zmodem.sftpProbeUnavailable")); + return { paths: filePaths, probeSkipped: true, conflictMode: "overwrite" }; + } + + const fileName = + filePaths.length === 1 + ? getLocalPathName(filePaths[0] ?? "", "file") + : i18n.t("zmodem.multipleFiles", { count: filePaths.length }); + const choice = await showTransferDuplicatePrompt({ + requestId: crypto.randomUUID(), + sessionId, + remotePath: "", + fileName, + isDirectory: false, + unverified: true, + }); + + if (choice === "overwrite" || choice === "overwriteAllForTask") { + return { paths: filePaths, probeSkipped: true, conflictMode: "overwrite" }; + } + + return { paths: [], probeSkipped: true, conflictMode: "skip" }; } export async function uploadFilesViaZmodem( @@ -249,7 +330,7 @@ export async function uploadFilesViaZmodem( toast.dismiss(preparingUploadToastId); preparingUploadToastId = null; } - throw new Error("All files were skipped or probe failed"); + return; } return new Promise((resolve, reject) => { @@ -265,6 +346,7 @@ export async function uploadFilesViaZmodem( pendingUpload = { sessionId, filePaths: conflict.paths, + conflictMode: conflict.conflictMode, resolve, reject, timeoutId, diff --git a/src/lib/transferDuplicatePrompt.ts b/src/lib/transferDuplicatePrompt.ts index 6747b2bc..7b1ff5ad 100644 --- a/src/lib/transferDuplicatePrompt.ts +++ b/src/lib/transferDuplicatePrompt.ts @@ -10,6 +10,7 @@ export interface TransferDuplicateRequest { targetWindowLabel?: string | null; respondViaBackend?: boolean; allowApplyToTask?: boolean; + unverified?: boolean; } type TransferDuplicateListener = (request: TransferDuplicateRequest | null) => void;