From b1eb8dc5e93b49b9b20aea13102c48ee5ab605eb Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 13:24:47 +0800 Subject: [PATCH 01/12] feat(sftp): show cancel btn in delete dialog --- crates/gpui_sftp/src/view/mod.rs | 3 ++- termua/src/window/settings/view.rs | 34 +++++++++++++----------------- 2 files changed, 17 insertions(+), 20 deletions(-) diff --git a/crates/gpui_sftp/src/view/mod.rs b/crates/gpui_sftp/src/view/mod.rs index 51bceae..bcb406c 100644 --- a/crates/gpui_sftp/src/view/mod.rs +++ b/crates/gpui_sftp/src/view/mod.rs @@ -546,7 +546,8 @@ impl SftpView { DialogButtonProps::default() .ok_text("Delete") .ok_variant(ButtonVariant::Danger) - .cancel_text("Cancel"), + .cancel_text("Cancel") + .show_cancel(true), ) .on_ok(move |_e, window, cx| { table.update(cx, |state, cx| { diff --git a/termua/src/window/settings/view.rs b/termua/src/window/settings/view.rs index a1a7f15..32a8121 100644 --- a/termua/src/window/settings/view.rs +++ b/termua/src/window/settings/view.rs @@ -8,6 +8,7 @@ use gpui::{ use gpui_component::{ ActiveTheme, Disableable, IconName, Sizable, StyledExt, TitleBar, WindowExt, button::{Button, ButtonVariants}, + dialog::{DialogAction, DialogClose, DialogFooter}, h_flex, input::Input, menu::{DropdownMenu, PopupMenuItem}, @@ -1294,10 +1295,8 @@ impl SettingsWindow { ), ) .footer( - h_flex() - .justify_end() - .gap_2() - .child( + DialogFooter::new() + .child(DialogClose::new().child( Button::new( "termua-settings-assistant-disable-dialog-cancel", ) @@ -1311,12 +1310,9 @@ impl SettingsWindow { .debug_selector(|| { "termua-settings-assistant-disable-dialog-cancel" .to_string() - }) - .on_click(|_, window, cx| { - window.close_dialog(cx); }), - ) - .child( + )) + .child(DialogAction::new().child( Button::new( "termua-settings-assistant-disable-dialog-stop", ) @@ -1331,18 +1327,18 @@ impl SettingsWindow { .debug_selector(|| { "termua-settings-assistant-disable-dialog-stop" .to_string() - }) - .on_click({ - let settings_entity = settings_entity.clone(); - move |_, window, cx| { - settings_entity.update(cx, |this, cx| { - this.shutdown_zeroclaw(window, cx); - }); - window.close_dialog(cx); - } }), - ), + )), ) + .on_ok({ + let settings_entity = settings_entity.clone(); + move |_, window, cx| { + settings_entity.update(cx, |this, cx| { + this.shutdown_zeroclaw(window, cx); + }); + true + } + }) }, window, cx, From 01c668222572f577fd573bf75b7c447be2d18bb0 Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 13:37:08 +0800 Subject: [PATCH 02/12] feat(sftp): support i8n --- Cargo.lock | 1 + crates/gpui_sftp/Cargo.toml | 1 + crates/gpui_sftp/src/lib.rs | 2 + crates/gpui_sftp/src/view/mod.rs | 25 +-- crates/gpui_sftp/src/view/preview.rs | 15 +- crates/gpui_sftp/src/view/render.rs | 41 +++-- crates/gpui_sftp/src/view/table/actions.rs | 167 ++++++++++++++---- crates/gpui_sftp/src/view/table/delegate.rs | 13 +- crates/gpui_sftp/src/view/table/navigation.rs | 21 ++- crates/gpui_sftp/src/view/tests.rs | 16 ++ locales/en.yml | 68 +++++++ locales/zh-CN.yml | 68 +++++++ termua/src/window/new_session/tests.rs | 3 +- 13 files changed, 363 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 84e8e6e..facb0f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3156,6 +3156,7 @@ dependencies = [ "gpui_transfer", "home", "log", + "rust-i18n", "smol", "time", "wezterm-ssh", diff --git a/crates/gpui_sftp/Cargo.toml b/crates/gpui_sftp/Cargo.toml index 88668e2..a8d77a7 100644 --- a/crates/gpui_sftp/Cargo.toml +++ b/crates/gpui_sftp/Cargo.toml @@ -18,6 +18,7 @@ gpui_common.workspace = true gpui_transfer = { path = "../gpui_transfer" } home.workspace = true log.workspace = true +rust-i18n.workspace = true smol.workspace = true time.workspace = true diff --git a/crates/gpui_sftp/src/lib.rs b/crates/gpui_sftp/src/lib.rs index a7b0920..111d69d 100644 --- a/crates/gpui_sftp/src/lib.rs +++ b/crates/gpui_sftp/src/lib.rs @@ -1,5 +1,7 @@ use gpui::actions; +rust_i18n::i18n!("../../locales"); + mod preview; mod state; mod view; diff --git a/crates/gpui_sftp/src/view/mod.rs b/crates/gpui_sftp/src/view/mod.rs index bcb406c..8f04b71 100644 --- a/crates/gpui_sftp/src/view/mod.rs +++ b/crates/gpui_sftp/src/view/mod.rs @@ -33,6 +33,7 @@ use gpui_transfer::{ TransferTask, }; use log::warn; +use rust_i18n::t; use smol::{ Timer, io::{AsyncReadExt, AsyncWriteExt}, @@ -113,8 +114,10 @@ enum ContextMenuTarget { fn delete_selected_item_title(name: Option<&str>) -> String { match name { - Some(name) if !name.trim().is_empty() => format!("Delete \"{name}\"?"), - _ => "Delete selected item?".to_string(), + Some(name) if !name.trim().is_empty() => { + t!("Sftp.Dialog.DeleteNamedItem", name = name).to_string() + } + _ => t!("Sftp.Dialog.DeleteSelectedItem").to_string(), } } @@ -158,15 +161,17 @@ fn sftp_context_menu(target: ContextMenuTarget) -> Vec { fn sftp_table_columns() -> Vec { // Keys are stable identifiers used by Table; keep them short and ASCII. vec![ - Column::new("name", "Name").width(px(360.0)).resizable(true), - Column::new("size", "Size") + Column::new("name", t!("Sftp.Table.Name").to_string()) + .width(px(360.0)) + .resizable(true), + Column::new("size", t!("Sftp.Table.Size").to_string()) .width(px(120.0)) .text_right() .resizable(true), - Column::new("modified", "Modified") + Column::new("modified", t!("Sftp.Table.Modified").to_string()) .width(px(180.0)) .resizable(true), - Column::new("perms", "Perms") + Column::new("perms", t!("Sftp.Table.Perms").to_string()) .width(px(120.0)) .resizable(true), ] @@ -284,7 +289,7 @@ impl SftpView { .col_selectable(false) .col_movable(false) }); - let path_input = new_input(window, cx, "Path"); + let path_input = new_input(window, cx, t!("Sftp.Placeholder.Path").to_string()); // Bootstrap the root listing. table.update(cx, |state, cx| { @@ -537,16 +542,16 @@ impl SftpView { let title = if targets_count <= 1 { delete_selected_item_title(selected_name) } else { - format!("Delete {targets_count} items?") + t!("Sftp.Dialog.DeleteItems", count = targets_count).to_string() }; dialog .title(title) .confirm() .button_props( DialogButtonProps::default() - .ok_text("Delete") + .ok_text(t!("Sftp.Dialog.DeleteOk").to_string()) .ok_variant(ButtonVariant::Danger) - .cancel_text("Cancel") + .cancel_text(t!("Sftp.Dialog.Cancel").to_string()) .show_cancel(true), ) .on_ok(move |_e, window, cx| { diff --git a/crates/gpui_sftp/src/view/preview.rs b/crates/gpui_sftp/src/view/preview.rs index 6240c5e..794015a 100644 --- a/crates/gpui_sftp/src/view/preview.rs +++ b/crates/gpui_sftp/src/view/preview.rs @@ -1,5 +1,6 @@ use super::{format::fenced_text_as_markdown, *}; use crate::preview::{PreviewGate, PreviewKind, gate_preview, read_bytes_with_limit}; +use rust_i18n::t; #[derive(Clone, Debug)] pub(super) struct PreviewTarget { @@ -62,14 +63,22 @@ impl SftpView { let mut remote_f = match sftp.open(&target.id).await { Ok(f) => f, Err(err) => { - return PreviewOutcome::Error(format!("Open failed: {err}").into()); + return PreviewOutcome::Error( + t!("Sftp.Preview.OpenFailed", err = err.to_string()) + .to_string() + .into(), + ); } }; let (bytes, truncated) = match read_bytes_with_limit(&mut remote_f, limit_bytes).await { Ok(res) => res, Err(err) => { - return PreviewOutcome::Error(format!("Read failed: {err}").into()); + return PreviewOutcome::Error( + t!("Sftp.Preview.ReadFailed", err = err.to_string()) + .to_string() + .into(), + ); } }; @@ -129,7 +138,7 @@ impl SftpView { cx.spawn(async move |this, cx| { let outcome = match sftp { Some(sftp) => load_preview_outcome(sftp, &target, kind, limit_bytes).await, - None => PreviewOutcome::Error("Disconnected".into()), + None => PreviewOutcome::Error(t!("Sftp.Toast.Disconnected").to_string().into()), }; let _ = this.update(cx, |this, cx| { diff --git a/crates/gpui_sftp/src/view/render.rs b/crates/gpui_sftp/src/view/render.rs index afc861a..ae8bc2d 100644 --- a/crates/gpui_sftp/src/view/render.rs +++ b/crates/gpui_sftp/src/view/render.rs @@ -1,5 +1,6 @@ use gpui::{AnyElement, Hsla, Pixels}; use gpui_common::TermuaIcon; +use rust_i18n::t; use super::{format::human_bytes, *}; @@ -235,9 +236,15 @@ impl SftpView { // Render our own icon in the label so it can be bigger. PopupMenuItem::element(move |_window, _cx| { let (label, icon) = if show_hidden { - ("Hide Hidden Files", IconName::EyeOff) + ( + t!("Sftp.Context.HideHiddenFiles").to_string(), + IconName::EyeOff, + ) } else { - ("Show Hidden Files", IconName::Eye) + ( + t!("Sftp.Context.ShowHiddenFiles").to_string(), + IconName::Eye, + ) }; div() @@ -344,32 +351,32 @@ impl SftpView { ContextMenu::Separator => menu.separator(), ContextMenu::Action(action) => match action { ContextMenuAction::Refresh => menu.menu_with_icon( - "Refresh", + t!("Sftp.Context.Refresh").to_string(), Icon::default().path(TermuaIcon::Refresh), Box::new(Refresh), ), ContextMenuAction::Upload => menu.menu_with_icon( - "Upload", + t!("Sftp.Context.Upload").to_string(), Icon::default().path(TermuaIcon::Upload), Box::new(Upload), ), ContextMenuAction::Download => menu.menu_with_icon( - "Download", + t!("Sftp.Context.Download").to_string(), Icon::default().path(TermuaIcon::Download), Box::new(Download), ), ContextMenuAction::NewFolder => menu.menu_with_icon( - "New Folder", + t!("Sftp.Context.NewFolder").to_string(), Icon::default().path(TermuaIcon::FolderPlus), Box::new(NewFolder), ), ContextMenuAction::Rename => menu.menu_with_icon( - "Rename", + t!("Sftp.Context.Rename").to_string(), Icon::default().path(TermuaIcon::SquarePen), Box::new(Rename), ), ContextMenuAction::Delete => menu.menu_with_icon( - "Delete", + t!("Sftp.Context.Delete").to_string(), Icon::default().path(TermuaIcon::Trash), Box::new(Delete), ), @@ -398,7 +405,7 @@ impl SftpView { .target .as_ref() .map(|t| (SharedString::from(t.name.clone()), t.size)) - .unwrap_or_else(|| ("Preview".into(), None)); + .unwrap_or_else(|| (t!("Sftp.Preview.Title").to_string().into(), None)); let preview_body = self.render_preview_body(preview_w, window, cx); let muted_fg = cx.theme().muted_foreground.opacity(0.85); @@ -482,7 +489,7 @@ impl SftpView { .gap(px(8.0)) .text_color(muted_fg) .child(Icon::new(IconName::Search).size_6()) - .child("Select a file to preview") + .child(t!("Sftp.Preview.SelectFile").to_string()) .into_any_element(), PreviewContent::Loading => div() .size_full() @@ -493,7 +500,7 @@ impl SftpView { .gap(px(8.0)) .text_color(muted_fg) .child(Icon::new(IconName::LoaderCircle).size_6()) - .child("Loading preview...") + .child(t!("Sftp.Preview.Loading").to_string()) .into_any_element(), PreviewContent::Binary => div() .size_full() @@ -504,7 +511,7 @@ impl SftpView { .gap(px(8.0)) .text_color(muted_fg) .child(Icon::new(IconName::TriangleAlert).size_6()) - .child("Binary file (preview unsupported)") + .child(t!("Sftp.Preview.BinaryUnsupported").to_string()) .into_any_element(), PreviewContent::Error { message } => div() .size_full() @@ -599,8 +606,8 @@ impl SftpView { let y = px(140.0); let title = match op.kind { - SftpOpKind::NewFolder { .. } => "New Folder", - SftpOpKind::Rename { .. } => "Rename", + SftpOpKind::NewFolder { .. } => t!("Sftp.Dialog.NewFolder").to_string(), + SftpOpKind::Rename { .. } => t!("Sftp.Dialog.Rename").to_string(), }; div() @@ -649,7 +656,9 @@ impl SftpView { cx.stop_propagation(); }), ) - .child(div().text_xs().child("Cancel")), + .child( + div().text_xs().child(t!("Sftp.Dialog.Cancel").to_string()), + ), ) .child( div() @@ -665,7 +674,7 @@ impl SftpView { cx.stop_propagation(); }), ) - .child(div().text_xs().child("OK")), + .child(div().text_xs().child(t!("Sftp.Dialog.OK").to_string())), ), ), ) diff --git a/crates/gpui_sftp/src/view/table/actions.rs b/crates/gpui_sftp/src/view/table/actions.rs index 1a9eeac..1fec996 100644 --- a/crates/gpui_sftp/src/view/table/actions.rs +++ b/crates/gpui_sftp/src/view/table/actions.rs @@ -1,4 +1,5 @@ use super::*; +use rust_i18n::t; #[derive(Clone, Debug)] struct PlannedUpload { @@ -107,7 +108,12 @@ async fn upload_copy_loop( Ok(0) => break, Ok(n) => n, Err(err) => { - upload_send_failed(tx, epoch, format!("Read local file failed: {err}")).await; + upload_send_failed( + tx, + epoch, + t!("Sftp.Transfer.ReadLocalFileFailed", err = err.to_string()).to_string(), + ) + .await; return UploadOutcome::Failed; } }; @@ -118,7 +124,12 @@ async fn upload_copy_loop( } if let Err(err) = remote_f.write_all(&buf[..n]).await { - upload_send_failed(tx, epoch, format!("Write remote file failed: {err}")).await; + upload_send_failed( + tx, + epoch, + t!("Sftp.Transfer.WriteRemoteFileFailed", err = err.to_string()).to_string(), + ) + .await; return UploadOutcome::Failed; } @@ -163,7 +174,12 @@ async fn run_upload_worker( let mut local_f = match smol::fs::File::open(&local).await { Ok(f) => f, Err(err) => { - upload_send_failed(&tx, epoch, format!("Open local file failed: {err}")).await; + upload_send_failed( + &tx, + epoch, + t!("Sftp.Transfer.OpenLocalFileFailed", err = err.to_string()).to_string(), + ) + .await; return; } }; @@ -202,7 +218,12 @@ async fn run_upload_worker( upload_send_failed( &tx, epoch, - format!("Open remote file failed: {remote_path}: {err2}"), + t!( + "Sftp.Transfer.OpenRemotePathFailed", + path = remote_path.clone(), + err = err2.to_string() + ) + .to_string(), ) .await; return; @@ -212,7 +233,12 @@ async fn run_upload_worker( upload_send_failed( &tx, epoch, - format!("Open remote file failed: {remote_path}: {err}"), + t!( + "Sftp.Transfer.OpenRemotePathFailed", + path = remote_path.clone(), + err = err.to_string() + ) + .to_string(), ) .await; return; @@ -271,8 +297,12 @@ async fn plan_uploads( .map(|s| s.to_string()) else { let _ = this.update(cx, |this, cx| { - this.delegate_mut() - .show_toast(PromptLevel::Warning, "Invalid filename", None, cx); + this.delegate_mut().show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.InvalidFilename").to_string(), + None, + cx, + ); }); continue; }; @@ -305,8 +335,8 @@ fn show_upload_nothing_to_upload( let _ = this.update(cx, |this, cx| { this.delegate_mut().show_toast( PromptLevel::Info, - "Nothing to upload", - Some("No valid files were selected.".to_string()), + t!("Sftp.Toast.NothingToUpload").to_string(), + Some(t!("Sftp.Toast.NoValidFilesSelected").to_string()), cx, ); }); @@ -530,11 +560,16 @@ fn finish_upload_batch( ) { let _ = this.update(cx, |this, cx| { let title = if total_files == 1 { - "Upload finished".to_string() + t!("Sftp.Toast.UploadFinished").to_string() } else if uploaded == total_files { - format!("Upload finished ({uploaded} files)") + t!("Sftp.Toast.UploadFinishedFiles", count = uploaded).to_string() } else { - format!("Upload finished ({uploaded}/{total_files} files)") + t!( + "Sftp.Toast.UploadFinishedPartial", + uploaded = uploaded, + total = total_files + ) + .to_string() }; this.delegate_mut() .show_toast(PromptLevel::Info, title, None, cx); @@ -679,7 +714,11 @@ async fn download_copy_loop( Ok(0) => break, Ok(n) => n, Err(err) => { - fail_download_task_in_center(cx, ctx, format!("Read remote file failed: {err}")); + fail_download_task_in_center( + cx, + ctx, + t!("Sftp.Transfer.ReadRemoteFileFailed", err = err.to_string()).to_string(), + ); return None; } }; @@ -690,7 +729,11 @@ async fn download_copy_loop( } if let Err(err) = local_f.write_all(&buf[..n]).await { - fail_download_task_in_center(cx, ctx, format!("Write local file failed: {err}")); + fail_download_task_in_center( + cx, + ctx, + t!("Sftp.Transfer.WriteLocalFileFailed", err = err.to_string()).to_string(), + ); return None; } @@ -725,7 +768,11 @@ async fn download_to_path( let mut remote_f = match sftp.open(&id).await { Ok(f) => f, Err(err) => { - fail_download_task_in_center(cx, &ctx, format!("Open remote file failed: {err}")); + fail_download_task_in_center( + cx, + &ctx, + t!("Sftp.Transfer.OpenRemoteFileFailed", err = err.to_string()).to_string(), + ); return; } }; @@ -733,7 +780,11 @@ async fn download_to_path( let mut local_f = match smol::fs::File::create(&dst).await { Ok(f) => f, Err(err) => { - fail_download_task_in_center(cx, &ctx, format!("Create local file failed: {err}")); + fail_download_task_in_center( + cx, + &ctx, + t!("Sftp.Transfer.CreateLocalFileFailed", err = err.to_string()).to_string(), + ); return; } }; @@ -755,7 +806,7 @@ impl SftpTable { let Some(parent) = self.selected_dir_for_new_entries(target_row) else { return; }; - let input = new_input(window, cx, "Folder name"); + let input = new_input(window, cx, t!("Sftp.Placeholder.FolderName").to_string()); self.op = Some(SftpOp { kind: SftpOpKind::NewFolder { parent }, input: input.clone(), @@ -780,9 +831,12 @@ impl SftpTable { return; }; - let input = new_configured_input(window, cx, "New name", |input| { - input.default_value(row.name.clone()) - }); + let input = new_configured_input( + window, + cx, + t!("Sftp.Placeholder.NewName").to_string(), + |input| input.default_value(row.name.clone()), + ); self.op = Some(SftpOp { kind: SftpOpKind::Rename { @@ -809,14 +863,24 @@ impl SftpTable { return; }; let Some(sftp) = self.sftp.clone() else { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; }; let name = op.input.read(cx).value().to_string(); let name = name.trim().to_string(); if name.is_empty() { - self.show_toast(PromptLevel::Info, "Name is required", None, cx); + self.show_toast( + PromptLevel::Info, + t!("Sftp.Toast.NameRequired").to_string(), + None, + cx, + ); return; } @@ -832,7 +896,7 @@ impl SftpTable { Ok(()) => { this.delegate_mut().show_toast( PromptLevel::Info, - "Folder created", + t!("Sftp.Toast.FolderCreated").to_string(), None, cx, ); @@ -840,7 +904,7 @@ impl SftpTable { } Err(err) => this.delegate_mut().show_toast( PromptLevel::Warning, - "Create folder failed", + t!("Sftp.Toast.CreateFolderFailed").to_string(), Some(err.to_string()), cx, ), @@ -856,13 +920,17 @@ impl SftpTable { .await; let _ = this.update(cx, |this, cx| match res { Ok(()) => { - this.delegate_mut() - .show_toast(PromptLevel::Info, "Renamed", None, cx); + this.delegate_mut().show_toast( + PromptLevel::Info, + t!("Sftp.Toast.Renamed").to_string(), + None, + cx, + ); this.delegate_mut().refresh_dir(parent.clone(), cx); } Err(err) => this.delegate_mut().show_toast( PromptLevel::Warning, - "Rename failed", + t!("Sftp.Toast.RenameFailed").to_string(), Some(err.to_string()), cx, ), @@ -880,7 +948,12 @@ impl SftpTable { cx: &mut Context>, ) { if self.sftp.is_none() { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; }; let Some(remote_dir) = self.selected_dir_for_new_entries(target_row) else { @@ -891,7 +964,7 @@ impl SftpTable { files: true, directories: false, multiple: true, - prompt: Some("Select files to upload".into()), + prompt: Some(t!("Sftp.Dialog.SelectFilesToUpload").to_string().into()), }); let window_handle = _window.window_handle(); @@ -925,14 +998,19 @@ impl SftpTable { cx: &mut Context>, ) { let Some(sftp) = self.sftp.clone() else { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; }; if !accept_external_file_drop_paths(&locals) { self.show_toast( PromptLevel::Info, - "Only files are supported", - Some("Dragging folders is not supported.".to_string()), + t!("Sftp.Toast.OnlyFilesSupported").to_string(), + Some(t!("Sftp.Toast.DraggingFoldersUnsupported").to_string()), cx, ); return; @@ -957,7 +1035,12 @@ impl SftpTable { cx: &mut Context>, ) { let Some(sftp) = self.sftp.clone() else { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; }; let Some(row_ix) = target_row else { @@ -1001,7 +1084,12 @@ impl SftpTable { cx: &mut Context>, ) { let Some(sftp) = self.sftp.clone() else { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; }; let Some(tree) = self.tree.as_ref() else { @@ -1053,14 +1141,19 @@ impl SftpTable { if failed == 0 { let title = if deleted == 1 { - "Deleted".to_string() + t!("Sftp.Toast.Deleted").to_string() } else { - format!("Deleted {deleted} items") + t!("Sftp.Toast.DeletedItems", count = deleted).to_string() }; this.delegate_mut() .show_toast(PromptLevel::Info, title, None, cx); } else { - let title = format!("Deleted {deleted}/{total} items"); + let title = t!( + "Sftp.Toast.DeletedPartial", + deleted = deleted, + total = total + ) + .to_string(); this.delegate_mut() .show_toast(PromptLevel::Warning, title, last_error, cx); } diff --git a/crates/gpui_sftp/src/view/table/delegate.rs b/crates/gpui_sftp/src/view/table/delegate.rs index 6defc09..f605f71 100644 --- a/crates/gpui_sftp/src/view/table/delegate.rs +++ b/crates/gpui_sftp/src/view/table/delegate.rs @@ -1,4 +1,5 @@ use super::*; +use rust_i18n::t; impl TableDelegate for SftpTable { fn columns_count(&self, _cx: &App) -> usize { @@ -20,11 +21,11 @@ impl TableDelegate for SftpTable { cx: &mut Context>, ) -> impl IntoElement { let (label, sort_col) = match col_ix { - 0 => ("Name", SortColumn::Name), - 1 => ("Size", SortColumn::Size), - 2 => ("Modified", SortColumn::Modified), - 3 => ("Perms", SortColumn::Perms), - _ => ("", SortColumn::Name), + 0 => (t!("Sftp.Table.Name").to_string(), SortColumn::Name), + 1 => (t!("Sftp.Table.Size").to_string(), SortColumn::Size), + 2 => (t!("Sftp.Table.Modified").to_string(), SortColumn::Modified), + 3 => (t!("Sftp.Table.Perms").to_string(), SortColumn::Perms), + _ => (String::new(), SortColumn::Name), }; let active = self.sort.column == sort_col; @@ -141,7 +142,7 @@ impl TableDelegate for SftpTable { .gap(px(8.0)) .text_color(muted) .child(Icon::new(IconName::Inbox).size_6()) - .child("Empty directory") + .child(t!("Sftp.Table.Empty").to_string()) } fn render_td( diff --git a/crates/gpui_sftp/src/view/table/navigation.rs b/crates/gpui_sftp/src/view/table/navigation.rs index f336bb1..abe2a4f 100644 --- a/crates/gpui_sftp/src/view/table/navigation.rs +++ b/crates/gpui_sftp/src/view/table/navigation.rs @@ -1,4 +1,5 @@ use super::*; +use rust_i18n::t; impl SftpTable { pub(in crate::view) fn new(sftp: wezterm_ssh::Sftp) -> Self { @@ -180,8 +181,8 @@ impl SftpTable { self.op = None; self.show_toast( PromptLevel::Warning, - "SSH terminal closed", - Some("SFTP session disconnected.".to_string()), + t!("Sftp.Toast.SshTerminalClosed").to_string(), + Some(t!("Sftp.Toast.SessionDisconnected").to_string()), cx, ); } @@ -263,7 +264,12 @@ impl SftpTable { pub(super) fn refresh_dir(&mut self, dir: String, cx: &mut Context>) { let Some(sftp) = self.sftp.clone() else { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; }; let Some(tree) = self.tree.as_mut() else { @@ -304,7 +310,7 @@ impl SftpTable { Err(err) => { this.delegate_mut().show_toast( PromptLevel::Warning, - "Failed to read directory", + t!("Sftp.Toast.FailedReadDirectory").to_string(), Some(err.to_string()), cx, ); @@ -319,7 +325,12 @@ impl SftpTable { pub(in crate::view) fn cd(&mut self, dir: String, cx: &mut Context>) { if self.sftp.is_none() { - self.show_toast(PromptLevel::Warning, "Disconnected", None, cx); + self.show_toast( + PromptLevel::Warning, + t!("Sftp.Toast.Disconnected").to_string(), + None, + cx, + ); return; } diff --git a/crates/gpui_sftp/src/view/tests.rs b/crates/gpui_sftp/src/view/tests.rs index 708b9fb..ed576e7 100644 --- a/crates/gpui_sftp/src/view/tests.rs +++ b/crates/gpui_sftp/src/view/tests.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use gpui_common::TermuaIcon; +use rust_i18n::t; use super::*; @@ -75,6 +76,21 @@ fn sftp_table_has_expected_columns() { assert_eq!(keys, ["name", "size", "modified", "perms"]); } +#[test] +fn sftp_locale_keys_are_available() { + assert_eq!(t!("Sftp.Table.Name", locale = "en"), "Name"); + assert_eq!(t!("Sftp.Table.Name", locale = "zh-CN"), "名称"); + assert_eq!(t!("Sftp.Context.Upload", locale = "zh-CN"), "上传"); + assert_eq!( + t!("Sftp.Preview.SelectFile", locale = "zh-CN"), + "选择文件以预览" + ); + assert_eq!( + t!("Sftp.Dialog.DeleteSelectedItem", locale = "zh-CN"), + "删除选中的项目?" + ); +} + #[test] fn sftp_dir_icon_path_is_folder_closed_blue() { assert_eq!(sftp_dir_icon_path(false), TermuaIcon::FolderClosedBlue); diff --git a/locales/en.yml b/locales/en.yml index 0cd0e26..6fc6ff1 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -159,6 +159,74 @@ Transfers: Cancel: "Cancel" Canceling: "Canceling..." +Sftp: + Table: + Name: "Name" + Size: "Size" + Modified: "Modified" + Perms: "Perms" + Empty: "Empty directory" + Placeholder: + Path: "Path" + FolderName: "Folder name" + NewName: "New name" + Context: + Refresh: "Refresh" + Upload: "Upload" + Download: "Download" + NewFolder: "New Folder" + Rename: "Rename" + Delete: "Delete" + ShowHiddenFiles: "Show Hidden Files" + HideHiddenFiles: "Hide Hidden Files" + Preview: + Title: "Preview" + SelectFile: "Select a file to preview" + Loading: "Loading preview..." + BinaryUnsupported: "Binary file (preview unsupported)" + OpenFailed: "Open failed: %{err}" + ReadFailed: "Read failed: %{err}" + Dialog: + DeleteNamedItem: "Delete \"%{name}\"?" + DeleteSelectedItem: "Delete selected item?" + DeleteItems: "Delete %{count} items?" + DeleteOk: "Delete" + Cancel: "Cancel" + OK: "OK" + NewFolder: "New Folder" + Rename: "Rename" + SelectFilesToUpload: "Select files to upload" + Toast: + SshTerminalClosed: "SSH terminal closed" + SessionDisconnected: "SFTP session disconnected." + Disconnected: "Disconnected" + FailedReadDirectory: "Failed to read directory" + InvalidFilename: "Invalid filename" + NothingToUpload: "Nothing to upload" + NoValidFilesSelected: "No valid files were selected." + UploadFinished: "Upload finished" + UploadFinishedFiles: "Upload finished (%{count} files)" + UploadFinishedPartial: "Upload finished (%{uploaded}/%{total} files)" + NameRequired: "Name is required" + FolderCreated: "Folder created" + CreateFolderFailed: "Create folder failed" + Renamed: "Renamed" + RenameFailed: "Rename failed" + OnlyFilesSupported: "Only files are supported" + DraggingFoldersUnsupported: "Dragging folders is not supported." + Deleted: "Deleted" + DeletedItems: "Deleted %{count} items" + DeletedPartial: "Deleted %{deleted}/%{total} items" + Transfer: + ReadLocalFileFailed: "Read local file failed: %{err}" + WriteRemoteFileFailed: "Write remote file failed: %{err}" + OpenLocalFileFailed: "Open local file failed: %{err}" + OpenRemoteFileFailed: "Open remote file failed: %{err}" + OpenRemotePathFailed: "Open remote file failed: %{path}: %{err}" + ReadRemoteFileFailed: "Read remote file failed: %{err}" + WriteLocalFileFailed: "Write local file failed: %{err}" + CreateLocalFileFailed: "Create local file failed: %{err}" + SessionsSidebar: Placeholder: Search: "Search sessions..." diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index e55c41c..48718e7 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -158,6 +158,74 @@ Transfers: Cancel: "取消" Canceling: "正在取消..." +Sftp: + Table: + Name: "名称" + Size: "大小" + Modified: "修改时间" + Perms: "权限" + Empty: "空目录" + Placeholder: + Path: "路径" + FolderName: "文件夹名称" + NewName: "新名称" + Context: + Refresh: "刷新" + Upload: "上传" + Download: "下载" + NewFolder: "新建文件夹" + Rename: "重命名" + Delete: "删除" + ShowHiddenFiles: "显示隐藏文件" + HideHiddenFiles: "隐藏隐藏文件" + Preview: + Title: "预览" + SelectFile: "选择文件以预览" + Loading: "正在加载预览..." + BinaryUnsupported: "二进制文件(不支持预览)" + OpenFailed: "打开失败:%{err}" + ReadFailed: "读取失败:%{err}" + Dialog: + DeleteNamedItem: "删除“%{name}”?" + DeleteSelectedItem: "删除选中的项目?" + DeleteItems: "删除 %{count} 个项目?" + DeleteOk: "删除" + Cancel: "取消" + OK: "确定" + NewFolder: "新建文件夹" + Rename: "重命名" + SelectFilesToUpload: "选择要上传的文件" + Toast: + SshTerminalClosed: "SSH 终端已关闭" + SessionDisconnected: "SFTP 会话已断开。" + Disconnected: "已断开连接" + FailedReadDirectory: "读取目录失败" + InvalidFilename: "无效的文件名" + NothingToUpload: "没有可上传的文件" + NoValidFilesSelected: "未选择有效文件。" + UploadFinished: "上传完成" + UploadFinishedFiles: "上传完成(%{count} 个文件)" + UploadFinishedPartial: "上传完成(%{uploaded}/%{total} 个文件)" + NameRequired: "名称不能为空" + FolderCreated: "文件夹已创建" + CreateFolderFailed: "创建文件夹失败" + Renamed: "已重命名" + RenameFailed: "重命名失败" + OnlyFilesSupported: "仅支持文件" + DraggingFoldersUnsupported: "不支持拖拽文件夹。" + Deleted: "已删除" + DeletedItems: "已删除 %{count} 个项目" + DeletedPartial: "已删除 %{deleted}/%{total} 个项目" + Transfer: + ReadLocalFileFailed: "读取本地文件失败:%{err}" + WriteRemoteFileFailed: "写入远程文件失败:%{err}" + OpenLocalFileFailed: "打开本地文件失败:%{err}" + OpenRemoteFileFailed: "打开远程文件失败:%{err}" + OpenRemotePathFailed: "打开远程文件失败:%{path}:%{err}" + ReadRemoteFileFailed: "读取远程文件失败:%{err}" + WriteLocalFileFailed: "写入本地文件失败:%{err}" + CreateLocalFileFailed: "创建本地文件失败:%{err}" + SessionsSidebar: Placeholder: Search: "搜索会话..." diff --git a/termua/src/window/new_session/tests.rs b/termua/src/window/new_session/tests.rs index 721223c..ae6c4da 100644 --- a/termua/src/window/new_session/tests.rs +++ b/termua/src/window/new_session/tests.rs @@ -1,4 +1,5 @@ use gpui::{ParentElement, Render, Styled, div}; +use rust_i18n::t; use super::*; use crate::{env::build_terminal_env, store::SessionEnvVar}; @@ -29,7 +30,7 @@ fn test_session_env( #[test] fn new_session_colorterm_field_label_uses_camel_case_locale() { - assert_eq!(rust_i18n::t!("NewSession.Field.ColorTerm"), "ColorTerm:"); + assert_eq!(t!("NewSession.Field.ColorTerm"), "ColorTerm:"); } #[gpui::test] From 57cad0eddc28c8ff267a61e4b45034af5327168b Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 14:02:27 +0800 Subject: [PATCH 03/12] feat(term): support i8n --- Cargo.lock | 1 + crates/gpui_term/Cargo.toml | 1 + crates/gpui_term/build.rs | 3 +++ crates/gpui_term/src/lib.rs | 2 ++ crates/gpui_term/src/view/mod.rs | 33 ++++++++++++++++++++------ locales/en.yml | 11 ++++++++- locales/zh-CN.yml | 9 +++++++ termua/build.rs | 1 + termua/src/window/main_window/state.rs | 28 ++++++++++++++++++---- termua/src/window/main_window/tests.rs | 14 +++++++++++ termua/src/window/settings/tests.rs | 10 ++++++++ 11 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 crates/gpui_term/build.rs diff --git a/Cargo.lock b/Cargo.lock index facb0f6..87a51ae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3195,6 +3195,7 @@ dependencies = [ "parking_lot", "polling", "portable-pty", + "rust-i18n", "schemars", "serde", "serde_json", diff --git a/crates/gpui_term/Cargo.toml b/crates/gpui_term/Cargo.toml index 20d7b8d..8953352 100644 --- a/crates/gpui_term/Cargo.toml +++ b/crates/gpui_term/Cargo.toml @@ -25,6 +25,7 @@ libc.workspace = true log.workspace = true parking_lot.workspace = true polling.workspace = true +rust-i18n.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/gpui_term/build.rs b/crates/gpui_term/build.rs new file mode 100644 index 0000000..18c9f7b --- /dev/null +++ b/crates/gpui_term/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=../../locales"); +} diff --git a/crates/gpui_term/src/lib.rs b/crates/gpui_term/src/lib.rs index 966659c..7390356 100644 --- a/crates/gpui_term/src/lib.rs +++ b/crates/gpui_term/src/lib.rs @@ -2,6 +2,8 @@ use std::ops::RangeInclusive; use bitflags::bitflags; +rust_i18n::i18n!("../../locales"); + mod backends; mod builder; pub mod cast; diff --git a/crates/gpui_term/src/view/mod.rs b/crates/gpui_term/src/view/mod.rs index c0832a8..8bfb83c 100644 --- a/crates/gpui_term/src/view/mod.rs +++ b/crates/gpui_term/src/view/mod.rs @@ -11,6 +11,7 @@ use gpui_component::{ notification::Notification, }; use record::{RecordingMenuEntry, recording_context_menu_entry, recording_indicator_label}; +use rust_i18n::t; use schemars::JsonSchema; use scrolling::{ScrollState, TerminalScrollbarHandle}; use serde::Deserialize; @@ -973,9 +974,14 @@ impl TerminalView { let icon = Icon::default() .path(TermuaIcon::Record) .text_color(icon_color); + let label_key = if checked { + "Terminal.ContextMenu.RecordingActive" + } else { + "Terminal.ContextMenu.Recording" + }; menu.item( - PopupMenuItem::new("Recording") + PopupMenuItem::new(t!(label_key).to_string()) .icon(icon) .checked(checked) .action(Box::new(ToggleCastRecording)), @@ -984,12 +990,25 @@ impl TerminalView { } }; - menu.menu_with_icon("Copy", IconName::Copy, Box::new(Copy)) - .menu("Paste", Box::new(Paste)) - .separator() - .menu("SelectAll", Box::new(SelectAll)) - .separator() - .menu("Clear", Box::new(Clear)) + menu.menu_with_icon( + t!("Terminal.ContextMenu.Copy").to_string(), + IconName::Copy, + Box::new(Copy), + ) + .menu( + t!("Terminal.ContextMenu.Paste").to_string(), + Box::new(Paste), + ) + .separator() + .menu( + t!("Terminal.ContextMenu.SelectAll").to_string(), + Box::new(SelectAll), + ) + .separator() + .menu( + t!("Terminal.ContextMenu.Clear").to_string(), + Box::new(Clear), + ) } } diff --git a/locales/en.yml b/locales/en.yml index 6fc6ff1..5d3f7f2 100644 --- a/locales/en.yml +++ b/locales/en.yml @@ -281,6 +281,15 @@ Assistant: RunInTerminalOk: "Run" RunInTerminalCancel: "Cancel" +Terminal: + ContextMenu: + Recording: "Record" + RecordingActive: "Recording" + Copy: "Copy" + Paste: "Paste" + SelectAll: "Select All" + Clear: "Clear" + MainWindow: QuitConfirm: Title: "Quit Termua?" @@ -390,7 +399,7 @@ Settings: Dark: "Dark" Language: English: "English" - ZhCn: "Simplified Chinese" + ZhCn: "简体中文" KeyBindingsUi: PressKeys: "Press keys..." DefaultLabel: "Default: %{label}" diff --git a/locales/zh-CN.yml b/locales/zh-CN.yml index 48718e7..11afc1f 100644 --- a/locales/zh-CN.yml +++ b/locales/zh-CN.yml @@ -280,6 +280,15 @@ Assistant: RunInTerminalOk: "运行" RunInTerminalCancel: "取消" +Terminal: + ContextMenu: + Recording: "录制" + RecordingActive: "录制中" + Copy: "复制" + Paste: "粘贴" + SelectAll: "全选" + Clear: "清空" + MainWindow: QuitConfirm: Title: "退出 Termua?" diff --git a/termua/build.rs b/termua/build.rs index 6e8f04f..b683f24 100644 --- a/termua/build.rs +++ b/termua/build.rs @@ -2,6 +2,7 @@ use std::{env, fs, path::PathBuf, process::Command}; fn main() { println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../locales"); println!("cargo:rerun-if-changed=../assets/logo/termua.ico"); let out_dir = PathBuf::from(env::var_os("OUT_DIR").expect("missing OUT_DIR")); diff --git a/termua/src/window/main_window/state.rs b/termua/src/window/main_window/state.rs index 4e8b53c..7f49298 100644 --- a/termua/src/window/main_window/state.rs +++ b/termua/src/window/main_window/state.rs @@ -63,6 +63,11 @@ impl gpui_term::ContextMenuProvider for TermuaContextMenuProvider { let record_icon = Icon::default() .path(TermuaIcon::Record) .text_color(record_icon_color); + let recording_label_key = if recording_active { + "Terminal.ContextMenu.RecordingActive" + } else { + "Terminal.ContextMenu.Recording" + }; let has_sftp = terminal.read(cx).sftp().is_some(); @@ -78,7 +83,7 @@ impl gpui_term::ContextMenuProvider for TermuaContextMenuProvider { menu = menu .item( - gpui_component::menu::PopupMenuItem::new("Recording") + gpui_component::menu::PopupMenuItem::new(t!(recording_label_key).to_string()) .icon(record_icon) .checked(recording_active) .action(Box::new(ToggleCastRecording)), @@ -86,12 +91,25 @@ impl gpui_term::ContextMenuProvider for TermuaContextMenuProvider { .separator(); menu = menu - .menu_with_icon("Copy", IconName::Copy, Box::new(CopyAction)) - .menu("Paste", Box::new(Paste)) + .menu_with_icon( + t!("Terminal.ContextMenu.Copy").to_string(), + IconName::Copy, + Box::new(CopyAction), + ) + .menu( + t!("Terminal.ContextMenu.Paste").to_string(), + Box::new(Paste), + ) .separator() - .menu("SelectAll", Box::new(SelectAll)) + .menu( + t!("Terminal.ContextMenu.SelectAll").to_string(), + Box::new(SelectAll), + ) .separator() - .menu("Clear", Box::new(Clear)); + .menu( + t!("Terminal.ContextMenu.Clear").to_string(), + Box::new(Clear), + ); menu } diff --git a/termua/src/window/main_window/tests.rs b/termua/src/window/main_window/tests.rs index 72f9ba9..75e3157 100644 --- a/termua/src/window/main_window/tests.rs +++ b/termua/src/window/main_window/tests.rs @@ -19,6 +19,7 @@ use gpui_term::{ Authentication, CursorShape, Event as TerminalEvent, SshOptions, Terminal, TerminalBackend, TerminalBounds, TerminalType, TerminalView, UserInput as TerminalUserInput, }; +use rust_i18n::t; use super::*; use crate::{ @@ -28,6 +29,19 @@ use crate::{ ssh::{SshHostKeyMismatchDetails, SshTerminalBuilderFn}, }; +#[test] +fn terminal_context_menu_labels_follow_the_active_locale() { + let _guard = crate::locale::lock(); + crate::locale::set_locale("zh-CN"); + + assert_eq!(t!("Terminal.ContextMenu.Recording"), "录制"); + assert_eq!(t!("Terminal.ContextMenu.RecordingActive"), "录制中"); + assert_eq!(t!("Terminal.ContextMenu.Copy"), "复制"); + assert_eq!(t!("Terminal.ContextMenu.Paste"), "粘贴"); + assert_eq!(t!("Terminal.ContextMenu.SelectAll"), "全选"); + assert_eq!(t!("Terminal.ContextMenu.Clear"), "清空"); +} + #[gpui::test] fn ssh_host_key_mismatch_dialog_renders_label_prefixes(cx: &mut gpui::TestAppContext) { use std::{cell::RefCell, rc::Rc}; diff --git a/termua/src/window/settings/tests.rs b/termua/src/window/settings/tests.rs index 7d10037..191008f 100644 --- a/termua/src/window/settings/tests.rs +++ b/termua/src/window/settings/tests.rs @@ -6,6 +6,7 @@ use gpui_component::{ input::{InputEvent, InputState}, select::SearchableVec, }; +use rust_i18n::t; use super::{ state::{build_nav_tree_items, sidebar_nav_specs}, @@ -2292,6 +2293,15 @@ fn sidebar_nav_order_matches_english_in_chinese_locale() { assert_eq!(chinese_item_order, english_item_order); } +#[test] +fn simplified_chinese_language_option_uses_native_name_in_english_locale() { + let _guard = crate::locale::lock(); + + crate::locale::set_locale("en"); + + assert_eq!(t!("Settings.Language.ZhCn"), "简体中文"); +} + #[test] fn sidebar_nav_specs_do_not_include_terminal_root_item() { let specs = sidebar_nav_specs(); From cba7192f382da545bdc643123cdc814d7f6ef953 Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 14:04:49 +0800 Subject: [PATCH 04/12] chore: cargo fmt --- crates/gpui_sftp/src/view/preview.rs | 3 ++- crates/gpui_sftp/src/view/table/actions.rs | 3 ++- crates/gpui_sftp/src/view/table/delegate.rs | 3 ++- crates/gpui_sftp/src/view/table/navigation.rs | 3 ++- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/gpui_sftp/src/view/preview.rs b/crates/gpui_sftp/src/view/preview.rs index 794015a..0e692c5 100644 --- a/crates/gpui_sftp/src/view/preview.rs +++ b/crates/gpui_sftp/src/view/preview.rs @@ -1,6 +1,7 @@ +use rust_i18n::t; + use super::{format::fenced_text_as_markdown, *}; use crate::preview::{PreviewGate, PreviewKind, gate_preview, read_bytes_with_limit}; -use rust_i18n::t; #[derive(Clone, Debug)] pub(super) struct PreviewTarget { diff --git a/crates/gpui_sftp/src/view/table/actions.rs b/crates/gpui_sftp/src/view/table/actions.rs index 1fec996..0d8e31c 100644 --- a/crates/gpui_sftp/src/view/table/actions.rs +++ b/crates/gpui_sftp/src/view/table/actions.rs @@ -1,6 +1,7 @@ -use super::*; use rust_i18n::t; +use super::*; + #[derive(Clone, Debug)] struct PlannedUpload { local: PathBuf, diff --git a/crates/gpui_sftp/src/view/table/delegate.rs b/crates/gpui_sftp/src/view/table/delegate.rs index f605f71..ede570e 100644 --- a/crates/gpui_sftp/src/view/table/delegate.rs +++ b/crates/gpui_sftp/src/view/table/delegate.rs @@ -1,6 +1,7 @@ -use super::*; use rust_i18n::t; +use super::*; + impl TableDelegate for SftpTable { fn columns_count(&self, _cx: &App) -> usize { self.columns.len() diff --git a/crates/gpui_sftp/src/view/table/navigation.rs b/crates/gpui_sftp/src/view/table/navigation.rs index abe2a4f..5e40b30 100644 --- a/crates/gpui_sftp/src/view/table/navigation.rs +++ b/crates/gpui_sftp/src/view/table/navigation.rs @@ -1,6 +1,7 @@ -use super::*; use rust_i18n::t; +use super::*; + impl SftpTable { pub(in crate::view) fn new(sftp: wezterm_ssh::Sftp) -> Self { let tree = TreeState::new(Entry::new(".", "~", EntryKind::Dir)); From 590dbc499d54ab08c1f213a3d8b2c18c4b993e31 Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 19:27:31 +0800 Subject: [PATCH 05/12] fix: aggregate sftp transfer summary --- crates/gpui_sftp/src/view/table/actions.rs | 7 +- crates/gpui_transfer/src/lib.rs | 293 +++++++++++++++++++-- termua/src/footbar/transfers.rs | 26 +- 3 files changed, 285 insertions(+), 41 deletions(-) diff --git a/crates/gpui_sftp/src/view/table/actions.rs b/crates/gpui_sftp/src/view/table/actions.rs index 0d8e31c..95054eb 100644 --- a/crates/gpui_sftp/src/view/table/actions.rs +++ b/crates/gpui_sftp/src/view/table/actions.rs @@ -19,6 +19,7 @@ struct UploadTaskCtx { epoch: usize, file_name: String, remote_path: SharedString, + total: u64, cancel: Arc, group_id: Option, group_total: Option, @@ -360,6 +361,7 @@ fn begin_upload_transfers( epoch: f.epoch, file_name: f.file_name.clone(), remote_path: f.remote_path.clone().into(), + total: f.total, cancel: Arc::clone(&f.cancel), group_id: Some(group_id.to_string()), group_total: Some(total_files), @@ -393,6 +395,7 @@ async fn run_upload_workers( epoch: f.epoch, file_name: f.file_name.clone(), remote_path: f.remote_path.clone().into(), + total: f.total, cancel: Arc::clone(&f.cancel), group_id: f.group_id.clone(), group_total: f.group_total, @@ -496,8 +499,8 @@ fn set_upload_terminal_in_center( ctx, status, TransferProgress::Determinate(1.0), - None, - None, + (status == TransferStatus::Finished).then_some(ctx.total), + (ctx.total > 0).then_some(ctx.total), detail_override, ); cx.update_global::(|state, _cx| state.upsert(task)); diff --git a/crates/gpui_transfer/src/lib.rs b/crates/gpui_transfer/src/lib.rs index d104fbb..82e431f 100644 --- a/crates/gpui_transfer/src/lib.rs +++ b/crates/gpui_transfer/src/lib.rs @@ -118,20 +118,53 @@ pub struct TransferCenterState { group_tasks: HashMap>, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct TransferCenterSummary { + pub done: usize, + pub total: usize, + pub bytes_done: u64, + pub bytes_total: u64, +} + #[derive(Default)] struct TransferGroupState { total: usize, completed: HashSet, + bytes: HashMap, } impl Global for TransferCenterState {} +impl TransferGroupState { + fn summary(&self) -> TransferCenterSummary { + let mut summary = TransferCenterSummary { + done: self.completed.len().min(self.total), + total: self.total, + ..Default::default() + }; + + for (done, total) in self.bytes.values().copied() { + summary.bytes_done += done.min(total); + summary.bytes_total += total; + } + + summary + } +} + impl TransferCenterState { pub fn upsert(&mut self, mut task: TransferTask) { let now = Instant::now(); let task_id = task.id.clone(); if let Some(existing) = self.tasks.get(task.id.as_str()).cloned() { + let preserve_transfer_bytes = existing.group_id == task.group_id; + if preserve_transfer_bytes && task.bytes_done.is_none() { + task.bytes_done = existing.bytes_done; + } + if preserve_transfer_bytes && task.bytes_total.is_none() { + task.bytes_total = existing.bytes_total; + } let preserve_group = existing.group_id == task.group_id; self.remove_task_from_group_state(&existing, true, preserve_group); task.created_at = existing.created_at; @@ -156,7 +189,11 @@ impl TransferCenterState { if let Some(pos) = self.order.iter().position(|k| k == id) { self.order.remove(pos); } - self.remove_task_from_group_state(&task, false, false); + self.remove_task_from_group_state(&task, false, task.status != TransferStatus::InProgress); + if self.tasks.is_empty() { + self.group_tasks.clear(); + self.groups.clear(); + } } pub fn remove_group(&mut self, group_id: &str) { @@ -169,15 +206,26 @@ impl TransferCenterState { for id in ids { self.remove(id.as_str()); } + self.group_tasks.remove(group_id); + self.groups.remove(group_id); } pub fn remove_groups_with_prefix(&mut self, prefix: &str) { - let group_ids: Vec = self + let mut group_ids: Vec = self .group_tasks .keys() .filter(|group_id| group_id.starts_with(prefix)) .cloned() .collect(); + for group_id in self + .groups + .keys() + .filter(|group_id| group_id.starts_with(prefix)) + { + if !group_ids.iter().any(|id| id == group_id) { + group_ids.push(group_id.clone()); + } + } for group_id in group_ids { self.remove_group(group_id.as_str()); @@ -196,12 +244,27 @@ impl TransferCenterState { self.tasks.is_empty() } - pub fn group_counts(&self, group_id: &str) -> Option<(usize, usize)> { - let g = self.groups.get(group_id)?; - if g.total == 0 { - return None; + pub fn summary(&self) -> TransferCenterSummary { + let mut summary = TransferCenterSummary::default(); + + for group in self.groups.values() { + summary.add(group.summary()); + } + + for task in self.tasks.values().filter(|task| task.group_id.is_none()) { + summary.total += 1; + if task.status == TransferStatus::Finished { + summary.done += 1; + } + if let (Some(done), Some(total)) = (task.bytes_done, task.bytes_total) + && total > 0 + { + summary.bytes_done += done.min(total); + summary.bytes_total += total; + } } - Some((g.completed.len(), g.total)) + + summary } fn apply_task_to_group_state(&mut self, task: &TransferTask) { @@ -219,6 +282,22 @@ impl TransferCenterState { g.total = g.total.max(total); } + if let Some(bytes_total) = task.bytes_total + && bytes_total > 0 + { + let bytes_done = task + .bytes_done + .unwrap_or_else(|| { + if task.status == TransferStatus::Finished { + bytes_total + } else { + 0 + } + }) + .min(bytes_total); + g.bytes.insert(task.id.clone(), (bytes_done, bytes_total)); + } + match task.status { TransferStatus::InProgress => {} TransferStatus::Finished | TransferStatus::Cancelled | TransferStatus::Failed => { @@ -240,20 +319,42 @@ impl TransferCenterState { if let Some(group_tasks) = self.group_tasks.get_mut(group_id) { group_tasks.remove(task.id.as_str()); if group_tasks.is_empty() { - if preserve_group_when_empty { - group_tasks.clear(); - } else { - self.group_tasks.remove(group_id); + self.group_tasks.remove(group_id); + if !preserve_group_when_empty { self.groups.remove(group_id); return; } } } - if remove_completion && let Some(group_state) = self.groups.get_mut(group_id) { - group_state.completed.remove(task.id.as_str()); + if let Some(group_state) = self.groups.get_mut(group_id) { + if remove_completion { + group_state.completed.remove(task.id.as_str()); + } + if remove_completion || task.status == TransferStatus::InProgress { + group_state.bytes.remove(task.id.as_str()); + } + } + } +} + +impl TransferCenterSummary { + pub fn progress(self) -> TransferProgress { + if self.bytes_total > 0 { + TransferProgress::Determinate( + (self.bytes_done as f32 / self.bytes_total as f32).clamp(0.0, 1.0), + ) + } else { + TransferProgress::Indeterminate } } + + fn add(&mut self, other: Self) { + self.done += other.done; + self.total += other.total; + self.bytes_done += other.bytes_done; + self.bytes_total += other.bytes_total; + } } #[cfg(test)] @@ -295,7 +396,7 @@ mod tests { } #[test] - fn group_counts_keep_original_total_even_when_tasks_auto_dismiss() { + fn summary_keeps_original_total_even_when_tasks_auto_dismiss() { let mut s = TransferCenterState::default(); s.upsert( @@ -309,11 +410,11 @@ mod tests { .with_status(TransferStatus::InProgress), ); - assert_eq!(s.group_counts("g1"), Some((1, 3))); + assert_summary(&s, 1, 3, 0, 0); // Simulate auto-dismiss removing finished tasks: the group total should not change. s.remove("t1"); - assert_eq!(s.group_counts("g1"), Some((1, 3))); + assert_summary(&s, 1, 3, 0, 0); // When the remaining task finishes, done count should advance. s.upsert( @@ -321,7 +422,135 @@ mod tests { .with_group("g1", Some(3)) .with_status(TransferStatus::Finished), ); - assert_eq!(s.group_counts("g1"), Some((2, 3))); + assert_summary(&s, 2, 3, 0, 0); + } + + #[test] + fn summary_progress_uses_bytes() { + assert_eq!( + TransferCenterSummary { + done: 1, + total: 2, + bytes_done: 25, + bytes_total: 100, + } + .progress(), + TransferProgress::Determinate(0.25) + ); + } + + #[test] + fn summary_keeps_finished_task_bytes_after_auto_dismiss() { + let mut s = TransferCenterState::default(); + + s.upsert( + TransferTask::new("t1", "one") + .with_group("g1", Some(2)) + .with_status(TransferStatus::Finished) + .with_bytes(Some(10), Some(10)), + ); + s.upsert( + TransferTask::new("t2", "two") + .with_group("g1", Some(2)) + .with_status(TransferStatus::InProgress) + .with_bytes(Some(5), Some(20)), + ); + + assert_summary(&s, 1, 2, 15, 30); + + s.remove("t1"); + assert_summary(&s, 1, 2, 15, 30); + } + + #[test] + fn summary_resets_after_all_tasks_auto_dismiss() { + let mut s = TransferCenterState::default(); + + s.upsert( + TransferTask::new("old-1", "old one") + .with_group("old", Some(2)) + .with_status(TransferStatus::Finished) + .with_bytes(Some(10), Some(10)), + ); + s.upsert( + TransferTask::new("old-2", "old two") + .with_group("old", Some(2)) + .with_status(TransferStatus::Finished) + .with_bytes(Some(20), Some(20)), + ); + + s.remove("old-1"); + s.remove("old-2"); + + assert_eq!(s.summary(), TransferCenterSummary::default()); + + s.upsert( + TransferTask::new("new-1", "new one") + .with_group("new", Some(2)) + .with_status(TransferStatus::InProgress) + .with_bytes(Some(5), Some(50)), + ); + + assert_summary(&s, 0, 2, 5, 50); + } + + #[test] + fn summary_keeps_finished_group_while_another_group_is_active() { + let mut s = TransferCenterState::default(); + + s.upsert( + TransferTask::new("old-1", "old one") + .with_group("old", Some(1)) + .with_status(TransferStatus::Finished) + .with_bytes(Some(10), Some(10)), + ); + s.upsert( + TransferTask::new("new-1", "new one") + .with_group("new", Some(2)) + .with_status(TransferStatus::InProgress) + .with_bytes(Some(5), Some(50)), + ); + s.remove("old-1"); + + assert_summary(&s, 1, 3, 15, 60); + } + + #[test] + fn terminal_update_without_bytes_preserves_existing_group_bytes() { + let mut s = TransferCenterState::default(); + + s.upsert( + TransferTask::new("t1", "one") + .with_group("g1", Some(1)) + .with_status(TransferStatus::InProgress) + .with_bytes(Some(4), Some(10)), + ); + s.upsert( + TransferTask::new("t1", "one") + .with_group("g1", Some(1)) + .with_status(TransferStatus::Cancelled), + ); + + assert_summary(&s, 1, 1, 4, 10); + } + + #[test] + fn moving_task_to_another_group_does_not_reuse_previous_bytes() { + let mut s = TransferCenterState::default(); + + s.upsert( + TransferTask::new("t1", "one") + .with_group("g1", Some(1)) + .with_status(TransferStatus::InProgress) + .with_bytes(Some(4), Some(10)), + ); + s.upsert( + TransferTask::new("t1", "one") + .with_group("g2", Some(1)) + .with_status(TransferStatus::InProgress), + ); + + assert_summary(&s, 0, 1, 0, 0); } #[test] @@ -349,8 +578,7 @@ mod tests { .collect::>(), vec!["t3"] ); - assert_eq!(s.group_counts("g1"), None); - assert_eq!(s.group_counts("g2"), Some((0, 1))); + assert_summary(&s, 0, 1, 0, 0); } #[test] @@ -362,7 +590,7 @@ mod tests { .with_group("g1", Some(2)) .with_status(TransferStatus::Finished), ); - assert_eq!(s.group_counts("g1"), Some((1, 2))); + assert_summary(&s, 1, 2, 0, 0); s.upsert( TransferTask::new("t1", "one") @@ -370,8 +598,7 @@ mod tests { .with_status(TransferStatus::InProgress), ); - assert_eq!(s.group_counts("g1"), None); - assert_eq!(s.group_counts("g2"), Some((0, 1))); + assert_summary(&s, 0, 1, 0, 0); } #[test] @@ -391,8 +618,24 @@ mod tests { .collect::>(), vec!["t3"] ); - assert_eq!(s.group_counts("sftp-1"), None); - assert_eq!(s.group_counts("sftp-2"), None); - assert_eq!(s.group_counts("http-1"), Some((0, 1))); + assert_summary(&s, 0, 1, 0, 0); + } + + fn assert_summary( + state: &TransferCenterState, + done: usize, + total: usize, + bytes_done: u64, + bytes_total: u64, + ) { + assert_eq!( + state.summary(), + TransferCenterSummary { + done, + total, + bytes_done, + bytes_total, + } + ); } } diff --git a/termua/src/footbar/transfers.rs b/termua/src/footbar/transfers.rs index 0117d0c..fecf75e 100644 --- a/termua/src/footbar/transfers.rs +++ b/termua/src/footbar/transfers.rs @@ -37,21 +37,18 @@ impl FootbarView { return div().into_any_element(); }; - let transfers_done = transfers - .iter() - .filter(|t| t.status == TransferStatus::Finished) - .count(); - let stripe_a = cx.theme().progress_bar.alpha(0.22); let stripe_b = cx.theme().progress_bar.alpha(0.10); - let progress_el = render_transfers_summary_progress(task, stripe_a, stripe_b, cx.theme()); - - let (done, total) = task - .group_id - .as_deref() - .and_then(|gid| cx.global::().group_counts(gid)) - .unwrap_or((transfers_done, transfers.len())); - let transfers_done_label = format!("{done}/{total}"); + let summary = cx.global::().summary(); + let progress_el = render_transfers_summary_progress( + task, + summary.progress(), + stripe_a, + stripe_b, + cx.theme(), + ); + + let transfers_done_label = format!("{}/{}", summary.done, summary.total); let transfers_for_panel = Arc::new(transfers.to_vec()); let view = cx.entity(); @@ -106,6 +103,7 @@ impl FootbarView { fn render_transfers_summary_progress( task: &TransferTask, + progress: TransferProgress, stripe_a: Hsla, stripe_b: Hsla, theme: &gpui_component::Theme, @@ -113,7 +111,7 @@ fn render_transfers_summary_progress( const SUMMARY_PROGRESS_W: gpui::Pixels = px(220.0); const SUMMARY_PROGRESS_H: gpui::Pixels = px(6.0); - match task.progress { + match progress { TransferProgress::Determinate(pct) => { let value = (pct.clamp(0.0, 1.0) * 100.0).clamp(0.0, 100.0); let progress = Progress::new(format!( From ea18ea92eab762625c308aef8eeeb1ce2122d527 Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 21:26:43 +0800 Subject: [PATCH 06/12] fix: align terminal sftp upload concurrency --- crates/gpui_term/src/terminal.rs | 137 ++++++++++++------ termua/src/panel/terminal_panel.rs | 21 --- termua/src/window/main_window/actions/sftp.rs | 11 +- 3 files changed, 96 insertions(+), 73 deletions(-) diff --git a/crates/gpui_term/src/terminal.rs b/crates/gpui_term/src/terminal.rs index 1d8f55c..3f620f2 100644 --- a/crates/gpui_term/src/terminal.rs +++ b/crates/gpui_term/src/terminal.rs @@ -120,10 +120,12 @@ pub enum Event { file: String, bytes: u64, }, - /// SFTP upload cancelled/aborted. + /// SFTP upload group cancelled/aborted. /// /// Currently this is emitted on failures (e.g. remote permission issues). - SftpUploadCancelled, + SftpUploadCancelled { + transfer_id: u64, + }, /// SFTP per-file upload cancelled. /// /// This is emitted when the embedding app toggles the `cancel` token in @@ -443,7 +445,7 @@ pub enum TerminalType { pub struct Terminal { backend_type: TerminalType, inner: Box, - sftp_upload_active: bool, + sftp_upload_active_groups: usize, sftp_upload_transfer_id: u64, } @@ -473,7 +475,7 @@ mod sftp_upload { use super::Event; pub(crate) struct StartParams { - pub(crate) paths: Vec, + pub(crate) files: Vec, pub(crate) sftp: Sftp, pub(crate) upload_pool: PermitPool, pub(crate) max_concurrency: usize, @@ -536,25 +538,25 @@ mod sftp_upload { }, } - fn set_upload_active(this: &WeakEntity, cx: &mut AsyncApp, active: bool) { - let _ = this.update(cx, move |this, _cx| { - this.sftp_upload_active = active; - }); - } - - fn cancel_upload(this: &WeakEntity, cx: &mut AsyncApp, toast: Option) { + fn cancel_upload( + this: &WeakEntity, + cx: &mut AsyncApp, + transfer_id: u64, + toast: Option, + ) { let _ = this.update(cx, |this, cx| { - this.sftp_upload_active = false; if let Some(toast) = toast { cx.emit(toast); } - cx.emit(Event::SftpUploadCancelled); + cx.emit(Event::SftpUploadCancelled { transfer_id }); + this.finish_sftp_upload_batch(); }); } fn cancel_upload_with_toast( this: &WeakEntity, cx: &mut AsyncApp, + transfer_id: u64, level: gpui::PromptLevel, title: String, detail: Option, @@ -562,6 +564,7 @@ mod sftp_upload { cancel_upload( this, cx, + transfer_id, Some(Event::Toast { level, title, @@ -613,7 +616,7 @@ mod sftp_upload { params: StartParams, ) { let StartParams { - paths, + files, sftp, upload_pool, max_concurrency, @@ -621,14 +624,13 @@ mod sftp_upload { remote_dir_hint, } = params; - set_upload_active(&this, cx, true); let Some(mut state) = prepare_upload( &this, cx, &sftp, &upload_pool, PrepareUploadParams { - paths, + files, transfer_id, remote_dir_hint: remote_dir_hint.as_deref(), max_concurrency, @@ -651,8 +653,8 @@ mod sftp_upload { .await; let _ = this.update(cx, |this, cx| { - this.sftp_upload_active = false; cx.emit(Event::SftpUploadFinished { files, total_bytes }); + this.finish_sftp_upload_batch(); }); } @@ -679,7 +681,7 @@ mod sftp_upload { } struct PrepareUploadParams<'a> { - paths: Vec, + files: Vec, transfer_id: u64, remote_dir_hint: Option<&'a str>, max_concurrency: usize, @@ -693,17 +695,17 @@ mod sftp_upload { params: PrepareUploadParams<'_>, ) -> Option { let PrepareUploadParams { - paths, + files, transfer_id, remote_dir_hint, max_concurrency, } = params; - let local_files = collect_local_files(paths); - if local_files.is_empty() { + if files.is_empty() { cancel_upload_with_toast( this, cx, + transfer_id, gpui::PromptLevel::Warning, "Nothing to upload".to_string(), Some("No files were selected.".to_string()), @@ -717,6 +719,7 @@ mod sftp_upload { cancel_upload_with_toast( this, cx, + transfer_id, gpui::PromptLevel::Critical, "Upload failed".to_string(), Some(format!("SFTP: failed to resolve remote directory: {err}")), @@ -727,12 +730,13 @@ mod sftp_upload { // Plan unique remote names up-front (best-effort), to avoid collisions across // concurrently executing upload tasks. - let planned = match plan_files(sftp, &remote_dir, local_files).await { + let planned = match plan_files(sftp, &remote_dir, files).await { Ok(planned) => planned, Err(PlanError::TooManyCollisions { remote_path }) => { cancel_upload_with_toast( this, cx, + transfer_id, gpui::PromptLevel::Critical, "Upload failed".to_string(), Some(format!( @@ -745,6 +749,7 @@ mod sftp_upload { cancel_upload_with_toast( this, cx, + transfer_id, gpui::PromptLevel::Critical, "Upload failed".to_string(), Some(format!("SFTP metadata failed for {remote_path}: {err}")), @@ -1374,7 +1379,7 @@ impl Terminal { Self { backend_type, inner, - sftp_upload_active: false, + sftp_upload_active_groups: 0, sftp_upload_transfer_id: 0, } } @@ -1386,7 +1391,7 @@ impl Terminal { pub fn replace_backend(&mut self, backend_type: TerminalType, inner: Box) { self.backend_type = backend_type; self.inner = inner; - self.sftp_upload_active = false; + self.sftp_upload_active_groups = 0; self.sftp_upload_transfer_id = 0; } @@ -1601,37 +1606,31 @@ impl Terminal { } pub fn sftp_upload_is_active(&self) -> bool { - self.sftp_upload_active + self.sftp_upload_active_groups > 0 } /// Starts uploading local files to the remote current directory via SFTP. /// /// For non-SSH terminals, `self.sftp()` returns `None` and this is a no-op. pub fn start_sftp_upload(&mut self, paths: Vec, cx: &mut Context) { - if self.sftp_upload_active { - cx.emit(Event::Toast { - level: gpui::PromptLevel::Warning, - title: "Transfer in progress".to_string(), - detail: Some("Wait for the current upload to finish.".to_string()), - }); - return; - } - - let max_concurrency = TerminalSettings::global(cx) - .sftp_upload_max_concurrency - .clamp(1, 15); - - let upload_pool = gpui_common::set_sftp_upload_permit_pool_max(cx, max_concurrency); - let Some(sftp) = self.sftp() else { return; }; self.sftp_upload_transfer_id = self.sftp_upload_transfer_id.wrapping_add(1); let transfer_id = self.sftp_upload_transfer_id; + let files = sftp_upload::collect_local_files(paths); + self.emit_sftp_upload_file_placeholders(transfer_id, &files, cx); + + self.sftp_upload_active_groups = self.sftp_upload_active_groups.saturating_add(1); + + let max_concurrency = TerminalSettings::global(cx) + .sftp_upload_max_concurrency + .clamp(1, 15); + let upload_pool = gpui_common::set_sftp_upload_permit_pool_max(cx, max_concurrency); let params = sftp_upload::StartParams { - paths, + files, sftp, upload_pool, max_concurrency, @@ -1639,11 +1638,32 @@ impl Terminal { remote_dir_hint: self.current_dir(), }; - // Note: do *not* assume `paths` is non-empty or all are files. cx.spawn(async move |this, cx| sftp_upload::run_start(this, cx, params).await) .detach(); } + fn finish_sftp_upload_batch(&mut self) { + self.sftp_upload_active_groups = self.sftp_upload_active_groups.saturating_sub(1); + } + + fn emit_sftp_upload_file_placeholders( + &self, + transfer_id: u64, + files: &[sftp_upload::LocalUploadFile], + cx: &mut Context, + ) { + for (file_index, file) in files.iter().enumerate() { + cx.emit(Event::SftpUploadFileProgress { + transfer_id, + file_index, + file: file.name.clone(), + sent: 0, + total: file.size, + cancel: Arc::clone(&file.cancel), + }); + } + } + pub fn focus_in(&self) { self.inner.focus_in() } @@ -1828,6 +1848,33 @@ fn plain_text_for_keystroke(keystroke: &Keystroke) -> Option { .filter(|v| !v.is_empty()) } +#[cfg(test)] +mod sftp_upload_activity_tests { + use std::sync::{Arc, atomic::AtomicUsize}; + + use super::*; + + #[test] + fn multiple_upload_groups_can_be_active_together() { + let mut terminal = Terminal::new( + TerminalType::WezTerm, + Box::new(cast_recording_tests::FakeBackend { + active: false, + stop_calls: Arc::new(AtomicUsize::new(0)), + }), + ); + + terminal.sftp_upload_active_groups = 2; + assert!(terminal.sftp_upload_is_active()); + + terminal.finish_sftp_upload_batch(); + assert!(terminal.sftp_upload_is_active()); + + terminal.finish_sftp_upload_batch(); + assert!(!terminal.sftp_upload_is_active()); + } +} + #[cfg(test)] mod cast_recording_tests { use std::sync::{ @@ -1837,9 +1884,9 @@ mod cast_recording_tests { use super::*; - struct FakeBackend { - active: bool, - stop_calls: Arc, + pub(super) struct FakeBackend { + pub(super) active: bool, + pub(super) stop_calls: Arc, } impl TerminalBackend for FakeBackend { diff --git a/termua/src/panel/terminal_panel.rs b/termua/src/panel/terminal_panel.rs index b18c684..7741868 100644 --- a/termua/src/panel/terminal_panel.rs +++ b/termua/src/panel/terminal_panel.rs @@ -220,16 +220,6 @@ impl TerminalPanel { return; } - if terminal.sftp_upload_is_active() { - self.notify( - MessageKind::Warning, - "Transfer in progress", - Some("Wait for the current upload to finish before starting another."), - window, - cx, - ); - return; - } let _ = terminal; self.pending_sftp_upload = Some(PendingSftpUpload { @@ -264,17 +254,6 @@ impl TerminalPanel { self.terminal_view.update(cx, |terminal_view, cx| { terminal_view.terminal.update(cx, |terminal, cx| { - if terminal.sftp_upload_is_active() { - cx.emit(gpui_term::Event::Toast { - level: gpui::PromptLevel::Warning, - title: "Transfer in progress".to_string(), - detail: Some( - "Wait for the current upload to finish before starting another." - .to_string(), - ), - }); - return; - } terminal.start_sftp_upload(dialog.paths, cx); }); }); diff --git a/termua/src/window/main_window/actions/sftp.rs b/termua/src/window/main_window/actions/sftp.rs index fb4c25b..ebf1fde 100644 --- a/termua/src/window/main_window/actions/sftp.rs +++ b/termua/src/window/main_window/actions/sftp.rs @@ -17,9 +17,6 @@ use crate::{ }; impl TermuaWindow { - fn sftp_upload_panel_prefix(panel_id: usize) -> String { - format!("sftp-upload-{panel_id}-") - } fn sftp_upload_group_id(panel_id: usize, transfer_id: u64) -> String { format!("sftp-upload-{panel_id}-{transfer_id}") @@ -253,8 +250,8 @@ impl TermuaWindow { ); true } - TerminalEvent::SftpUploadCancelled => { - Self::handle_sftp_upload_cancelled(panel_id, tab_label, window, cx); + TerminalEvent::SftpUploadCancelled { transfer_id } => { + Self::handle_sftp_upload_cancelled(panel_id, transfer_id, tab_label, window, cx); true } TerminalEvent::SftpUploadFileCancelled { @@ -341,6 +338,7 @@ impl TermuaWindow { fn handle_sftp_upload_cancelled( panel_id: usize, + transfer_id: &u64, tab_label: &SharedString, window: &mut Window, cx: &mut Context, @@ -354,7 +352,7 @@ impl TermuaWindow { if cx.try_global::().is_some() { cx.global_mut::() - .remove_groups_with_prefix(Self::sftp_upload_panel_prefix(panel_id).as_str()); + .remove_group(Self::sftp_upload_group_id(panel_id, *transfer_id).as_str()); } } @@ -427,7 +425,6 @@ mod tests { #[test] fn sftp_transfer_keys_are_stable() { - assert_eq!(TermuaWindow::sftp_upload_panel_prefix(7), "sftp-upload-7-"); assert_eq!(TermuaWindow::sftp_upload_group_id(7, 9), "sftp-upload-7-9"); assert_eq!( TermuaWindow::sftp_upload_task_id(7, 9, 2), From 3a1ca531812d43ec94dc7e29df1b9067e4053880 Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 21:31:59 +0800 Subject: [PATCH 07/12] Update sftp.rs --- termua/src/window/main_window/actions/sftp.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/termua/src/window/main_window/actions/sftp.rs b/termua/src/window/main_window/actions/sftp.rs index ebf1fde..858480f 100644 --- a/termua/src/window/main_window/actions/sftp.rs +++ b/termua/src/window/main_window/actions/sftp.rs @@ -17,7 +17,6 @@ use crate::{ }; impl TermuaWindow { - fn sftp_upload_group_id(panel_id: usize, transfer_id: u64) -> String { format!("sftp-upload-{panel_id}-{transfer_id}") } From f35fb60c37fcf2f404acd5af2e7e85457750e4ef Mon Sep 17 00:00:00 2001 From: iamazy Date: Sun, 12 Jul 2026 21:39:57 +0800 Subject: [PATCH 08/12] chore: upgrade to 0.1.3 --- Cargo.lock | 16 ++++++++-------- Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 87a51ae..87ace7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3002,7 +3002,7 @@ dependencies = [ [[package]] name = "gpui_common" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "gpui", @@ -3014,7 +3014,7 @@ dependencies = [ [[package]] name = "gpui_dock" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "gpui", @@ -3147,7 +3147,7 @@ dependencies = [ [[package]] name = "gpui_sftp" -version = "0.1.2" +version = "0.1.3" dependencies = [ "camino", "gpui", @@ -3174,7 +3174,7 @@ dependencies = [ [[package]] name = "gpui_term" -version = "0.1.2" +version = "0.1.3" dependencies = [ "alacritty_terminal", "anyhow", @@ -3214,7 +3214,7 @@ dependencies = [ [[package]] name = "gpui_transfer" -version = "0.1.2" +version = "0.1.3" dependencies = [ "gpui", ] @@ -4660,7 +4660,7 @@ dependencies = [ [[package]] name = "menubar" -version = "0.1.2" +version = "0.1.3" dependencies = [ "env_logger", "gpui", @@ -7711,7 +7711,7 @@ dependencies = [ [[package]] name = "termua" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "async-tungstenite", @@ -7748,7 +7748,7 @@ dependencies = [ [[package]] name = "termua_zeroclaw" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index 364ad40..46cae4d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ [workspace.package] edition = "2024" -version = "0.1.2" +version = "0.1.3" license = "AGPL-3" authors = ["iamazy "] documentation = "https://github.com/iamazy/termua" From 589bca43d05392322e6c60bc66458a88c6941474 Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 13 Jul 2026 22:06:30 +0800 Subject: [PATCH 09/12] fix: refine sftp selection behavior --- crates/gpui_sftp/src/view/mod.rs | 49 ++++---- crates/gpui_sftp/src/view/render.rs | 15 ++- crates/gpui_sftp/src/view/table/delegate.rs | 18 ++- crates/gpui_sftp/src/view/table/navigation.rs | 32 +++++ crates/gpui_sftp/src/view/tests.rs | 117 ++++++++++++++++++ 5 files changed, 204 insertions(+), 27 deletions(-) diff --git a/crates/gpui_sftp/src/view/mod.rs b/crates/gpui_sftp/src/view/mod.rs index 8f04b71..cb90869 100644 --- a/crates/gpui_sftp/src/view/mod.rs +++ b/crates/gpui_sftp/src/view/mod.rs @@ -5,15 +5,15 @@ use std::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, }, - time::{Duration, Instant}, }; use camino::Utf8PathBuf; use gpui::{ App, AppContext, Bounds, Context, Div, Entity, EventEmitter, ExternalPaths, FocusHandle, Focusable, Image, ImageSource, InteractiveElement, IntoElement, MouseButton, MouseDownEvent, - ParentElement, PathPromptOptions, PromptLevel, Render, SharedString, Stateful, Styled, - StyledImage, Subscription, Window, canvas, div, img, prelude::FluentBuilder, px, + MouseMoveEvent, MouseUpEvent, ParentElement, PathPromptOptions, PromptLevel, Render, + SharedString, Stateful, Styled, StyledImage, Subscription, Window, canvas, div, img, + prelude::FluentBuilder, px, }; use gpui_common::TermuaIcon; use gpui_component::{ @@ -224,6 +224,7 @@ struct SftpTable { selected_ids: HashSet, selection_anchor_id: Option, + drag_selecting: bool, columns: Vec, sort: SortSpec, @@ -241,7 +242,6 @@ pub struct SftpView { path_input: Entity, path_editing: bool, table_bounds: Bounds, - last_row_activate: Option<(usize, Instant)>, preview: PreviewPane, preview_epoch: usize, show_preview: bool, @@ -280,6 +280,24 @@ where cx.new(|cx| configure(InputState::new(window, cx).placeholder(placeholder))) } +fn table_event_activation_row(ev: &TableEvent) -> Option { + match ev { + TableEvent::DoubleClickedRow(row_ix) | TableEvent::DoubleClickedCell(row_ix, _) => { + Some(*row_ix) + } + _ => None, + } +} + +fn begin_blank_table_drag_select( + state: &mut TableState, + cx: &mut Context>, +) { + state.clear_selection(cx); + state.delegate_mut().begin_blank_drag_select(); + cx.notify(); +} + impl SftpView { pub fn new(sftp: wezterm_ssh::Sftp, window: &mut Window, cx: &mut Context) -> Self { let focus_handle = cx.focus_handle(); @@ -304,7 +322,6 @@ impl SftpView { path_input, path_editing: false, table_bounds: Bounds::default(), - last_row_activate: None, preview: PreviewPane { target: None, content: PreviewContent::Empty, @@ -385,16 +402,11 @@ impl SftpView { ev: &TableEvent, cx: &mut Context, ) { - // Prefer Table's native double-click events, but also provide a fallback path: - // on some platforms/backends, click_count can be unreliable, so we treat a fast - // repeated SelectRow on the same row as an "activate". - let mut activate_row: Option = None; + let activate_row = table_event_activation_row(ev); match ev { - TableEvent::DoubleClickedRow(row_ix) => activate_row = Some(*row_ix), - TableEvent::DoubleClickedCell(row_ix, _col_ix) => activate_row = Some(*row_ix), TableEvent::SelectRow(row_ix) => { - self.handle_table_row_selected(table, *row_ix, &mut activate_row, cx); + self.handle_table_row_selected(table, *row_ix, cx); } TableEvent::ClearSelection => self.close_preview(cx), _ => {} @@ -418,7 +430,6 @@ impl SftpView { &mut self, table: &Entity>, row_ix: usize, - activate_row: &mut Option, cx: &mut Context, ) { let target = table @@ -444,18 +455,6 @@ impl SftpView { } PreviewGate::Hidden | PreviewGate::TooLarge { .. } => self.close_preview(cx), } - - let now = Instant::now(); - // Use a short threshold similar to typical OS double-click timing. - let threshold = Duration::from_millis(450); - let is_fast_repeat = self - .last_row_activate - .map(|(prev_ix, prev_at)| prev_ix == row_ix && now.duration_since(prev_at) <= threshold) - .unwrap_or(false); - self.last_row_activate = Some((row_ix, now)); - if is_fast_repeat { - *activate_row = Some(row_ix); - } } pub fn disconnect(&mut self, cx: &mut Context) { diff --git a/crates/gpui_sftp/src/view/render.rs b/crates/gpui_sftp/src/view/render.rs index ae8bc2d..a465fdb 100644 --- a/crates/gpui_sftp/src/view/render.rs +++ b/crates/gpui_sftp/src/view/render.rs @@ -64,7 +64,7 @@ impl SftpView { .on_any_mouse_down({ let view_handle = view_handle.clone(); let table = table.clone(); - move |_ev, window, cx| { + move |ev, window, cx| { let bounds = view_handle.read(cx).table_bounds; if bounds.size.width <= px(0.0) || bounds.size.height <= px(0.0) { return; @@ -90,9 +90,22 @@ impl SftpView { let ix = table_row_ix_from_mouse_y(body_y, scroll_y, row_h); if ix.is_some_and(|ix| table.read(cx).delegate().row(ix).is_some()) { cx.stop_propagation(); + } else if ev.button == MouseButton::Left { + table.update(cx, |state, cx| { + begin_blank_table_drag_select(state, cx); + }); } } }) + .on_mouse_up(MouseButton::Left, { + let table = table.clone(); + move |_ev, _window, cx| { + table.update(cx, |state, cx| { + state.delegate_mut().end_drag_select(); + cx.notify(); + }); + } + }) .child( canvas( { diff --git a/crates/gpui_sftp/src/view/table/delegate.rs b/crates/gpui_sftp/src/view/table/delegate.rs index ede570e..8e3cbbf 100644 --- a/crates/gpui_sftp/src/view/table/delegate.rs +++ b/crates/gpui_sftp/src/view/table/delegate.rs @@ -75,11 +75,27 @@ impl TableDelegate for SftpTable { .on_mouse_down( MouseButton::Left, cx.listener(move |table, ev: &MouseDownEvent, _window, cx| { - table.delegate_mut().click_row_local(row_ix, ev.modifiers); + table.delegate_mut().begin_drag_select(row_ix, ev.modifiers); // Keep Table's internal focus row in sync for keyboard navigation / preview. table.set_selected_row(row_ix, cx); cx.notify(); }), + ) + .on_mouse_move(cx.listener(move |table, ev: &MouseMoveEvent, _window, cx| { + if ev.pressed_button != Some(MouseButton::Left) { + table.delegate_mut().end_drag_select(); + return; + } + + table.delegate_mut().drag_select_row(row_ix); + table.set_selected_row(row_ix, cx); + cx.notify(); + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |table, _ev: &MouseUpEvent, _window, _cx| { + table.delegate_mut().end_drag_select(); + }), ); if row.kind != EntryKind::Dir { diff --git a/crates/gpui_sftp/src/view/table/navigation.rs b/crates/gpui_sftp/src/view/table/navigation.rs index 5e40b30..fca7477 100644 --- a/crates/gpui_sftp/src/view/table/navigation.rs +++ b/crates/gpui_sftp/src/view/table/navigation.rs @@ -17,6 +17,7 @@ impl SftpTable { show_hidden: false, selected_ids: HashSet::new(), selection_anchor_id: None, + drag_selecting: false, columns: sftp_table_columns(), sort, visible, @@ -96,6 +97,37 @@ impl SftpTable { self.selection_anchor_id = Some(target_id); } + pub(in crate::view) fn begin_drag_select(&mut self, row_ix: usize, modifiers: gpui::Modifiers) { + self.click_row_local(row_ix, modifiers); + self.drag_selecting = true; + } + + pub(in crate::view) fn begin_blank_drag_select(&mut self) { + self.clear_selection(); + self.drag_selecting = true; + } + + pub(in crate::view) fn drag_select_row(&mut self, row_ix: usize) { + if !self.drag_selecting { + return; + } + let Some(row) = self.row(row_ix) else { + return; + }; + self.selected_ids.insert(row.id.clone()); + } + + pub(in crate::view) fn end_drag_select(&mut self) { + self.drag_selecting = false; + } + + pub(in crate::view) fn clear_selection(&mut self) { + self.selected_ids.clear(); + self.selection_anchor_id = None; + self.context_row = None; + self.drag_selecting = false; + } + pub(in crate::view) fn set_context_menu_target(&mut self, row_ix: Option) { self.context_row = row_ix; diff --git a/crates/gpui_sftp/src/view/tests.rs b/crates/gpui_sftp/src/view/tests.rs index ed576e7..3bff4cf 100644 --- a/crates/gpui_sftp/src/view/tests.rs +++ b/crates/gpui_sftp/src/view/tests.rs @@ -14,6 +14,7 @@ fn delegate_with_tree(tree: TreeState) -> SftpTable { show_hidden: false, selected_ids: std::collections::HashSet::new(), selection_anchor_id: None, + drag_selecting: false, columns: sftp_table_columns(), sort, visible: Vec::new(), @@ -339,6 +340,122 @@ fn selection_shift_click_selects_range() { ); } +#[test] +fn selection_dragging_over_rows_adds_to_selection() { + let mut tree = TreeState::new(Entry::new("/", "/", EntryKind::Dir)); + tree.upsert_children( + "/", + vec![ + Entry::new("/a", "a", EntryKind::File), + Entry::new("/b", "b", EntryKind::File), + Entry::new("/c", "c", EntryKind::Dir), + Entry::new("/d", "d", EntryKind::File), + ], + ); + let mut d = delegate_with_tree(tree); + d.rebuild_visible(); + + d.begin_drag_select(0, gpui::Modifiers::none()); + d.drag_select_row(1); + d.drag_select_row(2); + assert_eq!( + d.selected_ids_sorted(), + vec!["/a".to_string(), "/b".to_string(), "/c".to_string()] + ); + + d.end_drag_select(); + d.drag_select_row(3); + assert_eq!( + d.selected_ids_sorted(), + vec!["/a".to_string(), "/b".to_string(), "/c".to_string()] + ); +} + +#[test] +fn selection_dragging_from_blank_area_selects_rows_moved_over() { + let mut tree = TreeState::new(Entry::new("/", "/", EntryKind::Dir)); + tree.upsert_children( + "/", + vec![ + Entry::new("/a", "a", EntryKind::File), + Entry::new("/b", "b", EntryKind::File), + Entry::new("/c", "c", EntryKind::File), + ], + ); + let mut d = delegate_with_tree(tree); + d.rebuild_visible(); + + d.begin_blank_drag_select(); + d.drag_select_row(1); + d.drag_select_row(2); + + assert_eq!( + d.selected_ids_sorted(), + vec!["/b".to_string(), "/c".to_string()] + ); +} + +#[test] +fn clear_selection_removes_selected_rows_and_anchor() { + let mut tree = TreeState::new(Entry::new("/", "/", EntryKind::Dir)); + tree.upsert_children( + "/", + vec![ + Entry::new("/a", "a", EntryKind::File), + Entry::new("/b", "b", EntryKind::File), + ], + ); + let mut d = delegate_with_tree(tree); + d.rebuild_visible(); + + d.click_row_local(0, gpui::Modifiers::none()); + assert_eq!(d.selected_ids_sorted(), vec!["/a".to_string()]); + assert_eq!(d.selection_anchor_id.as_deref(), Some("/a")); + + d.clear_selection(); + + assert!(d.selected_ids_sorted().is_empty()); + assert_eq!(d.selection_anchor_id, None); +} + +#[gpui::test] +fn begin_blank_table_drag_select_clears_selection_and_starts_drag(cx: &mut gpui::TestAppContext) { + gpui_component::init(&mut cx.app.borrow_mut()); + + let mut tree = TreeState::new(Entry::new("/", "/", EntryKind::Dir)); + tree.upsert_children("/", vec![Entry::new("/a", "a", EntryKind::File)]); + let mut d = delegate_with_tree(tree); + d.rebuild_visible(); + + let (table, cx) = cx.add_window_view(|window, cx| TableState::new(d, window, cx)); + table.update(cx, |state, cx| { + state + .delegate_mut() + .click_row_local(0, gpui::Modifiers::none()); + state.set_selected_row(0, cx); + + begin_blank_table_drag_select(state, cx); + + assert!(state.delegate().selected_ids_sorted().is_empty()); + assert_eq!(state.delegate().selection_anchor_id, None); + assert!(state.delegate().drag_selecting); + assert_eq!(state.selected_row(), None); + }); +} + +#[test] +fn select_row_event_does_not_activate_directory() { + assert_eq!(table_event_activation_row(&TableEvent::SelectRow(2)), None); + assert_eq!( + table_event_activation_row(&TableEvent::DoubleClickedRow(2)), + Some(2) + ); + assert_eq!( + table_event_activation_row(&TableEvent::DoubleClickedCell(2, 0)), + Some(2) + ); +} + #[test] fn context_menu_target_right_click_selects_row_when_not_selected() { let mut tree = TreeState::new(Entry::new("/", "/", EntryKind::Dir)); From 7400af3c62e5f05d881346e8eeb05840a2cfdedf Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 13 Jul 2026 22:08:07 +0800 Subject: [PATCH 10/12] chore: import deps --- crates/gpui_sftp/src/view/table/actions.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/gpui_sftp/src/view/table/actions.rs b/crates/gpui_sftp/src/view/table/actions.rs index 95054eb..5875dd7 100644 --- a/crates/gpui_sftp/src/view/table/actions.rs +++ b/crates/gpui_sftp/src/view/table/actions.rs @@ -1,3 +1,5 @@ +use std::time::{Duration, Instant}; + use rust_i18n::t; use super::*; From 369421f707949a0334ea8a787a30454ed97870e4 Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 13 Jul 2026 22:36:11 +0800 Subject: [PATCH 11/12] fix: make lock password reveal press-and-hold --- termua/src/lock_screen/mod.rs | 25 ++++++++++ termua/src/lock_screen/overlay.rs | 8 +++- termua/src/lock_screen/view.rs | 77 +++++++++++++++++++++++++++++-- 3 files changed, 104 insertions(+), 6 deletions(-) diff --git a/termua/src/lock_screen/mod.rs b/termua/src/lock_screen/mod.rs index 23529dd..6677be8 100644 --- a/termua/src/lock_screen/mod.rs +++ b/termua/src/lock_screen/mod.rs @@ -299,6 +299,31 @@ mod tests { assert!(!state.locked()); } + #[test] + fn lock_password_reveal_is_press_and_hold() { + assert_eq!( + ( + super::view::lock_password_masked_for_reveal(true), + super::view::lock_password_masked_for_reveal(false) + ), + (false, true) + ); + } + + #[test] + fn lock_password_reveal_icon_changes_while_pressed() { + use gpui_component::IconNamed; + + assert_eq!( + super::view::lock_password_reveal_icon(false).path(), + gpui_component::IconName::Eye.path() + ); + assert_eq!( + super::view::lock_password_reveal_icon(true).path(), + gpui_component::IconName::EyeOff.path() + ); + } + #[test] fn lock_state_default_disables_lock_when_username_unavailable() { struct VarGuard { diff --git a/termua/src/lock_screen/overlay.rs b/termua/src/lock_screen/overlay.rs index 561609b..661266e 100644 --- a/termua/src/lock_screen/overlay.rs +++ b/termua/src/lock_screen/overlay.rs @@ -6,6 +6,7 @@ use super::LockState; pub struct LockOverlayState { pub password_input: Entity, + pub password_reveal_pressed: Entity, pub error: Option, } @@ -31,9 +32,9 @@ impl LockOverlayState { .placeholder(t!("LockScreen.Placeholder.Password").to_string()) .masked(true) }); - Self { password_input, + password_reveal_pressed: cx.new(|_| false), error: None, } } @@ -50,12 +51,17 @@ impl LockOverlayState { Some(crate::lock_screen::view::render_lock_overlay( self.error.clone(), self.password_input.clone(), + self.password_reveal_pressed.clone(), unlock, cx, )) } fn clear_password_input(&mut self, window: &mut Window, cx: &mut Context) { + self.password_reveal_pressed.update(cx, |reveal, cx| { + *reveal = false; + cx.notify(); + }); self.password_input.update(cx, |state, cx| { state.set_value("", window, cx); state.set_masked(true, window, cx); diff --git a/termua/src/lock_screen/view.rs b/termua/src/lock_screen/view.rs index c6098b6..f7a2b0d 100644 --- a/termua/src/lock_screen/view.rs +++ b/termua/src/lock_screen/view.rs @@ -1,17 +1,78 @@ use gpui::{ - AnyElement, Context, CursorStyle, InteractiveElement as _, IntoElement as _, MouseButton, - ParentElement as _, SharedString, Styled, Window, div, prelude::FluentBuilder as _, + AnyElement, App, Context, CursorStyle, Entity, InteractiveElement as _, IntoElement, + MouseButton, ParentElement as _, SharedString, Styled, Window, div, + prelude::FluentBuilder as _, }; use gpui_component::{ - ActiveTheme as _, + ActiveTheme as _, IconName, Sizable as _, + button::{Button, ButtonVariants as _}, input::{Input, InputState}, v_flex, }; use rust_i18n::t; +pub(super) fn lock_password_masked_for_reveal(reveal: bool) -> bool { + !reveal +} + +pub(super) fn lock_password_reveal_icon(reveal: bool) -> IconName { + if reveal { + IconName::EyeOff + } else { + IconName::Eye + } +} + +fn render_lock_password_reveal_button( + password_input: Entity, + reveal_pressed: Entity, + cx: &mut Context, +) -> impl IntoElement { + let reveal = *reveal_pressed.read(cx); + div() + .debug_selector(|| "termua-lock-password-reveal".to_string()) + .on_mouse_down(MouseButton::Left, { + let password_input = password_input.clone(); + let reveal_pressed = reveal_pressed.clone(); + move |_ev, window, cx| { + set_lock_password_reveal(&password_input, &reveal_pressed, true, window, cx); + } + }) + .on_mouse_up(MouseButton::Left, move |_ev, window, cx| { + set_lock_password_reveal(&password_input, &reveal_pressed, false, window, cx); + }) + .child( + Button::new("termua-lock-password-reveal-button") + .icon(lock_password_reveal_icon(reveal)) + .xsmall() + .ghost() + .tab_stop(false), + ) +} + +fn set_lock_password_reveal( + password_input: &Entity, + reveal_pressed: &Entity, + reveal: bool, + window: &mut Window, + cx: &mut App, +) { + reveal_pressed.update(cx, |pressed, cx| { + *pressed = reveal; + cx.notify(); + }); + password_input.update(cx, |state, cx| { + state.set_masked(lock_password_masked_for_reveal(reveal), window, cx); + }); + cx.refresh_windows(); + window.prevent_default(); + cx.stop_propagation(); +} + pub fn render_lock_overlay( lock_error: Option, - lock_password_input: gpui::Entity, + lock_password_input: Entity, + lock_password_reveal_pressed: Entity, unlock: fn(&mut T, &mut Window, &mut Context), cx: &mut Context, ) -> AnyElement { @@ -76,7 +137,13 @@ pub fn render_lock_overlay( .child( div() .debug_selector(|| "termua-lock-password-input".to_string()) - .child(Input::new(&lock_password_input).mask_toggle()), + .child(Input::new(&lock_password_input).suffix( + render_lock_password_reveal_button( + lock_password_input.clone(), + lock_password_reveal_pressed.clone(), + cx, + ), + )), ), ), ) From a5ebcd7ce6f93fe22eaeeeb13d65a25cc12cf39b Mon Sep 17 00:00:00 2001 From: iamazy Date: Mon, 13 Jul 2026 22:58:53 +0800 Subject: [PATCH 12/12] Update view.rs --- termua/src/lock_screen/view.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/termua/src/lock_screen/view.rs b/termua/src/lock_screen/view.rs index f7a2b0d..5e83b9f 100644 --- a/termua/src/lock_screen/view.rs +++ b/termua/src/lock_screen/view.rs @@ -140,7 +140,7 @@ pub fn render_lock_overlay( .child(Input::new(&lock_password_input).suffix( render_lock_password_reveal_button( lock_password_input.clone(), - lock_password_reveal_pressed.clone(), + lock_password_reveal_pressed, cx, ), )),