diff --git a/crates/nyaterm-desktop/src/features/connection_editor_window.rs b/crates/nyaterm-desktop/src/features/connection_editor_window.rs index 8eb651198..66258d4fb 100644 --- a/crates/nyaterm-desktop/src/features/connection_editor_window.rs +++ b/crates/nyaterm-desktop/src/features/connection_editor_window.rs @@ -4,10 +4,14 @@ use gpui::{ }; use super::{NyaTermApp, child_window_header, child_window_titlebar}; +use crate::models::ConnectionEditorField; pub(in crate::features) struct ConnectionEditorWindow { app: Entity, _app_subscription: Subscription, + /// Focus is per-window, so the field the main window focused means nothing + /// here; this window has to claim it on its own first frame. + focused_initial_field: bool, } impl ConnectionEditorWindow { @@ -16,6 +20,7 @@ impl ConnectionEditorWindow { Self { app, _app_subscription: app_subscription, + focused_initial_field: false, } } } @@ -40,6 +45,20 @@ impl Render for ConnectionEditorWindow { ) }); window.set_window_title(&title); + if !self.focused_initial_field { + self.focused_initial_field = true; + let field = self + .app + .read(cx) + .connection_state + .editor_fields() + .get(&ConnectionEditorField::Name) + .cloned(); + if let Some(field) = field { + window.focus(&field.read(cx).focus_handle()); + field.update(cx, |field, cx| field.select_all(window, cx)); + } + } let content = self .app .update(cx, |app, cx| app.connection_editor_window_view(editor, cx)); diff --git a/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs b/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs index 1ff1bdea0..0139d1104 100644 --- a/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs +++ b/crates/nyaterm-desktop/src/features/connections/connection_runtime/editor.rs @@ -102,16 +102,33 @@ impl NyaTermApp { }; self.connection_state.editor.begin_edit(editor); + // Fields mirror the draft, so they are rebuilt with it. + self.connection_state.build_editor_fields(cx); self.terminal.view.status = "connection editor opened".to_string(); if !self.open_connection_editor_window(cx) { - let editor_focus = self.connection_state.editor.focus_handle(); - window.focus(&editor_focus); + // Land on the name and select it, so an edit can start by typing. + match self + .connection_state + .editor_fields() + .get(&ConnectionEditorField::Name) + .cloned() + { + Some(field) => { + window.focus(&field.read(cx).focus_handle()); + field.update(cx, |field, cx| field.select_all(window, cx)); + } + None => { + let editor_focus = self.connection_state.editor.focus_handle(); + window.focus(&editor_focus); + } + } } cx.notify(); } pub(in crate::features) fn close_connection_editor(&mut self, cx: &mut Context) { self.connection_state.editor.close(); + self.connection_state.clear_editor_fields(); self.terminal.view.status = "connection editor closed".to_string(); cx.notify(); } @@ -188,6 +205,17 @@ impl NyaTermApp { cx.notify(); } + /// Take an edit from a field widget into the draft. + pub(in crate::features) fn apply_connection_editor_field_text( + &mut self, + field: ConnectionEditorField, + text: String, + cx: &mut Context, + ) { + self.connection_state.set_editor_field_text(field, text); + cx.notify(); + } + pub(in crate::features) fn focus_connection_editor_field( &mut self, field: ConnectionEditorField, diff --git a/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs b/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs index b765045f8..59af98297 100644 --- a/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs +++ b/crates/nyaterm-desktop/src/features/connections/connection_runtime/groups.rs @@ -43,14 +43,17 @@ impl NyaTermApp { parent_id, error: None, }); + self.connection_state.build_group_editor_field(cx); self.terminal.view.status = "connection group editor opened".to_string(); - let group_editor_focus = self.connection_state.group_editor.focus_handle(); - window.focus(&group_editor_focus); + if let Some(field) = self.connection_state.group_editor_field() { + window.focus(&field.read(cx).focus_handle()); + } cx.notify(); } pub(in crate::features) fn close_connection_group_editor(&mut self, cx: &mut Context) { self.connection_state.group_editor.close(); + self.connection_state.clear_group_editor_field(); self.terminal.view.status = "connection group editor closed".to_string(); cx.notify(); } diff --git a/crates/nyaterm-desktop/src/features/connections/state.rs b/crates/nyaterm-desktop/src/features/connections/state.rs index bd05064e0..5858fd768 100644 --- a/crates/nyaterm-desktop/src/features/connections/state.rs +++ b/crates/nyaterm-desktop/src/features/connections/state.rs @@ -1,4 +1,4 @@ -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use gpui::{ App, AppContext as _, Context, Entity, FocusHandle, Pixels, SharedString, Subscription, @@ -30,9 +30,9 @@ use self::editor_logic::{ apply_connection_group_editor_name_key, clear_connection_editor_group_menu_draft, clear_connection_editor_runtime_state, commit_connection_editor_new_group, connection_editor_inline_panel_draft, connection_editor_window_open_or_pending, - finish_connection_editor_save_state, focus_connection_editor_field, + editor_field_seeds, finish_connection_editor_save_state, focus_connection_editor_field, insert_connection_editor_description_newline, set_connection_editor_advanced_tab, - set_connection_editor_error, set_connection_editor_icon, + set_connection_editor_error, set_connection_editor_field_text, set_connection_editor_icon, set_connection_editor_icon_auto_detect, set_connection_editor_kind, set_connection_editor_menu_value, set_connection_editor_password_source, set_connection_editor_telnet_tab, set_connection_group_editor_error, @@ -120,6 +120,14 @@ pub(in crate::features) struct ConnectionImportState { pub(in crate::features) struct ConnectionEditorFeatureState { draft: Option, + /// One editable field per text input, built when the editor opens. + /// + /// The draft above stays the source of truth for saving; these own the + /// caret, selection and composition, and write back through their + /// subscriptions. Keeping them out of `ConnectionEditorState` keeps that + /// model a plain value the runtime can clone. + fields: HashMap>, + field_subscriptions: Vec, window: Option>, window_open_pending: bool, focus: FocusHandle, @@ -129,6 +137,9 @@ pub(in crate::features) struct ConnectionEditorFeatureState { pub(in crate::features) struct ConnectionGroupEditorFeatureState { draft: Option, + /// The folder-name input, built with the draft it mirrors. + field: Option>, + field_subscription: Option, focus: FocusHandle, } @@ -205,6 +216,8 @@ impl ConnectionFeatureState { }, editor: ConnectionEditorFeatureState { draft: None, + fields: HashMap::new(), + field_subscriptions: Vec::new(), window: None, window_open_pending: false, focus: focus.editor, @@ -213,6 +226,8 @@ impl ConnectionFeatureState { }, group_editor: ConnectionGroupEditorFeatureState { draft: None, + field: None, + field_subscription: None, focus: focus.group_editor, }, confirmations: ConnectionConfirmationState { @@ -239,6 +254,75 @@ impl ConnectionFeatureState { } } + /// The editor's fields, for handing to the render sections. + pub fn editor_fields(&self) -> &HashMap> { + &self.editor.fields + } + + /// Build a field per input and wire each back into the draft. + /// + /// Called when the editor opens, so the entities live exactly as long as the + /// draft they mirror and never leak between edits. + pub fn build_editor_fields(&mut self, cx: &mut Context) { + self.editor.fields.clear(); + self.editor.field_subscriptions.clear(); + let Some(draft) = self.editor.draft.as_ref() else { + return; + }; + for (field, value, masked) in editor_field_seeds(draft) { + let entity = cx.new(|cx| TextField::new(cx, value).masked(masked)); + let subscription = cx.subscribe(&entity, move |app: &mut NyaTermApp, _, event, cx| { + let TextFieldEvent::Changed(text) = event; + app.apply_connection_editor_field_text(field, text.clone(), cx); + }); + self.editor.fields.insert(field, entity); + self.editor.field_subscriptions.push(subscription); + } + } + + pub fn set_editor_field_text(&mut self, field: ConnectionEditorField, text: String) { + if let Some(draft) = self.editor.draft.as_mut() { + set_connection_editor_field_text(draft, field, text); + } + } + + pub fn group_editor_field(&self) -> Option> { + self.group_editor.field.clone() + } + + pub fn build_group_editor_field(&mut self, cx: &mut Context) { + let Some(draft) = self.group_editor.draft.as_ref() else { + self.group_editor.field = None; + self.group_editor.field_subscription = None; + return; + }; + let entity = cx.new(|cx| TextField::new(cx, draft.name.clone())); + let subscription = cx.subscribe(&entity, |app: &mut NyaTermApp, _, event, cx| { + let TextFieldEvent::Changed(text) = event; + app.connection_state.set_group_editor_name(text.clone()); + cx.notify(); + }); + self.group_editor.field = Some(entity); + self.group_editor.field_subscription = Some(subscription); + } + + pub fn clear_group_editor_field(&mut self) { + self.group_editor.field = None; + self.group_editor.field_subscription = None; + } + + pub fn set_group_editor_name(&mut self, name: String) { + if let Some(draft) = self.group_editor.draft.as_mut() { + draft.name = name; + draft.error = None; + } + } + + pub fn clear_editor_fields(&mut self) { + self.editor.fields.clear(); + self.editor.field_subscriptions.clear(); + } + pub fn finish_editor_save(&mut self, connection_id: String, group_id: Option) { finish_connection_editor_save_state( &mut self.editor.draft, diff --git a/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs b/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs index 24c40bd38..40ed476e0 100644 --- a/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs +++ b/crates/nyaterm-desktop/src/features/connections/state/editor_logic.rs @@ -511,3 +511,96 @@ pub(super) fn set_connection_group_editor_error( editor.error = Some(error); true } + +/// Which draft strings become editable fields, and which are secrets. +/// +/// Driven off the draft rather than a fixed list so a field that does not apply +/// to the current kind is simply never built. +pub(super) fn editor_field_seeds( + draft: &ConnectionEditorState, +) -> Vec<(ConnectionEditorField, String, bool)> { + vec![ + (ConnectionEditorField::Name, draft.name.clone(), false), + ( + ConnectionEditorField::Description, + draft.description.clone(), + false, + ), + ( + ConnectionEditorField::NewGroupName, + draft.new_group_name.clone(), + false, + ), + (ConnectionEditorField::Host, draft.host.clone(), false), + (ConnectionEditorField::Port, draft.port.clone(), false), + ( + ConnectionEditorField::Username, + draft.username.clone(), + false, + ), + ( + ConnectionEditorField::Password, + draft.password.clone(), + true, + ), + ( + ConnectionEditorField::ShellPath, + draft.shell_path.clone(), + false, + ), + ( + ConnectionEditorField::ShellArgs, + draft.shell_args.clone(), + false, + ), + ( + ConnectionEditorField::WorkingDir, + draft.working_dir.clone(), + false, + ), + ( + ConnectionEditorField::SerialPort, + draft.serial_port.clone(), + false, + ), + ( + ConnectionEditorField::BaudRate, + draft.baud_rate.clone(), + false, + ), + ( + ConnectionEditorField::PostLoginCommand, + draft.post_login_command.clone(), + false, + ), + ( + ConnectionEditorField::PostLoginDelay, + draft.post_login_delay_ms.clone(), + false, + ), + ] +} + +/// Write an edited field back into the draft. +pub(super) fn set_connection_editor_field_text( + draft: &mut ConnectionEditorState, + field: ConnectionEditorField, + text: String, +) { + match field { + ConnectionEditorField::Name => draft.name = text, + ConnectionEditorField::Description => draft.description = text, + ConnectionEditorField::NewGroupName => draft.new_group_name = text, + ConnectionEditorField::Host => draft.host = text, + ConnectionEditorField::Port => draft.port = text, + ConnectionEditorField::Username => draft.username = text, + ConnectionEditorField::Password => draft.password = text, + ConnectionEditorField::ShellPath => draft.shell_path = text, + ConnectionEditorField::ShellArgs => draft.shell_args = text, + ConnectionEditorField::WorkingDir => draft.working_dir = text, + ConnectionEditorField::SerialPort => draft.serial_port = text, + ConnectionEditorField::BaudRate => draft.baud_rate = text, + ConnectionEditorField::PostLoginCommand => draft.post_login_command = text, + ConnectionEditorField::PostLoginDelay => draft.post_login_delay_ms = text, + } +} diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs index e1da16159..be1ccc025 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/local.rs @@ -7,15 +7,18 @@ use gpui::{ use crate::features::{NyaTermApp, small_button}; use crate::models::{ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorState}; -use super::super::super::list::{ConnectionEditorChoice, connection_editor_select, editor_field}; +use super::super::super::list::{ + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, +}; pub(super) fn connection_editor_local_section( palette: crate::theme::ThemePalette, - editor: &ConnectionEditorState, + _editor: &ConnectionEditorState, shell_label: &'static str, shell_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -56,17 +59,10 @@ pub(super) fn connection_editor_local_section( ) .child(div().min_w_0().flex_1().child(editor_field( palette, - "connection-editor-shell", "", - editor.shell_path.clone(), - editor.focused_field == ConnectionEditorField::ShellPath, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::ShellPath, - window, - cx, - ); - }), + ConnectionEditorField::ShellPath, + fields, + cx, ))) .child( div() @@ -96,13 +92,10 @@ pub(super) fn connection_editor_local_section( ) .child(editor_field( palette, - "connection-editor-args", tr("dialog.shellArgs"), - editor.shell_args.clone(), - editor.focused_field == ConnectionEditorField::ShellArgs, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::ShellArgs, window, cx); - }), + ConnectionEditorField::ShellArgs, + fields, + cx, )) .child( div() @@ -111,17 +104,10 @@ pub(super) fn connection_editor_local_section( .gap_2() .child(div().min_w_0().flex_1().child(editor_field( palette, - "connection-editor-cwd", tr("dialog.workingDir"), - editor.working_dir.clone(), - editor.focused_field == ConnectionEditorField::WorkingDir, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::WorkingDir, - window, - cx, - ); - }), + ConnectionEditorField::WorkingDir, + fields, + cx, ))) .child(small_button( palette, diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs index e5dac62e7..ecb54ac7e 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/mod.rs @@ -54,6 +54,7 @@ impl NyaTermApp { ) -> AnyElement { let palette = self.theme_palette(); let language = self.settings.language.clone(); + let fields = self.connection_state.editor_fields().clone(); let title = if editor.id.is_some() { self.tr("dialog.editConnection") } else { @@ -373,15 +374,6 @@ impl NyaTermApp { selected: editor.shell_path == value, }) .collect::>(); - let password_display = if editor.password.is_empty() { - if editor.existing_password.is_some() { - self.tr("dialog.passwordAlreadySet").to_string() - } else { - String::new() - } - } else { - "•".repeat(editor.password.chars().count().min(24)) - }; let icon_key = editor.icon.as_deref(); let icon_def = resolve_connection_icon(icon_key, editor.kind.label()); let icon_picker_open = self.connection_state.editor.icon_picker_is_open(); @@ -538,12 +530,10 @@ impl NyaTermApp { .flex_col() .gap_3() .overflow_hidden() + // No blanket focus grab here: it existed to keep the old label-div + // inputs "focused", and would now steal focus back from whichever + // field the pointer just landed on, since click follows mouse-down. .track_focus(&editor_focus) - .on_click(cx.listener(|this, _, window, cx| { - let editor_focus = this.connection_state.editor.focus_handle(); - window.focus(&editor_focus); - cx.notify(); - })) .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { cx.stop_propagation(); this.handle_connection_editor_key_down(event, window, cx); @@ -618,17 +608,10 @@ impl NyaTermApp { .child(icon_picker) .child(div().min_w(px(192.)).flex_1().child(editor_field( palette, - "connection-editor-name", name_label, - editor.name.clone(), - editor.focused_field == ConnectionEditorField::Name, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::Name, - window, - cx, - ); - }), + ConnectionEditorField::Name, + &fields, + cx, ))) .child(div().min_w(px(192.)).max_w(px(288.)).flex_1().child( connection_editor_group_select( @@ -649,7 +632,6 @@ impl NyaTermApp { this.child(connection_editor_ssh_section( palette, &editor, - password_display.clone(), password_label.clone(), key_label.clone(), otp_label.clone(), @@ -664,6 +646,7 @@ impl NyaTermApp { backspace_options.clone(), active_menu, &language, + &fields, cx, )) }) @@ -675,6 +658,7 @@ impl NyaTermApp { shell_options, active_menu, &language, + &fields, cx, )) }) @@ -685,6 +669,7 @@ impl NyaTermApp { backspace_options.clone(), active_menu, &language, + &fields, cx, )) }) @@ -700,6 +685,7 @@ impl NyaTermApp { backspace_options, active_menu, &language, + &fields, cx, )) }) diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs index aa76ee03a..1b238f307 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/serial.rs @@ -7,7 +7,9 @@ use gpui::{ use crate::features::NyaTermApp; use crate::models::{ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorState}; -use super::super::super::list::{ConnectionEditorChoice, connection_editor_select, editor_field}; +use super::super::super::list::{ + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, +}; pub(super) fn connection_editor_serial_section( palette: crate::theme::ThemePalette, @@ -20,6 +22,7 @@ pub(super) fn connection_editor_serial_section( backspace_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -66,17 +69,10 @@ pub(super) fn connection_editor_serial_section( .gap_1() .child(div().min_w_0().flex_1().child(editor_field( palette, - "connection-editor-baud", tr("dialog.baudRate"), - editor.baud_rate.clone(), - editor.focused_field == ConnectionEditorField::BaudRate, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::BaudRate, - window, - cx, - ); - }), + ConnectionEditorField::BaudRate, + fields, + cx, ))) .child(div().w(px(72.)).flex_none().child(connection_editor_select( palette, diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs index d6447dfe1..48ff423a7 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/ssh.rs @@ -15,7 +15,8 @@ use crate::models::{ }; use super::super::super::list::{ - ConnectionEditorChoice, connection_editor_select, editor_field, toggle_chip, + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, + toggle_chip, }; fn ssh_segment_tab( @@ -99,7 +100,6 @@ fn ssh_advanced_content( pub(super) fn connection_editor_ssh_section( palette: crate::theme::ThemePalette, editor: &ConnectionEditorState, - password_display: String, password_label: String, key_label: String, otp_label: String, @@ -114,6 +114,7 @@ pub(super) fn connection_editor_ssh_section( backspace_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -295,34 +296,25 @@ pub(super) fn connection_editor_ssh_section( .gap_2() .child(editor_field( palette, - "connection-editor-host", tr("dialog.host"), - editor.host.clone(), - editor.focused_field == ConnectionEditorField::Host, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Host, window, cx); - }), + ConnectionEditorField::Host, + fields, + cx, )) .child(editor_field( palette, - "connection-editor-port", tr("dialog.port"), - editor.port.clone(), - editor.focused_field == ConnectionEditorField::Port, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Port, window, cx); - }), + ConnectionEditorField::Port, + fields, + cx, )), ) .child(editor_field( palette, - "connection-editor-username", tr("dialog.username"), - editor.username.clone(), - editor.focused_field == ConnectionEditorField::Username, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Username, window, cx); - }), + ConnectionEditorField::Username, + fields, + cx, )) .child( div() @@ -382,17 +374,10 @@ pub(super) fn connection_editor_ssh_section( |this| { this.child(editor_field( palette, - "connection-editor-password", tr("dialog.password"), - password_display.clone(), - editor.focused_field == ConnectionEditorField::Password, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::Password, - window, - cx, - ); - }), + ConnectionEditorField::Password, + fields, + cx, )) }, ) @@ -563,34 +548,18 @@ pub(super) fn connection_editor_ssh_section( .when(editor.post_login_enabled, |this| { this.child(editor_field( palette, - "connection-editor-post-login-command", tr("dialog.postLoginCommandContent"), - editor.post_login_command.clone(), - editor.focused_field - == ConnectionEditorField::PostLoginCommand, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::PostLoginCommand, - window, - cx, - ); - }), + ConnectionEditorField::PostLoginCommand, + fields, + cx, )) .child( editor_field( palette, - "connection-editor-post-login-delay", tr("dialog.postLoginDelay"), - editor.post_login_delay_ms.clone(), - editor.focused_field - == ConnectionEditorField::PostLoginDelay, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field( - ConnectionEditorField::PostLoginDelay, - window, - cx, - ); - }), + ConnectionEditorField::PostLoginDelay, + fields, + cx, ), ) }), diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs index 33b6b2e83..d6c856f53 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/connection/telnet.rs @@ -11,7 +11,9 @@ use crate::models::{ ConnectionEditorField, ConnectionEditorMenu, ConnectionEditorState, ConnectionEditorTelnetTab, }; -use super::super::super::list::{ConnectionEditorChoice, connection_editor_select, editor_field}; +use super::super::super::list::{ + ConnectionEditorChoice, ConnectionEditorFields, connection_editor_select, editor_field, +}; fn telnet_segment_tab( palette: crate::theme::ThemePalette, @@ -132,6 +134,7 @@ pub(super) fn connection_editor_telnet_section( backspace_options: Vec, open_menu: Option, language: &str, + fields: &ConnectionEditorFields, cx: &mut Context, ) -> gpui::Div { let tr = |key: &'static str| crate::i18n::text(language, key); @@ -194,23 +197,17 @@ pub(super) fn connection_editor_telnet_section( .gap_2() .child(editor_field( palette, - "connection-editor-telnet-host", tr("dialog.host"), - editor.host.clone(), - editor.focused_field == ConnectionEditorField::Host, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Host, window, cx); - }), + ConnectionEditorField::Host, + fields, + cx, )) .child(editor_field( palette, - "connection-editor-telnet-port", tr("dialog.port"), - editor.port.clone(), - editor.focused_field == ConnectionEditorField::Port, - cx.listener(|this, _, window, cx| { - this.focus_connection_editor_field(ConnectionEditorField::Port, window, cx); - }), + ConnectionEditorField::Port, + fields, + cx, )), ) .child( diff --git a/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs b/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs index 6a2142ad3..4b4c3a3ab 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/editor/group_delete.rs @@ -15,8 +15,6 @@ use crate::models::{ ConnectionGroupOpenConfirmState, }; -use super::super::list::editor_field; - impl NyaTermApp { pub(in crate::features) fn connection_group_editor_panel( &mut self, @@ -52,18 +50,26 @@ impl NyaTermApp { .text_color(rgb(palette.text)) .child(title), ) - .child(editor_field( - palette, - "connection-group-name", - self.tr("savedConnections.folderName"), - editor.name.clone(), - true, - cx.listener(|this, _, window, cx| { - let group_editor_focus = this.connection_state.group_editor.focus_handle(); - window.focus(&group_editor_focus); - cx.notify(); - }), - )) + .children(self.connection_state.group_editor_field().map(|field| { + div() + .h(px(36.)) + .px_3() + .py_1() + .flex() + .flex_col() + .justify_center() + .rounded_sm() + .border_1() + .border_color(rgb(palette.primary)) + .bg(rgb(palette.input)) + .child( + div() + .text_xs() + .text_color(rgb(palette.text_muted)) + .child(self.tr("savedConnections.folderName")), + ) + .child(div().min_w_0().flex_1().text_xs().child(field)) + })) .when_some(editor.error.clone(), |this, error| { this.child( div() diff --git a/crates/nyaterm-desktop/src/features/pages/connections/list.rs b/crates/nyaterm-desktop/src/features/pages/connections/list.rs index 6d228e5bf..31b73ab72 100644 --- a/crates/nyaterm-desktop/src/features/pages/connections/list.rs +++ b/crates/nyaterm-desktop/src/features/pages/connections/list.rs @@ -1,7 +1,8 @@ use std::collections::HashMap; use gpui::{ - App, AppContext as _, ClickEvent, Context, FontWeight, IntoElement, SharedString, Window, div, + App, AppContext as _, ClickEvent, Context, Entity, FontWeight, IntoElement, SharedString, + Window, div, prelude::{ FluentBuilder, InteractiveElement, ParentElement, StatefulInteractiveElement, Styled, }, @@ -9,8 +10,9 @@ use gpui::{ }; use nyaterm_core::{Group, ProxyConfig, SavedConnection, natural_compare, truncate_preview}; -use crate::features::{NyaTermApp, format_last_used_ms, transfer_input}; -use crate::models::{ConnectionEditorMenu, ConnectionSortMode}; +use crate::features::{NyaTermApp, format_last_used_ms}; +use crate::models::{ConnectionEditorField, ConnectionEditorMenu, ConnectionSortMode}; +use nyaterm_ui::TextField; #[derive(Clone)] pub(super) enum ConnectionListRow { @@ -921,15 +923,62 @@ mod tests { } } +/// The editor's text fields, resolved once per render. +/// +/// Sections receive this rather than the app, because they are free functions +/// that already take a `Context` and cannot borrow `NyaTermApp` again. +pub(super) type ConnectionEditorFields = HashMap>; + +/// A labelled row wrapping one editable field. pub(super) fn editor_field( palette: crate::theme::ThemePalette, - id: impl Into, label: &'static str, - value: String, - active: bool, - on_click: impl Fn(&gpui::ClickEvent, &mut gpui::Window, &mut gpui::App) + 'static, + field: ConnectionEditorField, + fields: &ConnectionEditorFields, + cx: &App, ) -> impl IntoElement { - transfer_input(id, label, value, active, palette).on_click(on_click) + // The whole labelled row is the hit target: the field itself is only one + // text line tall, so clicking the label or the padding would otherwise miss. + let entity = fields.get(&field); + let handle = entity.map(|field| field.read(cx).focus_handle()); + let focused = entity.is_some_and(|field| field.read(cx).has_focus()); + let mut row = div() + .h(px(36.)) + .px_3() + .py_1() + .flex() + .flex_col() + .justify_center() + .gap_0() + .rounded_sm() + .border_1() + .border_color(rgb(if focused { + palette.primary + } else { + palette.border + })) + .bg(rgb(palette.input)); + if !label.is_empty() { + row = row.child( + div() + .text_xs() + .text_color(rgb(palette.text_muted)) + .child(label), + ); + } + row.when_some(handle, |row, handle| { + row.on_mouse_down(gpui::MouseButton::Left, move |_, window, _| { + window.focus(&handle); + }) + }) + .children(fields.get(&field).map(|field| { + div() + .min_w_0() + .flex_1() + .text_xs() + .text_color(rgb(palette.text)) + .child(field.clone()) + })) } pub(super) fn icon_action_button( diff --git a/crates/nyaterm-desktop/src/models/connections.rs b/crates/nyaterm-desktop/src/models/connections.rs index 8a42de3f2..dcfe6222c 100644 --- a/crates/nyaterm-desktop/src/models/connections.rs +++ b/crates/nyaterm-desktop/src/models/connections.rs @@ -79,7 +79,7 @@ pub(crate) enum ConnectionEditorTelnetTab { Compatibility, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub(crate) enum ConnectionEditorField { Name, NewGroupName, diff --git a/crates/nyaterm-ui/src/text_field.rs b/crates/nyaterm-ui/src/text_field.rs index 82799ea12..6116a4348 100644 --- a/crates/nyaterm-ui/src/text_field.rs +++ b/crates/nyaterm-ui/src/text_field.rs @@ -54,6 +54,9 @@ pub struct TextField { /// Horizontal scroll, so a caret past the right edge stays visible. scroll_x: Pixels, caret_visible: bool, + /// Focus as of the last render, so owners can style their own chrome with + /// only an `&App` — focus itself is a window-scoped question. + focused: bool, blink: Option>, selecting: bool, } @@ -68,6 +71,7 @@ impl TextField { marked: None, scroll_x: px(0.), caret_visible: true, + focused: false, blink: None, selecting: false, } @@ -95,6 +99,10 @@ impl TextField { self.focus.clone() } + pub fn has_focus(&self) -> bool { + self.focused + } + /// Replace the buffer from the owner's state without emitting a change. pub fn set_content(&mut self, content: impl Into, cx: &mut Context) { let content = content.into(); @@ -289,6 +297,7 @@ fn buffer_offset(edit: &TextEdit, masked: bool, display: usize) -> usize { impl Render for TextField { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let focused = self.focus.is_focused(window); + self.focused = focused; div() .id("text-field") .track_focus(&self.focus)