From 4c7c06f3914ea9629bda2badc29c22b51987c8b1 Mon Sep 17 00:00:00 2001 From: TomZz Date: Tue, 16 Jun 2026 01:04:35 +0800 Subject: [PATCH] fix: fix interrupted message position, handle zombie transfers on restart, fix upload status text --- locales/en.yml | 5 ++- locales/zh-CN.yml | 3 ++ src/app/dialogs.rs | 83 +++++++++++++++++++++++++++++++++++----- src/app/mod.rs | 18 ++++++++- src/sftp/mod.rs | 92 +++++++++++++++++++++++++++++++++++++++------ src/terminal/mod.rs | 3 +- 6 files changed, 180 insertions(+), 24 deletions(-) diff --git a/locales/en.yml b/locales/en.yml index e5aec61..751bf4b 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -103,7 +103,10 @@ paused: "Paused" completed: "Completed" failed: "Failed" cancelled: "Cancelled" +interrupted: "Interrupted" session: "Session" +zombie: "Zombie" +zombie_reason: "This transfer was interrupted because the application exited. The transfer task no longer exists." software_builtin: "Built-in" retry: "Retry" @@ -140,4 +143,4 @@ settings_split_pane_right: "Split Pane Right" settings_split_pane_up: "Split Pane Up" settings_split_pane_down: "Split Pane Down" settings_close_pane: "Close Pane" -keybind_conflict: "\"%{key}\" is already used by \"%{action}\", please choose another key" +keybind_conflict: '"%{key}" is already used by "%{action}", please choose another key' diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 09a2336..b69dc3b 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -105,7 +105,10 @@ paused: "已暂停" completed: "已完成" failed: "失败" cancelled: "已取消" +interrupted: "已中断" session: "会话" +zombie: "僵尸任务" +zombie_reason: "此传输任务因程序退出而中断,传输进程已不存在。" software_builtin: "软件内置" retry: "重试" diff --git a/src/app/dialogs.rs b/src/app/dialogs.rs index 9aa6cf6..80596b5 100644 --- a/src/app/dialogs.rs +++ b/src/app/dialogs.rs @@ -516,7 +516,10 @@ impl Ashell { format_bytes(tot) ) } else { - format!("{}...", t!("downloading")) + match t.info.kind { + crate::terminal::TransferType::Upload => format!("{}...", t!("uploading")), + crate::terminal::TransferType::Download => format!("{}...", t!("downloading")), + } }; let btn_pause = Button::new(SharedString::from(format!("pause-{}", t.info.id))) @@ -582,6 +585,23 @@ impl Ashell { })); (txt, h_flex().gap_1().child(btn_resume).child(btn_cancel)) } + crate::terminal::TransferState::Interrupted(ref reason) => { + let txt = format!("{}: {}", t!("interrupted"), reason); + let btn_remove = Button::new(SharedString::from(format!( + "remove-{}", + t.info.id + ))) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + (txt, h_flex().gap_1().child(btn_remove)) + } crate::terminal::TransferState::Completed => { let txt = t!("completed").to_string(); let mut actions = h_flex().gap_1(); @@ -603,13 +623,55 @@ impl Ashell { }); actions = actions.child(btn_folder); } + let btn_remove = Button::new(SharedString::from(format!( + "remove-{}", + t.info.id + ))) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + actions = actions.child(btn_remove); (txt, actions) } crate::terminal::TransferState::Failed(ref err) => { - (format!("{}: {}", t!("failed"), err), h_flex().gap_1()) + let txt = format!("{}: {}", t!("failed"), err); + let btn_remove = Button::new(SharedString::from(format!( + "remove-{}", + t.info.id + ))) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + (txt, h_flex().gap_1().child(btn_remove)) } - crate::terminal::TransferState::Cancelled => { - (t!("cancelled").to_string(), h_flex().gap_1()) + crate::terminal::TransferState::Zombie(ref reason) => { + let txt = format!("{}: {}", t!("zombie"), reason); + let btn_remove = Button::new(SharedString::from(format!( + "remove-{}", + t.info.id + ))) + .ghost() + .small() + .icon(IconName::Close) + .on_click(window.listener_for(&view, { + let id = t.info.id.clone(); + move |this, _, _, cx| { + this.remove_transfer(&id, cx); + } + })); + (txt, h_flex().gap_1().child(btn_remove)) } }; @@ -665,14 +727,14 @@ impl Ashell { t!("session"), t.tab_title )), + ) + .child( + div() + .text_size(px(11.)) + .text_color(cx.theme().muted_foreground) + .child(status_text.clone()), ), ) - .child( - div() - .text_size(px(11.)) - .text_color(cx.theme().muted_foreground) - .child(status_text), - ) .child(actions), ) .when( @@ -691,6 +753,7 @@ impl Ashell { ) }, ) + })); let scroll_handle = window diff --git a/src/app/mod.rs b/src/app/mod.rs index 485b087..c047ac8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -440,7 +440,15 @@ impl Ashell { sftp_new_folder_input, sftp_delete_scroll_handle: gpui::ScrollHandle::new(), show_hidden_files: config.show_hidden_files(), - transfers: config.transfers(), + transfers: { + let mut transfers = config.transfers(); + for t in transfers.iter_mut() { + if matches!(t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused) { + t.state = crate::terminal::TransferState::Zombie(t!("zombie_reason").to_string()); + } + } + transfers + }, show_transfers_dialog: false, system_status: None, terminal_bounds: HashMap::new(), @@ -854,4 +862,12 @@ impl Ashell { size(px(cell_width), px(line_height)), )) } + + pub(crate) fn remove_transfer(&mut self, transfer_id: &str, cx: &mut Context) { + self.transfers.retain(|t| t.info.id != transfer_id); + self.config.set_transfers(self.transfers.clone()); + cx.notify(); + } + + } diff --git a/src/sftp/mod.rs b/src/sftp/mod.rs index f93f3a8..cb72d5c 100644 --- a/src/sftp/mod.rs +++ b/src/sftp/mod.rs @@ -379,16 +379,26 @@ async fn run_sftp( } Err(err) => { let err_msg = format!("{err:#}"); + let is_cancelled = err_msg.contains("transfer cancelled"); + let state = if is_cancelled { + crate::terminal::TransferState::Interrupted("User cancelled".to_string()) + } else { + crate::terminal::TransferState::Failed(err_msg.clone()) + }; let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone.clone(), - text: t!("download_failed", err = err_msg.clone()).to_string(), + text: if is_cancelled { + "Transmission cancelled".to_string() + } else { + t!("download_failed", err = err_msg.clone()).to_string() + }, }); let _ = events_clone.send(BackendEvent::TransferProgress { tab_id: tab_id_clone, id: id.clone(), transferred: 0, total: None, - state: crate::terminal::TransferState::Failed(err_msg), + state, }); } } @@ -480,16 +490,26 @@ async fn run_sftp( } Err(err) => { let err_msg = format!("{err:#}"); + let is_cancelled = err_msg.contains("transfer cancelled"); + let state = if is_cancelled { + crate::terminal::TransferState::Interrupted("User cancelled".to_string()) + } else { + crate::terminal::TransferState::Failed(err_msg.clone()) + }; let _ = events_clone.send(BackendEvent::SftpStatus { tab_id: tab_id_clone.clone(), - text: t!("upload_failed", err = err_msg.clone()).to_string(), + text: if is_cancelled { + "Transmission cancelled".to_string() + } else { + t!("upload_failed", err = err_msg.clone()).to_string() + }, }); let _ = events_clone.send(BackendEvent::TransferProgress { tab_id: tab_id_clone, id: id.clone(), transferred: 0, total: None, - state: crate::terminal::TransferState::Failed(err_msg), + state, }); } } @@ -1123,6 +1143,12 @@ async fn download_path_impl( .await .with_context(|| format!("create {}", local_dir.display()))?; + // Check for cancellation after initial setup + let state = flag.0.load(Ordering::SeqCst); + if state == 2 { + return Err(anyhow::anyhow!("transfer cancelled")); + } + let metadata = sftp .metadata(remote) .await @@ -1216,7 +1242,15 @@ async fn download_remote_directory_archive( base_name(remote_dir), Uuid::new_v4() ); + + // Check for cancellation before creating remote archive + let state = flag.0.load(Ordering::SeqCst); + if state == 2 { + return Err(anyhow::anyhow!("transfer cancelled")); + } + create_remote_archive(handle, remote_dir, &remote_archive).await?; + let local_extract_root = local_archive .parent() .unwrap_or_else(|| Path::new(".")) @@ -1322,6 +1356,12 @@ async fn upload_paths_impl( tab_id: &str, id: &str, ) -> Result { + // Check for cancellation before starting + let state = flag.0.load(Ordering::SeqCst); + if state == 2 { + return Err(anyhow::anyhow!("transfer cancelled")); + } + create_remote_dir_all(sftp, remote_dir).await?; let mut file_count = 0usize; let mut folder_count = 0usize; @@ -1374,8 +1414,19 @@ async fn upload_paths_impl( } } + // Check for cancellation before creating directories + let state = flag.0.load(Ordering::SeqCst); + if state == 2 { + return Err(anyhow::anyhow!("transfer cancelled")); + } + // Create directories sequentially first for dir in dirs_to_create { + // Check for cancellation between each directory creation + let state = flag.0.load(Ordering::SeqCst); + if state == 2 { + return Err(anyhow::anyhow!("transfer cancelled")); + } create_remote_dir_all(sftp, &dir).await?; } @@ -1543,14 +1594,31 @@ async fn exec_remote_command( let mut stderr = Vec::new(); let mut stdout = Vec::new(); let mut exit_status = None; - while let Some(msg) = channel.wait().await { - match msg { - russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data), - russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data), - russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code), - russh::ChannelMsg::Close => break, - _ => {} + + // Add timeout to prevent indefinite blocking (300 seconds = 5 minutes) + let timeout = tokio::time::Duration::from_secs(300); + let result = tokio::time::timeout(timeout, async { + loop { + // Yield to allow cancellation + tokio::task::yield_now().await; + + if let Some(msg) = channel.wait().await { + match msg { + russh::ChannelMsg::Data { data } => stdout.extend_from_slice(&data), + russh::ChannelMsg::ExtendedData { data, .. } => stderr.extend_from_slice(&data), + russh::ChannelMsg::ExitStatus { exit_status: code } => exit_status = Some(code), + russh::ChannelMsg::Close => break, + _ => {} + } + } else { + break; + } } + }) + .await; + + if result.is_err() { + return Err(anyhow!("remote command timeout: {command}")); } match exit_status.unwrap_or(0) { @@ -1714,6 +1782,8 @@ async fn extract_archive_to(path: &Path, target_dir: &Path) -> Result<()> { Ok(()) } + + #[derive(Clone)] struct SftpClientHandler; diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 4889f1f..abdcf65 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -684,7 +684,8 @@ pub enum TransferState { Paused, Completed, Failed(String), - Cancelled, + Interrupted(String), // 中断传输:包含原因(例如 "User cancelled", "Network timeout") + Zombie(String), // 程序重启后残留的 Running/Paused 任务 } #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]