From 2afcd1ea90652adca2f85f3d213f2ab011286eac Mon Sep 17 00:00:00 2001 From: TomZz Date: Fri, 12 Jun 2026 07:52:35 +0800 Subject: [PATCH] feat: Add SFTP directory batch delete and create folder features, fix auto refresh bug --- locales/en.yml | 10 ++ locales/zh-CN.yml | 12 ++ src/main.rs | 285 +++++++++++++++++++++++++++++++++++++++++++++- src/sftp.rs | 142 ++++++++++++++++++++++- src/terminal.rs | 6 + 5 files changed, 451 insertions(+), 4 deletions(-) diff --git a/locales/en.yml b/locales/en.yml index c87ab27..a7cce89 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -13,6 +13,16 @@ size: "SIZE" modified: "MODIFIED" new_ssh_connection: "New SSH Connection" create_or_edit_ssh_session: "Create or edit an SSH session" +new_folder: "New Folder" +create_folder_success: "Created folder: %{name}" +create_folder_failed: "Failed to create folder: %{err}" +delete_selected: "Delete" +confirm_delete: "Confirm Delete" +confirm_delete_desc: "Are you sure you want to delete the selected %{count} items? This action cannot be undone." +system_path_warning: "WARNING: You are attempting to delete critical system paths! This may cause system corruption!" +delete_success: "Successfully deleted %{count} items" +delete_failed: "Failed to delete: %{err}" +deleting_paths: "Deleting %{count} items..." local_terminal: "Local Terminal" open_local_shell_tab: "Open a new local shell tab" connect: "Connect" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 67b355a..8f083f7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -13,6 +13,18 @@ size: "大小" modified: "修改时间" new_ssh_connection: "新建 SSH 连接" create_or_edit_ssh_session: "创建或编辑一个 SSH 会话" +new_folder: "新建文件夹" +create_folder_success: "成功创建文件夹: %{name}" +create_folder_failed: "创建文件夹失败: %{err}" +delete_selected: "删除" +confirm_delete: "确认删除" +confirm_delete_desc: "确定要删除以下选中的 %{count} 个项吗?此操作无法撤销。" +system_path_warning: "警告:您即将删除系统关键路径!这可能会导致系统损坏!" +delete_success: "成功删除 %{count} 个项" +delete_failed: "删除失败: %{err}" +deleting_paths: "正在删除 %{count} 个项..." +cancel: "取消" +confirm: "确认" local_terminal: "本地终端" open_local_shell_tab: "打开一个新的本地终端页面" connect: "连接" diff --git a/src/main.rs b/src/main.rs index abdabe8..18cb1f1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -171,6 +171,9 @@ struct Ashell { connection_progress: Option, pending_sftp_path_sync: Option, sftp_context_menu: Option, + sftp_creating_folder: bool, + sftp_new_folder_input: Entity, + sftp_delete_scroll_handle: gpui::ScrollHandle, show_hidden_files: bool, transfers: Vec, show_transfers_dialog: bool, @@ -235,6 +238,7 @@ impl Ashell { .placeholder("-----BEGIN OPENSSH PRIVATE KEY-----") }); let sftp_path_input = cx.new(|cx| InputState::new(window, cx).default_value("/")); + let sftp_new_folder_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("new_folder").to_string())); let _subscriptions = vec![ cx.subscribe_in(&host_input, window, Self::on_input_event), @@ -245,6 +249,7 @@ impl Ashell { cx.subscribe_in(&key_path_input, window, Self::on_input_event), cx.subscribe_in(&key_inline_input, window, Self::on_input_event), cx.subscribe_in(&sftp_path_input, window, Self::on_input_event), + cx.subscribe_in(&sftp_new_folder_input, window, Self::on_input_event), ]; let (events_tx, events_rx) = mpsc::channel(); @@ -330,6 +335,9 @@ impl Ashell { connection_progress: None, pending_sftp_path_sync: Some("/".into()), sftp_context_menu: None, + sftp_creating_folder: false, + sftp_new_folder_input, + sftp_delete_scroll_handle: gpui::ScrollHandle::new(), show_hidden_files: false, transfers: config.transfers(), show_transfers_dialog: false, @@ -377,6 +385,28 @@ impl Ashell { window.prevent_default(); cx.stop_propagation(); } + } else if input == &self.sftp_new_folder_input { + match event { + InputEvent::PressEnter { .. } => { + let name = self.sftp_new_folder_input.read(cx).text().to_string(); + if !name.is_empty() { + let base_path = self.sftp_path_input.read(cx).text().to_string(); + let path = crate::sftp::join_remote(&base_path, &name); + if let Some(id) = self.active_tab.clone() { + if let Some(handle) = self.sftp_handles.get(&id) { + let _ = handle.commands.send(crate::sftp::SftpCommand::CreateDir(path)); + } + } + } + self.sftp_creating_folder = false; + window.prevent_default(); + cx.stop_propagation(); + } + InputEvent::Blur => { + self.sftp_creating_folder = false; + } + _ => {} + } } cx.notify(); } @@ -517,6 +547,13 @@ impl Ashell { } self.status = reason.into(); } + BackendEvent::SftpHome { tab_id, home } => { + if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { + if let Some(sftp) = tab.sftp.as_mut() { + sftp.home_dir = home; + } + } + } BackendEvent::TerminalTitleChanged { tab_id, title } => { if let Some(tab) = self.tabs.iter_mut().find(|t| t.id == tab_id) { if let Some(sftp) = &tab.sftp { @@ -527,7 +564,14 @@ impl Ashell { }; if potential_path.starts_with('/') || potential_path.starts_with('~') { - let path_to_sync = potential_path.to_string(); + let path_to_sync = if potential_path == "~" { + sftp.home_dir.clone() + } else if let Some(rest) = potential_path.strip_prefix("~/") { + crate::sftp::join_remote(&sftp.home_dir, rest) + } else { + potential_path.to_string() + }; + if path_to_sync != sftp.current_path { if let Some(handle) = self.sftp_handles.get(&tab_id) { handle.list_dir(path_to_sync); @@ -1385,6 +1429,177 @@ impl Ashell { }); } + fn show_delete_confirm_dialog(&mut self, window: &mut Window, cx: &mut Context) { + let view = cx.entity(); + let selected_entries = self.active_sftp().map(|s| s.selected_entries.clone()).unwrap_or_default(); + if selected_entries.is_empty() { + return; + } + + let has_system_path = selected_entries.iter().any(|path| { + let p = path.as_str(); + p.starts_with("/bin/") || p == "/bin" || + p.starts_with("/etc/") || p == "/etc" || + p.starts_with("/usr/") || p == "/usr" || + p.starts_with("/var/") || p == "/var" || + p.starts_with("/sys/") || p == "/sys" || + p.starts_with("/dev/") || p == "/dev" || + p.starts_with("/boot/") || p == "/boot" || + p.starts_with("/lib/") || p == "/lib" || + p.starts_with("/opt/") || p == "/opt" || + p.starts_with("/run/") || p == "/run" || + p.starts_with("/sbin/") || p == "/sbin" + }); + + window.open_dialog(cx, move |dialog: Dialog, _window, _| { + dialog + .title(t!("confirm_delete").to_string()) + .w(px(500.)) + .keyboard(false) + .on_ok({ + let view = view.clone(); + let paths_to_delete: Vec = selected_entries.clone().into_iter().collect(); + move |_, window, cx| { + view.update(cx, |this, cx| { + if let Some(id) = this.active_tab.clone() { + if let Some(handle) = this.sftp_handles.get(&id) { + let _ = handle.commands.send(crate::sftp::SftpCommand::DeletePaths(paths_to_delete.clone())); + } + if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == id) { + if let Some(sftp) = tab.sftp.as_mut() { + sftp.selected_entries.clear(); + } + } + } + cx.notify(); + }); + window.close_dialog(cx); + true + } + }) + .content({ + let view = view.clone(); + move |content, _window, cx| { + let scroll_handle = view.read(cx).sftp_delete_scroll_handle.clone(); + let selected_paths: Vec = view.read(cx) + .active_sftp() + .map(|s| s.selected_entries.clone().into_iter().collect()) + .unwrap_or_default(); + + let warning_block = if has_system_path { + Some( + div() + .w_full() + .p_3() + .mb_3() + .rounded_md() + .bg(gpui::rgba(0xff00001a)) + .border_1() + .border_color(gpui::rgba(0xff000080)) + .child( + div() + .text_color(gpui::rgba(0xff0000ff)) + .font_weight(FontWeight::BOLD) + .child(t!("system_path_warning").to_string()) + ) + ) + } else { + None + }; + + let paths_list = div() + .relative() + .max_h(px(200.)) + .w_full() + .border_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .rounded_md() + .child( + v_flex() + .id("delete-scroll-view") + .size_full() + .track_scroll(&scroll_handle) + .overflow_y_scroll() + .p_2() + .gap_1() + .children(selected_paths.into_iter().map(|path| { + div() + .text_size(rems(0.917)) + .text_color(cx.theme().muted_foreground) + .child(path) + })) + ) + .child( + div() + .absolute() + .top_0() + .bottom_0() + .right_0() + .child( + gpui_component::scroll::Scrollbar::vertical(&scroll_handle) + .scrollbar_show(gpui_component::scroll::ScrollbarShow::Always) + ) + ); + + content.child( + v_flex() + .w_full() + .gap_2() + .children(warning_block) + .child( + div() + .text_size(rems(1.0)) + .mb_2() + .child(t!("confirm_delete_desc", count = view.read(cx).active_sftp().map(|s| s.selected_entries.len()).unwrap_or(0)).to_string()) + ) + .child(paths_list) + ) + } + }) + .footer({ + let view = view.clone(); + let paths_to_delete: Vec = selected_entries.clone().into_iter().collect(); + h_flex() + .w_full() + .justify_end() + .gap_2() + .child( + Button::new("cancel") + .ghost() + .label(t!("cancel").to_string()) + .on_click(move |_, window, cx| { + window.close_dialog(cx); + }), + ) + .child( + Button::new("confirm") + .danger() + .label(t!("confirm").to_string()) + .on_click({ + let view = view.clone(); + move |_, window, cx| { + view.update(cx, |this, cx| { + if let Some(id) = this.active_tab.clone() { + if let Some(handle) = this.sftp_handles.get(&id) { + let _ = handle.commands.send(crate::sftp::SftpCommand::DeletePaths(paths_to_delete.clone())); + } + if let Some(tab) = this.tabs.iter_mut().find(|t| t.id == id) { + if let Some(sftp) = tab.sftp.as_mut() { + sftp.selected_entries.clear(); + } + } + } + cx.notify(); + }); + window.close_dialog(cx); + } + }), + ) + }) + }); + } + fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context) { let view = cx.entity(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { @@ -3254,6 +3469,36 @@ impl Ashell { .label(t!("refresh").to_string()) .on_click(cx.listener(|this, _, _, cx| this.refresh_sftp(cx))), ) + .child( + Button::new("sftp-new-folder") + .ghost() + .small() + .icon(IconName::Folder) + .label(t!("new_folder").to_string()) + .on_click(cx.listener(|this, _, window, cx| { + this.sftp_creating_folder = true; + this.sftp_new_folder_input.update(cx, |input, cx| { + input.set_value("", window, cx); + input.focus_handle(cx).focus(window, cx); + }); + cx.notify(); + })), + ) + .child( + Button::new("sftp-delete-selected") + .ghost() + .small() + .icon(IconName::Close) + .label(if selected_entries.is_empty() { + t!("delete_selected").to_string() + } else { + format!("{} ({})", t!("delete_selected").to_string(), selected_entries.len()) + }) + .disabled(selected_entries.is_empty()) + .on_click(cx.listener(|this, _, window, cx| { + this.show_delete_confirm_dialog(window, cx); + })), + ) .child( Button::new("sftp-upload-file") .ghost() @@ -3454,14 +3699,48 @@ impl Ashell { .w_full() .relative() .child( - uniform_list("remote-files-list", entries.len(), { + uniform_list("remote-files-list", entries.len() + if self.sftp_creating_folder { 1 } else { 0 }, { let entries = entries.clone(); let selected_path = selected_path.clone(); let view = view.clone(); + let creating_folder = self.sftp_creating_folder; + let new_folder_input = self.sftp_new_folder_input.clone(); move |visible_range, window, cx| { visible_range .map(|ix| { - let entry = entries[ix].clone(); + if creating_folder && ix == 0 { + return h_flex() + .w_full() + .h(px(28.)) + .items_center() + .gap_2() + .px_3() + .bg(cx.theme().tab_active) + .border_b_1() + .border_color(cx.theme().border.opacity(0.35)) + .child( + h_flex() + .w(px(24.)) + .flex_none() + .items_center() + .justify_center(), + ) + .child( + div() + .w(px(24.)) + .flex_none() + .text_size(rems(1.0)) + .child("📁"), + ) + .child( + gpui_component::input::Input::new(&new_folder_input) + .flex_1() + ) + .into_any_element(); + } + + let actual_ix = if creating_folder { ix - 1 } else { ix }; + let entry = entries[actual_ix].clone(); let left_row = entry.clone(); let right_row = entry.clone(); let remote_path = entry.full_path.clone(); diff --git a/src/sftp.rs b/src/sftp.rs index aa9a566..64d889a 100644 --- a/src/sftp.rs +++ b/src/sftp.rs @@ -61,6 +61,8 @@ pub enum SftpCommand { EditFile { remote_path: String, }, + CreateDir(String), + DeletePaths(Vec), UploadEditedFile { local_path: String, remote_path: String, @@ -254,6 +256,12 @@ async fn run_sftp( .canonicalize(".") .await .unwrap_or_else(|_| "/".to_string()); + + let _ = events.send(BackendEvent::SftpHome { + tab_id: tab_id.clone(), + home: home.clone(), + }); + emit_entries(&events, &tab_id, &sftp, &home).await?; let mut active_transfers: std::collections::HashMap = @@ -624,6 +632,85 @@ async fn run_sftp( } }); } + SftpCommand::CreateDir(path) => { + let actual_path = if path == "~" { + home.clone() + } else if let Some(rest) = path.strip_prefix("~/") { + crate::sftp::join_remote(&home, rest) + } else { + path.clone() + }; + + match sftp.create_dir(&actual_path).await { + Ok(_) => { + let _ = events.send(BackendEvent::SftpStatus { + tab_id: tab_id.clone(), + text: t!("create_folder_success", name = base_name(&actual_path)).to_string(), + }); + + // Re-fetch the parent directory to show the newly created folder + if let Some(parent) = parent_dir(&actual_path) { + let _ = commands_tx.send(SftpCommand::ListDir(parent)); + } else { + let _ = commands_tx.send(SftpCommand::ListDir("/".to_string())); + } + } + Err(err) => { + let _ = events.send(BackendEvent::SftpStatus { + tab_id: tab_id.clone(), + text: t!("create_folder_failed", err = format!("{err:#}")).to_string(), + }); + } + } + } + SftpCommand::DeletePaths(paths) => { + let _ = events.send(BackendEvent::SftpStatus { + tab_id: tab_id.clone(), + text: t!("deleting_paths", count = paths.len()).to_string(), + }); + + let mut errors = Vec::new(); + for path in paths.clone() { + let actual_path = if path == "~" { + home.clone() + } else if let Some(rest) = path.strip_prefix("~/") { + crate::sftp::join_remote(&home, rest) + } else { + path.clone() + }; + + if let Err(e) = recursive_delete(&sftp, actual_path).await { + errors.push(format!("{path}: {e:#}")); + } + } + + if errors.is_empty() { + let _ = events.send(BackendEvent::SftpStatus { + tab_id: tab_id.clone(), + text: t!("delete_success", count = paths.len()).to_string(), + }); + } else { + let _ = events.send(BackendEvent::SftpStatus { + tab_id: tab_id.clone(), + text: t!("delete_failed", err = errors.join(", ")).to_string(), + }); + } + + if let Some(first) = paths.first() { + let actual_path = if first == "~" { + home.clone() + } else if let Some(rest) = first.strip_prefix("~/") { + crate::sftp::join_remote(&home, rest) + } else { + first.clone() + }; + if let Some(parent) = parent_dir(&actual_path) { + let _ = commands_tx.send(SftpCommand::ListDir(parent)); + } else { + let _ = commands_tx.send(SftpCommand::ListDir("/".to_string())); + } + } + } } } @@ -633,6 +720,43 @@ async fn run_sftp( Ok(()) } +use std::future::Future; +use std::pin::Pin; + +fn recursive_delete<'a>( + sftp: &'a SftpSession, + path: String, +) -> Pin> + Send + 'a>> { + Box::pin(async move { + match sftp.read_dir(&path).await { + Ok(entries) => { + for entry in entries { + let name = entry.file_name(); + if name == "." || name == ".." { + continue; + } + let child_path = crate::sftp::join_remote(&path, &name); + + let meta = entry.metadata(); + let permissions = meta.permissions.unwrap_or(0); + let is_dir = (permissions & 0o170_000) == 0o040_000; + + if is_dir { + recursive_delete(sftp, child_path).await?; + } else { + sftp.remove_file(&child_path).await.with_context(|| format!("Failed to delete file {child_path}"))?; + } + } + sftp.remove_dir(&path).await.with_context(|| format!("Failed to delete dir {path}"))?; + } + Err(_) => { + sftp.remove_file(&path).await.with_context(|| format!("Failed to delete {path}"))?; + } + } + Ok(()) + }) +} + async fn emit_entries( events: &std::sync::mpsc::Sender, tab_id: &str, @@ -773,7 +897,23 @@ fn base_name(path: &str) -> String { .to_string() } -fn join_remote(parent: &str, child: &str) -> String { +pub(crate) fn parent_dir(path: &str) -> Option { + if path == "/" || path.is_empty() { + return None; + } + let trimmed = path.trim_end_matches('/'); + if let Some(idx) = trimmed.rfind('/') { + if idx == 0 { + Some("/".to_string()) + } else { + Some(trimmed[..idx].to_string()) + } + } else { + Some("/".to_string()) + } +} + +pub(crate) fn join_remote(parent: &str, child: &str) -> String { if parent == "/" { format!("/{child}") } else { diff --git a/src/terminal.rs b/src/terminal.rs index c4493f5..64e37a4 100644 --- a/src/terminal.rs +++ b/src/terminal.rs @@ -61,6 +61,10 @@ pub enum BackendEvent { tab_id: String, reason: String, }, + SftpHome { + tab_id: String, + home: String, + }, TransferProgress { #[allow(dead_code)] tab_id: String, @@ -159,6 +163,7 @@ pub struct SftpUiState { pub selected_path: Option, pub preview: Option, pub selected_entries: std::collections::HashSet, + pub home_dir: String, } impl TerminalTab { @@ -204,6 +209,7 @@ impl TerminalTab { selected_path: None, preview: None, selected_entries: std::collections::HashSet::new(), + home_dir: "/".into(), }); tab }