use gpui::{ Anchor, AppContext as _, Bounds, Context, DragMoveEvent, ElementId, Empty, Entity, Focusable as _, FontWeight, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Point, Render, SharedString, Size, StatefulInteractiveElement as _, Styled as _, Window, div, point, prelude::FluentBuilder as _, px, size, }; use gpui_component::{ ActiveTheme as _, Disableable as _, Icon, IconName, Sizable as _, WindowExt as _, button::ButtonVariants as _, dialog::Dialog, h_flex, input::Input, menu::{DropdownMenu as _, PopupMenuItem}, progress::Progress, scroll::{Scrollbar, ScrollbarShow}, v_flex, }; use rust_i18n::t; use crate::{ Ashell, app::controls::{pointer_button, pointer_switch, ui_rems}, session::config::AuthMethod, system::{RemoteProcess, format_bytes}, text_encoding::{FILE_ENCODINGS, TERMINAL_ENCODINGS, TextEncoding}, }; #[derive(Clone)] enum SftpEditorDrag { Move, Resize, } fn session_group_dropdown( id: impl Into, tab_index: isize, selected_group: String, connection_groups: Vec, view: Entity, ) -> impl IntoElement { let display_group = if selected_group.trim().is_empty() { t!("ungrouped").to_string() } else { selected_group.clone() }; pointer_button(id) .w_full() .outline() .dropdown_caret(true) .tab_index(tab_index) .label(display_group) .dropdown_menu_with_anchor(Anchor::BottomLeft, move |menu, window, _| { let ungrouped_view = view.clone(); let menu = menu.min_w(0.).item( PopupMenuItem::new(t!("ungrouped").to_string()) .checked(selected_group.trim().is_empty()) .on_click(window.listener_for(&ungrouped_view, |this, _, _, cx| { this.set_session_group(String::new(), cx); })), ); connection_groups.iter().fold(menu, |menu, group| { let group_value = group.clone(); let group_label = group.clone(); let group_view = view.clone(); menu.item( PopupMenuItem::new(group_label) .checked(group.eq_ignore_ascii_case(&selected_group)) .on_click(window.listener_for(&group_view, move |this, _, _, cx| { this.set_session_group(group_value.clone(), cx); })), ) }) }) } impl Render for SftpEditorDrag { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { Empty } } impl Ashell { pub(crate) fn show_ssh_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; } self.active_dialog = Some(crate::app::DialogKind::NewSsh); if let Some(id) = &self.editing_session_id { if let Some(session) = self.config.get(id) { self.session_protocol = session.protocol.clone(); } } else { self.session_protocol = "ssh".to_string(); } let initial_is_serial = self.session_protocol == "serial"; let initial_is_editing = self.editing_session_id.is_some(); let view = cx.entity(); let session_name_input = self.session_name_input.clone(); let host_input = self.host_input.clone(); let focus_host_input = host_input.clone(); let port_input = self.port_input.clone(); let user_input = self.user_input.clone(); let password_input = self.password_input.clone(); let key_path_input = self.key_path_input.clone(); let key_inline_input = self.key_inline_input.clone(); let passphrase_input = self.passphrase_input.clone(); let proxy_host_input = self.proxy_host_input.clone(); let proxy_port_input = self.proxy_port_input.clone(); let proxy_user_input = self.proxy_user_input.clone(); let proxy_password_input = self.proxy_password_input.clone(); let baud_rate_input = self.baud_rate_input.clone(); window.open_dialog(cx, move |dialog: Dialog, _window, _cx| { dialog .title(match (initial_is_serial, initial_is_editing) { (true, true) => t!("edit_serial_connection"), (true, false) => t!("new_serial_connection"), (false, true) => t!("edit_connection"), (false, false) => t!("new_connection"), }) .w(px(520.)) .overlay_closable(true) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { this.active_dialog = None; cx.notify(); }); } }) .content({ let view = view.clone(); let session_name_input = session_name_input.clone(); let host_input = host_input.clone(); let port_input = port_input.clone(); let user_input = user_input.clone(); let password_input = password_input.clone(); let key_path_input = key_path_input.clone(); let key_inline_input = key_inline_input.clone(); let passphrase_input = passphrase_input.clone(); let proxy_host_input = proxy_host_input.clone(); let proxy_port_input = proxy_port_input.clone(); let proxy_user_input = proxy_user_input.clone(); let proxy_password_input = proxy_password_input.clone(); let baud_rate_input = baud_rate_input.clone(); move |content, window, cx| { let auth_method = view.read(cx).ssh_auth_method; let is_password = auth_method == AuthMethod::Password; let is_key = auth_method == AuthMethod::Key; let is_config = auth_method == AuthMethod::Config; let is_editing = view.read(cx).editing_session_id.is_some(); let proxy_type = view.read(cx).ssh_proxy_type.clone(); let show_proxy_fields = proxy_type != "none"; let protocol = view.read(cx).session_protocol.clone(); let is_ssh = protocol == "ssh"; let is_serial = protocol == "serial"; let terminal_encoding = view.read(cx).ssh_terminal_encoding; let (session_group, connection_groups) = { let this = view.read(cx); (this.session_group.clone(), this.config.connection_groups()) }; content.child( v_flex() .gap_3() .child( h_flex() .gap_2() .child( pointer_button("proto-ssh") .label("SSH") .when(is_ssh, |button| button.primary()) .on_click(window.listener_for( &view, |this, _, window, cx| { this.set_session_protocol("ssh".to_string(), cx); Self::set_input_value(&this.port_input, "22", window, cx); }, )), ) .child( pointer_button("proto-serial") .label("Serial") .when(is_serial, |button| button.primary()) .on_click(window.listener_for( &view, |this, _, _window, cx| { this.set_session_protocol("serial".to_string(), cx); }, )), ), ) .when(is_serial, |this| { this.child( v_flex() .gap_1() .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("session_name").to_string())) .child(Input::new(&session_name_input).tab_index(0)) ) .child( v_flex() .gap_1() .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("connection_group").to_string())) .child(session_group_dropdown( "serial-connection-group-select", 1, session_group.clone(), connection_groups.clone(), view.clone(), )) ) .child( v_flex() .gap_1() .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("serial_port").to_string())) .child(Input::new(&host_input).tab_index(2)) ) .child( v_flex() .gap_1() .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("baud_rate").to_string())) .child(Input::new(&baud_rate_input).tab_index(3)) ) }) .when(is_ssh, |this| { this.child( h_flex() .gap_2() .child( pointer_button("ssh-auth-password") .label(t!("password").to_string()) .when(is_password, |button| button.primary()) .on_click(window.listener_for( &view, |this, _, _, cx| { this.set_ssh_auth_method( AuthMethod::Password, cx, ) }, )), ) .child( pointer_button("ssh-auth-key") .label(t!("key").to_string()) .when(is_key, |button| button.primary()) .on_click(window.listener_for( &view, |this, _, _, cx| { this.set_ssh_auth_method( AuthMethod::Key, cx, ) }, )), ) .child( pointer_button("ssh-auth-config") .label(t!("ssh_config").to_string()) .when(is_config, |button| button.primary()) .on_click(window.listener_for( &view, |this, _, _, cx| { this.set_ssh_auth_method( AuthMethod::Config, cx, ) }, )), ), ) .when(!is_config, |this| { this.child(Input::new(&session_name_input).tab_index(0)) .child( v_flex() .gap_1() .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("connection_group").to_string())) .child(session_group_dropdown( "ssh-connection-group-select", 1, session_group.clone(), connection_groups.clone(), view.clone(), )), ) .child( h_flex() .w_full() .gap_2() .child( Input::new(&host_input) .flex_1() .min_w(px(0.)) .tab_index(2), ) .child( Input::new(&port_input) .w(px(96.)) .tab_index(3), ), ) .when(is_password, |this| { this.child( h_flex() .w_full() .gap_2() .child( Input::new(&user_input) .flex_1() .min_w(px(0.)) .tab_index(4), ) .child( Input::new(&password_input) .flex_1() .min_w(px(0.)) .mask_toggle() .tab_index(5), ), ) }) .when(is_key, |this| { this.child( Input::new(&user_input).w_full().tab_index(4), ) }) }) .when(is_key, |this| { this.child( h_flex() .gap_2() .child( div() .flex_1() .cursor_pointer() .on_mouse_down( MouseButton::Left, window.listener_for( &view, |this, _, window, cx| { this.pick_ssh_key_path(window, cx); }, ), ) .child( Input::new(&key_path_input).tab_index(6), ), ) .child( pointer_button("clear-key-path") .ghost() .icon(IconName::Close) .on_click(window.listener_for( &view, |this, _, window, cx| { Self::set_input_value( &this.key_path_input, "", window, cx, ); }, )), ), ) .child(Input::new(&key_inline_input).h(px(128.)).tab_index(7)) .child(Input::new(&passphrase_input).mask_toggle().tab_index(8)) }) .when(is_config, |this| { let this = this.child( v_flex() .gap_1() .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("connection_group").to_string())) .child(session_group_dropdown( "ssh-config-connection-group-select", 0, session_group.clone(), connection_groups.clone(), view.clone(), )), ); let entries = view.read(cx).ssh_config_entries.clone(); let selected = view.read(cx).ssh_config_selected; let theme = cx.theme(); if entries.is_empty() { this.child( div() .text_sm() .text_color(theme.muted_foreground) .child(t!("ssh_config_empty").to_string()), ) } else { this.child( div() .h(px(192.)) .id("ssh-config-list") .track_scroll( &view.read(cx).connection_scroll_handle, ) .overflow_y_scroll() .border_1() .border_color(theme.border) .rounded_md() .children(entries.iter().enumerate().map( |(i, entry)| { let is_selected = selected == Some(i); let label = if entry.user.is_empty() { format!( "{}:{}", entry.hostname, entry.port ) } else { format!( "{}@{}:{}", entry.user, entry.hostname, entry.port ) }; let alias_label = if entry.host_alias == entry.hostname { String::new() } else { format!(" ({})", entry.host_alias) }; let view_clone = view.clone(); div() .id(("ssh-config-entry", i)) .px_2() .py_1() .when(is_selected, |el| { el.bg(theme.selection) }) .cursor_pointer() .hover(|el| el.bg(theme.selection)) .text_sm() .child(format!("{label}{alias_label}")) .on_click(window.listener_for( &view_clone, move |this, _, window, cx| { this.select_ssh_config_entry( i, window, cx, ); }, )) }, )), ) } }) .child( h_flex() .w_full() .items_center() .gap_2() .child( div() .flex_1() .text_sm() .font_weight(FontWeight::BOLD) .child(t!("terminal_encoding").to_string()), ) .child( pointer_button("ssh-terminal-encoding") .ghost() .icon(IconName::Globe) .label(terminal_encoding.label()) .dropdown_menu_with_anchor( Anchor::BottomRight, { let view = view.clone(); move |menu, window, _| { TERMINAL_ENCODINGS .iter() .copied() .fold( menu.min_w(0.), |menu, candidate| { menu.item( PopupMenuItem::new( candidate .label(), ) .checked( candidate == terminal_encoding, ) .on_click( window.listener_for( &view, move |this, _, _, cx| { this.set_ssh_terminal_encoding( candidate, cx, ); }, ), ), ) }, ) } }, ), ), ) .when(!is_config, |this| { this.child( div() .text_sm() .font_weight(FontWeight::BOLD) .child(t!("proxy").to_string()), ) .child( h_flex() .gap_2() .child( pointer_button("proxy-none") .label(t!("proxy_none").to_string()) .when(proxy_type == "none", |button| { button.primary() }) .on_click(window.listener_for( &view, |this, _, _, cx| { this.set_ssh_proxy_type( "none".to_string(), cx, ) }, )), ) .child( pointer_button("proxy-socks5") .label("SOCKS5") .when(proxy_type == "socks5", |button| { button.primary() }) .on_click(window.listener_for( &view, |this, _, _, cx| { this.set_ssh_proxy_type( "socks5".to_string(), cx, ) }, )), ) .child( pointer_button("proxy-http") .label("HTTP") .when(proxy_type == "http", |button| { button.primary() }) .on_click(window.listener_for( &view, |this, _, _, cx| { this.set_ssh_proxy_type( "http".to_string(), cx, ) }, )), ), ) .when( show_proxy_fields, |this| { this.child( h_flex() .gap_2() .child(Input::new(&proxy_host_input).flex_1()) .child( Input::new(&proxy_port_input).w(px(96.)), ), ) .child( h_flex() .gap_2() .child(Input::new(&proxy_user_input).flex_1()) .child( Input::new(&proxy_password_input).flex_1(), ), ) }, ) }) }) .child( h_flex() .justify_end() .gap_2() .child( pointer_button("connect-ssh-cancel") .label(t!("cancel").to_string()) .on_click(window.listener_for( &view, |this, _, window, cx| { this.active_dialog = None; window.close_dialog(cx); cx.notify(); }, )), ) .when(!is_config, |this| { this.child( pointer_button("connect-ssh-confirm") .primary() .label(if is_editing { t!("save") } else { t!("connect") }) .on_click(window.listener_for( &view, |this, _, window, cx| { this.connect_ssh(window, cx) }, )), ) }), ), ) } }) }); window.defer(cx, move |window, cx| { window.focus(&focus_host_input.read(cx).focus_handle(cx), cx); }); } pub(crate) fn show_selector_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; } self.active_dialog = Some(crate::app::DialogKind::SessionSelector); let view = cx.entity(); let selector_focus_handle = self.selector_focus_handle.clone(); let deferred_selector_focus_handle = selector_focus_handle.clone(); let sessions = self.config.sessions().to_vec(); self.selector_selection = self.default_selector_index(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("open_session").to_string()) .w(px(520.)) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { this.active_dialog = None; cx.notify(); }); } }) .on_ok({ let view = view.clone(); move |_, window, cx| { view.update(cx, |this, cx| { this.activate_selector_selection(window, cx); }); false } }) .content({ let view = view.clone(); let sessions = sessions.clone(); let selector_focus_handle = selector_focus_handle.clone(); move |content, window, _cx| { let selected_index = view.read(_cx).selector_selection; let scroll_handle = view.read(_cx).selector_scroll_handle.clone(); content.child( v_flex() .track_focus(&selector_focus_handle) .on_key_down(window.listener_for( &view, |this, event, window, cx| { this.on_selector_key_down(event, window, cx) }, )) .gap_2() .child( div() .w_full() .p_2() .rounded_md() .border_1() .border_color(if selected_index == 0 { _cx.theme().primary } else { _cx.theme().border }) .bg(if selected_index == 0 { _cx.theme().tab_active } else { _cx.theme().muted }) .cursor_pointer() .hover(|this| this.bg(_cx.theme().secondary)) .on_mouse_down( MouseButton::Left, window.listener_for(&view, |this, _, window, cx| { this.active_dialog = None; this.open_local(cx); window.close_dialog(cx); cx.notify(); }), ) .child( v_flex() .gap_1() .child( div() .text_size(ui_rems(1.0)) .font_weight(FontWeight::SEMIBOLD) .child(t!("local_terminal")), ) .child( div() .text_size(ui_rems(0.917)) .text_color(_cx.theme().muted_foreground) .child(t!("open_local_shell_tab")), ), ), ) .child( div() .relative() .max_h(px(320.)) .size_full() .child( v_flex() .size_full() .id("selector-scroll-view") .track_scroll(&scroll_handle) .overflow_y_scroll() .gap_2() .children( sessions.clone().into_iter().enumerate().map( |(ix, session)| { let connect_id = session.id.clone(); let is_selected = selected_index == ix + 1; let name = session.name.clone(); let detail = if session.protocol == "serial" { format!( "Serial: {}@{}", session.host, session.baud_rate ) } else { format!( "{}@{}:{}", session.user, session.host, session.port ) }; div() .id(("selector-open", ix)) .w_full() .p_2() .rounded_md() .border_1() .border_color(if is_selected { _cx.theme().primary } else { _cx.theme().border }) .bg(if is_selected { _cx.theme().tab_active } else { _cx.theme().muted }) .cursor_pointer() .hover(|this| this.bg(_cx.theme().secondary)) .on_mouse_down( MouseButton::Left, window.listener_for( &view, move |this, _, window, cx| { this.active_dialog = None; this.connect_saved_session( connect_id.clone(), window, cx, ); window.close_dialog(cx); cx.notify(); }, ), ) .child( v_flex() .gap_1() .child( div() .text_size(ui_rems(1.0)) .font_weight( FontWeight::SEMIBOLD, ) .child(name), ) .child( div() .text_size(ui_rems(0.917)) .text_color( _cx.theme() .muted_foreground, ) .child(detail), ), ) }, ), ), ) .child( div() .absolute() .top_0() .bottom_0() .left_0() .right_0() .child( gpui_component::scroll::Scrollbar::new( &scroll_handle, ) .id("selector-scrollbar") .axis( gpui_component::scroll::ScrollbarAxis::Vertical, ) .scrollbar_show( gpui_component::scroll::ScrollbarShow::Always, ), ), ), ), ) } }) }); window.defer(cx, move |window, cx| { window.focus(&deferred_selector_focus_handle, cx); }); } pub(crate) fn show_transfers_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; } self.active_dialog = Some(crate::app::DialogKind::Transfers); let view = cx.entity(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .w(px(600.)) .close_button(false) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { this.active_dialog = None; cx.notify(); }); } }) .content({ let view = view.clone(); move |content, window, cx| { let can_clear = view.read(cx).transfers.iter().any(|t| { !matches!( t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused ) }); let clear_btn = if can_clear { Some( pointer_button("clear_transfers_btn") .ghost() .icon(IconName::Delete) .label(t!("clear_transfers").to_string()) .on_click(window.listener_for(&view, |this, _, _, cx| { this.transfers.retain(|t| { matches!( t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused ) }); this.config.set_transfers(this.transfers.clone()); cx.notify(); })), ) } else { None }; let header = h_flex() .w_full() .justify_between() .items_center() .child( h_flex() .items_baseline() .child( div() .text_lg() .font_weight(FontWeight::SEMIBOLD) .child(t!("transfers").to_string()), ) .child( div() .text_sm() .text_color(cx.theme().muted_foreground) .ml_2() .child(t!("transfers_limit").to_string()), ), ) .child( h_flex().gap_2().children(clear_btn).child( pointer_button("close_dialog") .ghost() .icon(IconName::Close) .on_click(window.listener_for( &view, |this, _, window, cx| { this.active_dialog = None; window.close_dialog(cx); cx.notify(); }, )), ), ); let mut transfers = view.read(cx).transfers.clone(); transfers.sort_by_key(|t| match t.state { crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused => 0, _ => 1, }); if transfers.is_empty() { return content.child( v_flex().gap_2().child(header).child( div() .p_4() .text_center() .text_color(cx.theme().muted_foreground) .child(t!("no_transfers_yet").to_string()), ), ); } let list = v_flex().gap_2().children(transfers.into_iter().map(|t| { let (icon, _color) = match t.info.kind { crate::terminal::TransferType::Upload => { (IconName::ArrowUp, cx.theme().primary) } crate::terminal::TransferType::Download => { (IconName::ArrowDown, cx.theme().success) } }; let (status_text, actions) = match t.state { crate::terminal::TransferState::Running => { let percent = t .total .map(|tot| { (t.transferred as f64 / tot as f64 * 100.0) .clamp(0.0, 100.0) }) .unwrap_or(0.0); let txt = if let Some(tot) = t.total { format!( "{:.1}% ({}/{})", percent, format_bytes(t.transferred), format_bytes(tot) ) } else { match t.info.kind { crate::terminal::TransferType::Upload => { format!("{}...", t!("uploading")) } crate::terminal::TransferType::Download => { format!("{}...", t!("downloading")) } } }; let btn_pause = pointer_button(SharedString::from( format!("pause-{}", t.info.id), )) .ghost() .icon(IconName::Pause) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, _| { if let Some(handle) = this.active_sftp_handle() { handle.pause_transfer(id.clone()); } } })); let btn_cancel = pointer_button(SharedString::from( format!("cancel-{}", t.info.id), )) .ghost() .icon(IconName::Close) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, _| { if let Some(handle) = this.active_sftp_handle() { handle.cancel_transfer(id.clone()); } } })); (txt, h_flex().gap_1().child(btn_pause).child(btn_cancel)) } crate::terminal::TransferState::Paused => { let txt = t!("paused").to_string(); let btn_resume = pointer_button(SharedString::from( format!("resume-{}", t.info.id), )) .ghost() .icon(IconName::Play) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, _| { if let Some(handle) = this.active_sftp_handle() { handle.resume_transfer(id.clone()); } } })); let btn_cancel = pointer_button(SharedString::from( format!("cancel-{}", t.info.id), )) .ghost() .icon(IconName::Close) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, _| { if let Some(handle) = this.active_sftp_handle() { handle.cancel_transfer(id.clone()); } } })); (txt, h_flex().gap_1().child(btn_resume).child(btn_cancel)) } crate::terminal::TransferState::Interrupted(ref reason) => { let txt = format!("{}: {}", t!("interrupted"), reason); let btn_remove = pointer_button(SharedString::from( format!("remove-{}", t.info.id), )) .ghost() .icon(IconName::Close) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, cx| { this.remove_transfer(&id, cx); } })); (txt, h_flex().gap_1().child(btn_remove)) } crate::terminal::TransferState::Completed => { let txt = t!("completed").to_string(); let mut actions = h_flex().gap_1(); if matches!( t.info.kind, crate::terminal::TransferType::Download ) { let btn_folder = pointer_button(SharedString::from( format!("folder-{}", t.info.id), )) .ghost() .icon(IconName::Folder) .on_click({ let target = t.info.target.clone(); move |_, _, _| { let _ = std::process::Command::new("open") .arg(&target) .spawn(); } }); actions = actions.child(btn_folder); } let btn_remove = pointer_button(SharedString::from( format!("remove-{}", t.info.id), )) .ghost() .icon(IconName::Close) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, cx| { this.remove_transfer(&id, cx); } })); actions = actions.child(btn_remove); (txt, actions) } crate::terminal::TransferState::Failed(ref err) => { let txt = format!("{}: {}", t!("failed"), err); let btn_remove = pointer_button(SharedString::from( format!("remove-{}", t.info.id), )) .ghost() .icon(IconName::Close) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, cx| { this.remove_transfer(&id, cx); } })); (txt, h_flex().gap_1().child(btn_remove)) } crate::terminal::TransferState::Zombie(ref reason) => { let txt = format!("{}: {}", t!("zombie"), reason); let btn_remove = pointer_button(SharedString::from( format!("remove-{}", t.info.id), )) .ghost() .icon(IconName::Close) .on_click(window.listener_for(&view, { let id = t.info.id.clone(); move |this, _, _, cx| { this.remove_transfer(&id, cx); } })); (txt, h_flex().gap_1().child(btn_remove)) } }; let percent = match t.state { crate::terminal::TransferState::Completed => 100.0, _ => t .total .map(|tot| t.transferred as f64 / tot as f64 * 100.0) .unwrap_or(0.0), }; v_flex() .gap_1() .p_2() .rounded_md() .border_1() .border_color(cx.theme().border) .bg(cx.theme().muted) .child( h_flex() .items_center() .gap_2() .child( pointer_button(SharedString::from(format!( "icon-{}", t.info.id ))) .icon(icon) .ghost() .disabled(true), ) .child( v_flex() .flex_1() .min_w(px(0.)) .overflow_hidden() .child( div() .text_size(px(12.)) .font_weight(FontWeight::SEMIBOLD) .text_color(cx.theme().foreground) .overflow_hidden() .child(t.info.name.clone()), ) .child( div() .text_size(px(10.)) .text_color(cx.theme().muted_foreground) .overflow_hidden() .child(format!( "{}: {}", t!("session"), t.tab_title )), ) .child( div() .text_size(px(11.)) .text_color(cx.theme().muted_foreground) .child(status_text.clone()), ), ) .child(actions), ) .when( matches!( t.state, crate::terminal::TransferState::Running | crate::terminal::TransferState::Paused ), |this| { this.child( Progress::new(format!("progress-{}", t.info.id)) .with_size(px(4.)) .value(percent as f32) .color(cx.theme().primary) .w_full(), ) }, ) })); let scroll_handle = window .use_keyed_state("transfers-scroll", cx, |_, _| { gpui::ScrollHandle::default() }) .read(cx) .clone(); content.child( v_flex().gap_2().child(header).child( div() .w_full() .relative() .child( div() .w_full() .max_h(px(400.)) .flex_col() .id("transfers-scroll-view") .track_scroll(&scroll_handle) .overflow_y_scroll() .pr(px(14.)) .child(list), ) .child( div() .absolute() .top_0() .right_0() .bottom_0() .w(px(16.)) .child( Scrollbar::vertical(&scroll_handle) .scrollbar_show(ScrollbarShow::Always), ), ), ), ) } }) }); } pub(crate) fn show_delete_confirm_dialog( &mut self, window: &mut Window, cx: &mut Context, ) { let view = cx.entity(); let selected_entries = self .active_sftp() .map(|s| s.selected_entries.clone()) .unwrap_or_default(); if selected_entries.is_empty() { return; } let has_system_path = selected_entries.iter().any(|path| { let p = path.as_str(); p.starts_with("/bin/") || p == "/bin" || p.starts_with("/etc/") || p == "/etc" || p.starts_with("/usr/") || p == "/usr" || p.starts_with("/var/") || p == "/var" || p.starts_with("/sys/") || p == "/sys" || p.starts_with("/dev/") || p == "/dev" || p.starts_with("/boot/") || p == "/boot" || p.starts_with("/lib/") || p == "/lib" || p.starts_with("/opt/") || p == "/opt" || p.starts_with("/run/") || p == "/run" || p.starts_with("/sbin/") || p == "/sbin" }); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("confirm_delete").to_string()) .w(px(500.)) .keyboard(false) .on_ok({ let view = view.clone(); let paths_to_delete: Vec = selected_entries.clone().into_iter().collect(); move |_, window, cx| { view.update(cx, |this, cx| { if let Some(handle) = this.active_sftp_handle() { let _ = handle.commands.send( crate::sftp::SftpCommand::DeletePaths(paths_to_delete.clone()), ); } if let Some(sftp) = this.active_sftp_mut() { sftp.selected_entries.clear(); } cx.notify(); }); window.close_dialog(cx); true } }) .content({ let view = view.clone(); move |content, _window, cx| { let scroll_handle = view.read(cx).sftp_delete_scroll_handle.clone(); let selected_paths: Vec = view .read(cx) .active_sftp() .map(|s| s.selected_entries.clone().into_iter().collect()) .unwrap_or_default(); let warning_block = if has_system_path { Some( div() .w_full() .p_3() .mb_3() .rounded_md() .bg(gpui::rgba(0xff00001a)) .border_1() .border_color(gpui::rgba(0xff000080)) .child( div() .text_color(gpui::rgba(0xff0000ff)) .font_weight(FontWeight::BOLD) .child(t!("system_path_warning").to_string()), ), ) } else { None }; let paths_list = div() .relative() .max_h(px(200.)) .w_full() .border_1() .border_color(cx.theme().border) .bg(cx.theme().background) .rounded_md() .child( v_flex() .id("delete-scroll-view") .size_full() .track_scroll(&scroll_handle) .overflow_y_scroll() .p_2() .gap_1() .children(selected_paths.into_iter().map(|path| { div() .text_size(ui_rems(0.917)) .text_color(cx.theme().muted_foreground) .child(path) })), ) .child( div().absolute().top_0().bottom_0().right_0().child( gpui_component::scroll::Scrollbar::vertical(&scroll_handle) .scrollbar_show( gpui_component::scroll::ScrollbarShow::Always, ), ), ); content.child( v_flex() .w_full() .gap_2() .children(warning_block) .child( div().text_size(ui_rems(1.0)).mb_2().child( t!( "confirm_delete_desc", count = view .read(cx) .active_sftp() .map(|s| s.selected_entries.len()) .unwrap_or(0) ) .to_string(), ), ) .child(paths_list), ) } }) .footer({ let view = view.clone(); let paths_to_delete: Vec = selected_entries.clone().into_iter().collect(); h_flex() .w_full() .justify_end() .gap_2() .child( pointer_button("cancel") .ghost() .label(t!("cancel").to_string()) .on_click(move |_, window, cx| { window.close_dialog(cx); }), ) .child( pointer_button("confirm") .danger() .label(t!("confirm").to_string()) .on_click({ let view = view.clone(); move |_, window, cx| { view.update(cx, |this, cx| { if let Some(handle) = this.active_sftp_handle() { let _ = handle.commands.send( crate::sftp::SftpCommand::DeletePaths( paths_to_delete.clone(), ), ); } if let Some(sftp) = this.active_sftp_mut() { sftp.selected_entries.clear(); } cx.notify(); }); window.close_dialog(cx); } }), ) }) }); } pub(crate) fn show_terminate_process_dialog( &mut self, tab_id: String, process: RemoteProcess, window: &mut Window, cx: &mut Context, ) { if process.pid <= 1 || self.system_tab_id.as_deref() != Some(tab_id.as_str()) || !self.tabs.iter().any(|tab| { tab.id == tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected }) { return; } let view = cx.entity(); let pid = process.pid; let process_name = process.command; let process_user = process.user; window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("confirm_terminate_process").to_string()) .w(px(480.)) .keyboard(false) .content({ let process_name = process_name.clone(); let process_user = process_user.clone(); move |content, _window, cx| { content.child( v_flex() .w_full() .gap_3() .child( div() .w_full() .whitespace_normal() .line_clamp(3) .text_size(ui_rems(0.917)) .child( t!("confirm_terminate_process_desc", pid = pid) .to_string(), ), ) .child( v_flex() .w_full() .gap_1() .p_3() .rounded_md() .bg(cx.theme().muted) .child( div() .w_full() .truncate() .text_size(ui_rems(0.833)) .font_weight(FontWeight::SEMIBOLD) .child(process_name.clone()), ) .child( div() .text_size(ui_rems(0.75)) .text_color(cx.theme().muted_foreground) .child( t!( "process_summary", name = process_user.as_str(), pid = pid ) .to_string(), ), ), ), ) } }) .footer({ let view = view.clone(); let tab_id = tab_id.clone(); h_flex() .w_full() .justify_end() .gap_2() .child( pointer_button("terminate-process-cancel") .ghost() .label(t!("cancel").to_string()) .on_click(|_, window, cx| window.close_dialog(cx)), ) .child( pointer_button("terminate-process-confirm") .danger() .label(t!("terminate_process").to_string()) .on_click(move |_, window, cx| { view.update(cx, |this, cx| { this.terminate_remote_process(tab_id.clone(), pid, cx); }); window.close_dialog(cx); }), ) }) }); } pub(crate) fn prompt_active_ssh_reconnect_if_needed( &mut self, window: &mut Window, cx: &mut Context, ) { let Some(tab_id) = self.active_tab.as_ref().and_then(|active_tab_id| { self.tabs .iter() .find(|tab| { tab.id == *active_tab_id && tab.kind == crate::terminal::TabKind::Ssh && !tab.connected && tab.disconnected_reason.is_some() && tab.session.is_some() }) .map(|tab| tab.id.clone()) }) else { return; }; self.show_ssh_reconnect_dialog(tab_id, window, cx); } pub(crate) fn show_ssh_reconnect_dialog( &mut self, tab_id: String, window: &mut Window, cx: &mut Context, ) { if self.active_dialog.is_some() || self.active_tab.as_deref() != Some(tab_id.as_str()) || !self.tabs.iter().any(|tab| { tab.id == tab_id && tab.kind == crate::terminal::TabKind::Ssh && !tab.connected && tab.disconnected_reason.is_some() && tab.session.is_some() }) { return; } let session_name = self .tabs .iter() .find(|tab| tab.id == tab_id) .map(|tab| tab.title.clone()) .unwrap_or_else(|| tab_id.clone()); let view = cx.entity(); self.active_dialog = Some(crate::app::DialogKind::SshReconnect); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("reconnect_ssh_title").to_string()) .w(px(400.)) .keyboard(false) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::SshReconnect) { this.active_dialog = None; } cx.notify(); }); } }) .content({ let session_name = session_name.clone(); move |content, _window, _cx| { content.child( div() .w_full() .whitespace_normal() .text_size(ui_rems(0.917)) .child( t!("reconnect_ssh_desc", name = session_name.as_str()) .to_string(), ), ) } }) .footer({ let view = view.clone(); let tab_id = tab_id.clone(); h_flex() .w_full() .justify_end() .gap_2() .child( pointer_button("ssh-reconnect-cancel") .ghost() .label(t!("cancel").to_string()) .on_click({ let view = view.clone(); move |_, window, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::SshReconnect) { this.active_dialog = None; } cx.notify(); }); window.close_dialog(cx); } }), ) .child( pointer_button("ssh-reconnect-confirm") .primary() .label(t!("reconnect_ssh").to_string()) .on_click(move |_, window, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::SshReconnect) { this.active_dialog = None; } this.retry_disconnected_tab(&tab_id, cx); }); window.close_dialog(cx); }), ) }) }); } pub(crate) fn show_connection_group_dialog( &mut self, group: Option, window: &mut Window, cx: &mut Context, ) { if self.active_dialog.is_some() { return; } let editing_group = group.clone(); let is_editing = editing_group.is_some(); let existing_groups = self.config.connection_groups(); self.active_dialog = Some(crate::app::DialogKind::ConnectionGroup); self.editing_connection_group = editing_group.clone(); Self::set_input_value( &self.connection_group_name_input, group.unwrap_or_default(), window, cx, ); let view = cx.entity(); let group_input = self.connection_group_name_input.clone(); let focus_input = group_input.clone(); window.open_dialog(cx, move |dialog: Dialog, _window, _cx| { dialog .title(if is_editing { t!("rename_connection_group").to_string() } else { t!("new_connection_group").to_string() }) .w(px(360.)) .close_button(false) .overlay_closable(false) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::ConnectionGroup) { this.active_dialog = None; this.editing_connection_group = None; } cx.notify(); }); } }) .content({ let view = view.clone(); let group_input = group_input.clone(); let editing_group = editing_group.clone(); let existing_groups = existing_groups.clone(); move |content, window, cx| { let name = group_input.read(cx).value().trim().to_string(); let duplicate = existing_groups.iter().any(|group| { group.eq_ignore_ascii_case(&name) && editing_group .as_ref() .is_none_or(|old| !group.eq_ignore_ascii_case(old)) }); let unchanged = editing_group.as_ref().is_some_and(|old| old == &name); let error = if name.is_empty() { Some(t!("group_name_required").to_string()) } else if duplicate { Some(t!("group_name_exists").to_string()) } else { None }; content.child( v_flex() .gap_3() .child(Input::new(&group_input).w_full().tab_index(0)) .when_some(error.clone(), |this, message| { this.child( div() .text_sm() .text_color(cx.theme().danger) .child(message), ) }) .child( h_flex() .w_full() .justify_end() .gap_2() .child( pointer_button("connection-group-cancel") .ghost() .label(t!("cancel").to_string()) .on_click(window.listener_for( &view, |this, _, window, cx| { this.active_dialog = None; this.editing_connection_group = None; window.close_dialog(cx); cx.notify(); }, )), ) .child( pointer_button("connection-group-save") .primary() .label(t!("save").to_string()) .disabled(error.is_some() || unchanged) .on_click(window.listener_for( &view, |this, _, window, cx| { this.submit_connection_group_dialog( window, cx, ); }, )), ), ), ) } }) }); window.defer(cx, move |window, cx| { focus_input.read(cx).focus_handle(cx).focus(window, cx); }); } pub(crate) fn submit_connection_group_dialog( &mut self, window: &mut Window, cx: &mut Context, ) { let name = self .connection_group_name_input .read(cx) .value() .trim() .to_string(); if name.is_empty() { return; } let editing = self.editing_connection_group.clone(); let duplicate = self.config.connection_groups().iter().any(|group| { group.eq_ignore_ascii_case(&name) && editing .as_ref() .is_none_or(|old| !group.eq_ignore_ascii_case(old)) }); if duplicate || editing.as_ref().is_some_and(|old| old == &name) { return; } let previous_config = self.config.cache.clone(); let changed = if let Some(old_name) = editing.as_deref() { self.config.rename_connection_group(old_name, &name) } else { self.config.add_connection_group(&name) }; if !changed { return; } if let Err(err) = self.config.save() { self.config.cache = previous_config; tracing::warn!("failed to save connection group: {err:#}"); self.status = format!("{}: {err:#}", t!("save")).into(); cx.notify(); return; } self.status = if editing.is_some() { t!("connection_group_renamed", name = name).into() } else { t!("connection_group_created", name = name).into() }; self.active_dialog = None; self.editing_connection_group = None; window.close_dialog(cx); cx.notify(); } fn active_sftp_dialog_target(&self) -> Option<(String, crate::sftp::SftpHandle)> { let group_id = self.active_group.clone()?; let handle = self.sftp_handles.get(&group_id)?.clone(); Some((group_id, handle)) } pub(crate) fn show_sftp_rename_dialog( &mut self, remote_path: String, window: &mut Window, cx: &mut Context, ) { if self.active_dialog.is_some() { return; } let Some((group_id, _)) = self.active_sftp_dialog_target() else { return; }; self.active_dialog = Some(crate::app::DialogKind::SftpRename); self.sftp_rename_state = Some(crate::app::SftpRenameState { group_id, old_path: remote_path.clone(), in_flight: false, error: None, }); Self::set_input_value( &self.sftp_rename_input, crate::sftp::base_name(&remote_path), window, cx, ); let view = cx.entity(); let rename_input = self.sftp_rename_input.clone(); let focus_input = rename_input.clone(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("rename").to_string()) .w(px(440.)) .close_button(false) .keyboard(false) .overlay_closable(false) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::SftpRename) { this.active_dialog = None; this.sftp_rename_state = None; } cx.notify(); }); } }) .content({ let view = view.clone(); let rename_input = rename_input.clone(); move |content, window, cx| { let state = view.read(cx).sftp_rename_state.clone(); let new_name = rename_input.read(cx).value().trim().to_string(); let name_error = if new_name.is_empty() { Some(t!("name_required").to_string()) } else if matches!(new_name.as_str(), "." | "..") || new_name.contains('/') || new_name.contains('\0') { Some(t!("invalid_remote_name").to_string()) } else { None }; let in_flight = state.as_ref().is_some_and(|state| state.in_flight); let unchanged = state.as_ref().is_some_and(|state| { crate::sftp::base_name(&state.old_path) == new_name }); let error = state.and_then(|state| state.error); content.child( v_flex() .gap_3() .child( v_flex() .gap_1() .child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child(t!("new_name").to_string()), ) .child(Input::new(&rename_input).w_full().tab_index(0)), ) .when_some(name_error.or(error), |this, message| { this.child( div() .text_sm() .text_color(cx.theme().danger) .child(message), ) }) .child( h_flex() .justify_end() .gap_2() .child( pointer_button("sftp-rename-cancel") .label(t!("cancel").to_string()) .disabled(in_flight) .on_click(window.listener_for( &view, |this, _, window, cx| { if this .sftp_rename_state .as_ref() .is_some_and(|state| state.in_flight) { return; } this.active_dialog = None; this.sftp_rename_state = None; window.close_dialog(cx); cx.notify(); }, )), ) .child( pointer_button("sftp-rename-save") .primary() .label(t!("rename").to_string()) .loading(in_flight) .disabled( in_flight || unchanged || new_name.is_empty() || new_name == "." || new_name == ".." || new_name.contains('/') || new_name.contains('\0'), ) .on_click(window.listener_for( &view, |this, _, window, cx| { this.submit_sftp_rename(window, cx); }, )), ), ), ) } }) }); window.defer(cx, move |window, cx| { focus_input.read(cx).focus_handle(cx).focus(window, cx); }); } pub(crate) fn submit_sftp_rename(&mut self, window: &mut Window, cx: &mut Context) { let Some(state) = self.sftp_rename_state.clone() else { return; }; if state.in_flight { return; } let new_name = self.sftp_rename_input.read(cx).value().trim().to_string(); if new_name.is_empty() || matches!(new_name.as_str(), "." | "..") || new_name.contains('/') || new_name.contains('\0') { return; } let parent = crate::sftp::parent_dir(&state.old_path).unwrap_or_else(|| "/".to_string()); let new_path = crate::sftp::join_remote(&parent, &new_name); if new_path == state.old_path { return; } let Some(handle) = self.sftp_handles.get(&state.group_id).cloned() else { if let Some(state) = self.sftp_rename_state.as_mut() { state.error = Some(t!("sftp_connection_unavailable").to_string()); } cx.notify(); return; }; if let Some(state) = self.sftp_rename_state.as_mut() { state.in_flight = true; state.error = None; } cx.notify(); let group_id = state.group_id; let old_path = state.old_path; let response = handle.rename_path(old_path.clone(), new_path); cx.spawn_in(window, async move |this, cx| { let result = response .await .unwrap_or_else(|_| Err(t!("sftp_connection_unavailable").to_string())); match result { Ok(()) => { let _ = this.update_in(cx, |this, window, cx| { let is_current = this.active_dialog == Some(crate::app::DialogKind::SftpRename) && this.sftp_rename_state.as_ref().is_some_and(|state| { state.group_id == group_id && state.old_path == old_path }); if is_current { this.active_dialog = None; this.sftp_rename_state = None; this.status = t!("rename_success", name = new_name).to_string().into(); window.close_dialog(cx); cx.notify(); } }); } Err(error) => { let _ = this.update(cx, |this, cx| { if let Some(state) = this.sftp_rename_state.as_mut().filter(|state| { state.group_id == group_id && state.old_path == old_path }) { state.in_flight = false; state.error = Some(t!("rename_failed", err = error).to_string()); cx.notify(); } }); } } Ok::<(), anyhow::Error>(()) }) .detach(); } fn initial_sftp_editor_bounds(viewport: Size) -> Bounds { let margin = px(16.); let available_width = (viewport.width - margin * 2.).max(px(1.)); let available_height = (viewport.height - margin * 2.).max(px(1.)); let width = px(880.).min(available_width); let height = px(640.).min(available_height); Bounds { origin: point( ((viewport.width - width) / 2.).max(margin), ((viewport.height - height) / 2.).max(margin), ), size: size(width, height), } } fn clamp_sftp_editor_bounds( mut bounds: Bounds, viewport: Size, ) -> Bounds { let margin = px(16.); let max_width = (viewport.width - margin * 2.).max(px(1.)); let max_height = (viewport.height - margin * 2.).max(px(1.)); let min_width = px(640.).min(max_width); let min_height = px(420.).min(max_height); bounds.size.width = bounds.size.width.clamp(min_width, max_width); bounds.size.height = bounds.size.height.clamp(min_height, max_height); let max_x = (viewport.width - margin - bounds.size.width).max(margin); let max_y = (viewport.height - margin - bounds.size.height).max(margin); bounds.origin.x = bounds.origin.x.clamp(margin, max_x); bounds.origin.y = bounds.origin.y.clamp(margin, max_y); bounds } fn start_sftp_editor_move( &mut self, pointer_origin: Point, viewport: Size, cx: &mut Context, ) { let Some(state) = self.sftp_editor_state.as_mut() else { return; }; state.bounds = Self::clamp_sftp_editor_bounds(state.bounds, viewport); state.interaction = Some(crate::app::SftpEditorInteraction::Move { pointer_origin, initial_bounds: state.bounds, }); cx.notify(); } fn start_sftp_editor_resize( &mut self, pointer_origin: Point, viewport: Size, cx: &mut Context, ) { let Some(state) = self.sftp_editor_state.as_mut() else { return; }; state.bounds = Self::clamp_sftp_editor_bounds(state.bounds, viewport); state.interaction = Some(crate::app::SftpEditorInteraction::Resize { pointer_origin, initial_bounds: state.bounds, }); cx.notify(); } fn update_sftp_editor_interaction( &mut self, pointer: Point, viewport: Size, cx: &mut Context, ) { let Some(state) = self.sftp_editor_state.as_mut() else { return; }; let Some(interaction) = state.interaction.clone() else { return; }; let mut bounds = match interaction { crate::app::SftpEditorInteraction::Move { pointer_origin, initial_bounds, } => Bounds { origin: point( initial_bounds.origin.x + pointer.x - pointer_origin.x, initial_bounds.origin.y + pointer.y - pointer_origin.y, ), size: initial_bounds.size, }, crate::app::SftpEditorInteraction::Resize { pointer_origin, initial_bounds, } => { let margin = px(16.); let max_width = (viewport.width - margin - initial_bounds.origin.x).max(px(1.)); let max_height = (viewport.height - margin - initial_bounds.origin.y).max(px(1.)); let min_width = px(640.).min(max_width); let min_height = px(420.).min(max_height); Bounds { origin: initial_bounds.origin, size: size( (initial_bounds.size.width + pointer.x - pointer_origin.x) .clamp(min_width, max_width), (initial_bounds.size.height + pointer.y - pointer_origin.y) .clamp(min_height, max_height), ), } } }; bounds = Self::clamp_sftp_editor_bounds(bounds, viewport); if state.bounds != bounds { state.bounds = bounds; cx.notify(); } } fn finish_sftp_editor_interaction(&mut self, cx: &mut Context) { if let Some(state) = self.sftp_editor_state.as_mut() && state.interaction.take().is_some() { cx.notify(); } } pub(crate) fn show_sftp_editor_dialog( &mut self, remote_path: String, window: &mut Window, cx: &mut Context, ) { if self.active_dialog.is_some() { return; } let Some((group_id, _)) = self.active_sftp_dialog_target() else { return; }; let bounds = Self::initial_sftp_editor_bounds(window.viewport_size()); let initial_encoding = self .active_tab .as_ref() .and_then(|tab_id| self.tabs.iter().find(|tab| tab.id == *tab_id)) .map(|tab| tab.text_encoding()) .unwrap_or(TextEncoding::Utf8); self.active_dialog = Some(crate::app::DialogKind::SftpEditor); self.sftp_editor_state = Some(crate::app::SftpEditorState { group_id, remote_path: remote_path.clone(), raw_content: Vec::new(), original_content: String::new(), encoding: initial_encoding, has_bom: false, decode_had_errors: false, loaded: false, loading: true, saving: false, message: None, error: None, bounds, interaction: None, }); self.sftp_editor_input.update(cx, |input, cx| { input.set_highlighter(crate::sftp::editor_language(&remote_path), cx); input.set_value("", window, cx); }); let view = cx.entity(); let editor_input = self.sftp_editor_input.clone(); let title = format!( "{} - {}", t!("edit_file"), crate::sftp::base_name(&remote_path) ); window.open_dialog(cx, move |dialog: Dialog, window, _| { let viewport = window.viewport_size(); dialog .w(viewport.width) .h(viewport.height) .margin_top(px(0.)) .p_0() .bg(gpui::transparent_black()) .border_0() .rounded_none() .close_button(false) .keyboard(true) .overlay_closable(false) .on_cancel({ let view = view.clone(); move |_, _, cx| { !view .read(cx) .sftp_editor_state .as_ref() .is_some_and(|state| state.saving) } }) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::SftpEditor) { this.active_dialog = None; this.sftp_editor_state = None; } cx.notify(); }); } }) .content({ let view = view.clone(); let editor_input = editor_input.clone(); let title = title.clone(); move |content, window, cx| { let viewport = window.viewport_size(); let state = view.read(cx).sftp_editor_state.clone(); let current_content = editor_input.read(cx).value().to_string(); let (current_bytes, encode_had_errors) = state .as_ref() .map(|state| { let (encoded, had_errors) = state .encoding .encode_file(¤t_content, state.has_bom); (encoded.len(), had_errors) }) .unwrap_or((current_content.len(), false)); let loaded = state.as_ref().is_some_and(|state| state.loaded); let loading = state.as_ref().is_some_and(|state| state.loading); let saving = state.as_ref().is_some_and(|state| state.saving); let file_encoding = state .as_ref() .map(|state| state.encoding) .unwrap_or(TextEncoding::Utf8); let decode_had_errors = state .as_ref() .is_some_and(|state| state.decode_had_errors); let dirty = state.as_ref().is_some_and(|state| { state.loaded && state.original_content != current_content }); let over_limit = current_bytes > crate::sftp::MAX_INLINE_EDIT_BYTES; let error = state.as_ref().and_then(|state| state.error.clone()); let message = state.as_ref().and_then(|state| state.message.clone()); let remote_path = state .as_ref() .map(|state| state.remote_path.clone()) .unwrap_or_default(); let status = if loading { t!("loading_file_content").to_string() } else if saving { t!("saving_file_content").to_string() } else if over_limit { t!("editor_content_too_large", max = "2 MB").to_string() } else if encode_had_errors { t!( "encoding_encode_failed", encoding = file_encoding.label() ) .to_string() } else if let Some(error) = error.clone() { error } else if decode_had_errors { t!( "encoding_decode_warning", encoding = file_encoding.label() ) .to_string() } else if dirty { t!("unsaved_changes").to_string() } else if let Some(message) = message { message } else if loaded { t!("file_content_loaded").to_string() } else { String::new() }; let status_color = if error.is_some() || over_limit || encode_had_errors { cx.theme().danger } else if dirty || decode_had_errors { cx.theme().warning } else if loaded && !loading && !saving { cx.theme().success } else { cx.theme().muted_foreground }; let editor_bounds = state .as_ref() .map(|state| { Ashell::clamp_sftp_editor_bounds(state.bounds, viewport) }) .unwrap_or_else(|| Ashell::initial_sftp_editor_bounds(viewport)); let moving = state.as_ref().is_some_and(|state| { matches!( state.interaction.as_ref(), Some(crate::app::SftpEditorInteraction::Move { .. }) ) }); let resizing = state.as_ref().is_some_and(|state| { matches!( state.interaction.as_ref(), Some(crate::app::SftpEditorInteraction::Resize { .. }) ) }); content.p_0().child( div() .id("sftp-editor-stage") .size_full() .relative() .when(moving, |this| this.cursor_grabbing()) .when(resizing, |this| this.cursor_nwse_resize()) .on_mouse_up( MouseButton::Left, window.listener_for(&view, |this, _, _, cx| { this.finish_sftp_editor_interaction(cx); }), ) .on_mouse_up_out( MouseButton::Left, window.listener_for(&view, |this, _, _, cx| { this.finish_sftp_editor_interaction(cx); }), ) .child( v_flex() .absolute() .left(editor_bounds.origin.x) .top(editor_bounds.origin.y) .w(editor_bounds.size.width) .h(editor_bounds.size.height) .min_h(px(0.)) .overflow_hidden() .occlude() .bg(cx.theme().background) .border_1() .border_color(cx.theme().border) .rounded(cx.theme().radius_lg) .shadow_xl() .on_any_mouse_down(|_, _, cx| { cx.stop_propagation(); }) .child( h_flex() .id("sftp-editor-title-bar") .flex_none() .h(px(40.)) .px_3() .items_center() .gap_2() .border_b_1() .border_color(cx.theme().border) .bg(cx.theme().muted.opacity(0.8)) .cursor_grab() .on_mouse_down( MouseButton::Left, window.listener_for( &view, |this, event: &MouseDownEvent, window, cx| { this.start_sftp_editor_move( event.position, window.viewport_size(), cx, ); window.prevent_default(); cx.stop_propagation(); }, ), ) .on_drag( SftpEditorDrag::Move, |drag, _, _, cx| { cx.stop_propagation(); cx.new(|_| drag.clone()) }, ) .on_drag_move(window.listener_for( &view, |this, event: &DragMoveEvent, window, cx| { if matches!( event.drag(cx), SftpEditorDrag::Move ) { this.update_sftp_editor_interaction( event.event.position, window.viewport_size(), cx, ); } }, )) .on_mouse_up( MouseButton::Left, window.listener_for(&view, |this, _, _, cx| { this.finish_sftp_editor_interaction(cx); }), ) .on_mouse_up_out( MouseButton::Left, window.listener_for(&view, |this, _, _, cx| { this.finish_sftp_editor_interaction(cx); }), ) .child( div() .flex_1() .min_w(px(0.)) .truncate() .font_weight(FontWeight::SEMIBOLD) .child(title.clone()), ) .child( div() .on_mouse_down( MouseButton::Left, |_, window, cx| { window.prevent_default(); cx.stop_propagation(); }, ) .child( pointer_button("sftp-editor-close") .small() .ghost() .icon(IconName::Close) .tooltip(t!("cancel").to_string()) .disabled(saving) .on_click(window.listener_for( &view, |this, _, window, cx| { if this .sftp_editor_state .as_ref() .is_some_and(|state| { state.saving }) { return; } this.active_dialog = None; this.sftp_editor_state = None; window.close_dialog(cx); cx.notify(); }, )), ), ), ) .child( v_flex() .flex_1() .min_h(px(0.)) .gap_2() .p_3() .child( h_flex() .w_full() .items_center() .gap_2() .child( div() .id("sftp-editor-remote-path") .flex_1() .min_w(px(0.)) .truncate() .text_sm() .text_color( cx.theme().muted_foreground, ) .tooltip({ let remote_path = remote_path.clone(); move |window, cx| { gpui_component::tooltip::Tooltip::new( remote_path.clone(), ) .build(window, cx) } }) .child(remote_path), ) .child( pointer_button( "sftp-editor-encoding", ) .ghost() .icon(IconName::Globe) .label(file_encoding.label()) .tooltip( t!("file_encoding").to_string(), ) .disabled(loading || saving) .dropdown_menu_with_anchor( Anchor::BottomRight, { let view = view.clone(); move |menu, window, _| { FILE_ENCODINGS .iter() .copied() .fold( menu.min_w(0.), |menu, candidate| { menu.item( PopupMenuItem::new( candidate.label(), ) .checked( candidate == file_encoding, ) .on_click( window.listener_for( &view, move |this, _, window, cx| { this.set_sftp_editor_encoding( candidate, window, cx, ); }, ), ), ) }, ) } }, ), ), ) .child( div() .flex_1() .min_h(px(0.)) .when( loading || (!loaded && error.is_some()), |this| { this.flex() .items_center() .justify_center() }, ) .child(if loaded { Input::new(&editor_input) .size_full() .tab_index(0) .into_any_element() } else { v_flex() .items_center() .gap_3() .child( div() .text_sm() .text_color(status_color) .child(status.clone()), ) .when(!loading, |this| { this.child( pointer_button( "sftp-editor-retry", ) .icon(IconName::Redo) .label( t!("retry") .to_string(), ) .on_click( window.listener_for( &view, |this, _, window, cx| { this.load_sftp_editor_content(window, cx); }, ), ), ) }) .into_any_element() }), ) .child( h_flex() .items_center() .gap_2() .child( div() .flex_1() .min_w(px(0.)) .truncate() .text_sm() .text_color(status_color) .child(status), ) .when(loaded, |this| { this.child( div() .text_sm() .text_color( cx.theme() .muted_foreground, ) .child( crate::system::format_bytes( current_bytes as u64, ), ), ) }) .child( pointer_button("sftp-editor-cancel") .label(t!("cancel").to_string()) .disabled(saving) .on_click(window.listener_for( &view, |this, _, window, cx| { if this .sftp_editor_state .as_ref() .is_some_and(|state| { state.saving }) { return; } this.active_dialog = None; this.sftp_editor_state = None; window.close_dialog(cx); cx.notify(); }, )), ) .child( pointer_button("sftp-editor-save") .primary() .icon(IconName::Check) .label(t!("save").to_string()) .loading(saving) .disabled( !loaded || !dirty || saving || over_limit || decode_had_errors || encode_had_errors, ) .on_click(window.listener_for( &view, |this, _, window, cx| { this.save_sftp_editor_content( window, cx, ); }, )), ), ), ) .child( div() .id("sftp-editor-resize-handle") .absolute() .right_0() .bottom_0() .size(px(18.)) .cursor_nwse_resize() .on_mouse_down( MouseButton::Left, window.listener_for( &view, |this, event: &MouseDownEvent, window, cx| { this.start_sftp_editor_resize( event.position, window.viewport_size(), cx, ); window.prevent_default(); cx.stop_propagation(); }, ), ) .on_drag( SftpEditorDrag::Resize, |drag, _, _, cx| { cx.stop_propagation(); cx.new(|_| drag.clone()) }, ) .on_drag_move(window.listener_for( &view, |this, event: &DragMoveEvent, window, cx| { if matches!( event.drag(cx), SftpEditorDrag::Resize ) { this.update_sftp_editor_interaction( event.event.position, window.viewport_size(), cx, ); } }, )) .on_mouse_up( MouseButton::Left, window.listener_for(&view, |this, _, _, cx| { this.finish_sftp_editor_interaction(cx); }), ) .on_mouse_up_out( MouseButton::Left, window.listener_for(&view, |this, _, _, cx| { this.finish_sftp_editor_interaction(cx); }), ) .child( Icon::new(IconName::ResizeCorner) .size_3() .absolute() .right(px(2.)) .bottom(px(2.)) .text_color( cx.theme() .muted_foreground .opacity(0.6), ), ), ), ), ) } }) }); self.load_sftp_editor_content(window, cx); } pub(crate) fn set_sftp_editor_encoding( &mut self, encoding: TextEncoding, window: &mut Window, cx: &mut Context, ) { let Some(state) = self.sftp_editor_state.clone() else { return; }; if state.encoding == encoding { return; } let current_content = self.sftp_editor_input.read(cx).value().to_string(); let dirty = state.loaded && current_content != state.original_content; if !state.loaded || dirty { if let Some(editor_state) = self.sftp_editor_state.as_mut() { editor_state.encoding = encoding; editor_state.has_bom = encoding.default_bom(); if !state.loaded { editor_state.decode_had_errors = false; } editor_state.error = None; editor_state.message = Some(t!("file_encoding_changed", encoding = encoding.label()).to_string()); } cx.notify(); return; } let (content, had_errors, has_bom) = encoding.decode_file(&state.raw_content); self.sftp_editor_input.update(cx, |input, cx| { input.set_value(content.clone(), window, cx); }); if let Some(editor_state) = self.sftp_editor_state.as_mut() { editor_state.encoding = encoding; editor_state.original_content = content; editor_state.has_bom = has_bom; editor_state.decode_had_errors = had_errors; editor_state.message = None; editor_state.error = None; } cx.notify(); } pub(crate) fn load_sftp_editor_content(&mut self, window: &mut Window, cx: &mut Context) { let Some(state) = self.sftp_editor_state.clone() else { return; }; let Some(handle) = self.sftp_handles.get(&state.group_id).cloned() else { if let Some(state) = self.sftp_editor_state.as_mut() { state.loading = false; state.error = Some(t!("sftp_connection_unavailable").to_string()); } cx.notify(); return; }; if let Some(state) = self.sftp_editor_state.as_mut() { state.loaded = false; state.loading = true; state.saving = false; state.message = None; state.error = None; } cx.notify(); let group_id = state.group_id; let remote_path = state.remote_path; let response = handle.read_text_file(remote_path.clone()); cx.spawn_in(window, async move |this, cx| { let result = response .await .unwrap_or_else(|_| Err(t!("sftp_connection_unavailable").to_string())); let _ = gpui::AsyncWindowContext::update(cx, |window, cx| { let _ = this.update(cx, |this, cx| { let is_current = this.active_dialog == Some(crate::app::DialogKind::SftpEditor) && this.sftp_editor_state.as_ref().is_some_and(|state| { state.group_id == group_id && state.remote_path == remote_path }); if !is_current { return; } match result { Ok(raw_content) => { let preferred_encoding = this .sftp_editor_state .as_ref() .map(|state| state.encoding) .unwrap_or(TextEncoding::Utf8); let encoding = TextEncoding::detect_bom(&raw_content) .or_else(|| { std::str::from_utf8(&raw_content) .is_ok() .then_some(TextEncoding::Utf8) }) .unwrap_or(preferred_encoding); let (content, had_errors, has_bom) = encoding.decode_file(&raw_content); this.sftp_editor_input.update(cx, |input, cx| { input.set_value(content.clone(), window, cx); input.focus_handle(cx).focus(window, cx); }); if let Some(state) = this.sftp_editor_state.as_mut() { state.raw_content = raw_content; state.original_content = content; state.encoding = encoding; state.has_bom = has_bom; state.decode_had_errors = had_errors; state.loaded = true; state.loading = false; state.message = None; state.error = None; } } Err(error) => { if let Some(state) = this.sftp_editor_state.as_mut() { state.loaded = false; state.loading = false; state.error = Some(t!("open_remote_file_failed", err = error).to_string()); } } } cx.notify(); }); }); Ok::<(), anyhow::Error>(()) }) .detach(); } pub(crate) fn save_sftp_editor_content(&mut self, window: &mut Window, cx: &mut Context) { let Some(state) = self.sftp_editor_state.clone() else { return; }; if !state.loaded || state.loading || state.saving { return; } let content = self.sftp_editor_input.read(cx).value().to_string(); if content == state.original_content { return; } if state.decode_had_errors { if let Some(state) = self.sftp_editor_state.as_mut() { state.error = Some( t!("encoding_decode_warning", encoding = state.encoding.label()).to_string(), ); } cx.notify(); return; } let (encoded_content, encode_had_errors) = state.encoding.encode_file(&content, state.has_bom); if encode_had_errors { if let Some(state) = self.sftp_editor_state.as_mut() { state.error = Some( t!("encoding_encode_failed", encoding = state.encoding.label()).to_string(), ); } cx.notify(); return; } if encoded_content.len() > crate::sftp::MAX_INLINE_EDIT_BYTES { if let Some(state) = self.sftp_editor_state.as_mut() { state.error = Some(t!("editor_content_too_large", max = "2 MB").to_string()); } cx.notify(); return; } let Some(handle) = self.sftp_handles.get(&state.group_id).cloned() else { if let Some(state) = self.sftp_editor_state.as_mut() { state.error = Some(t!("sftp_connection_unavailable").to_string()); } cx.notify(); return; }; if let Some(state) = self.sftp_editor_state.as_mut() { state.saving = true; state.message = None; state.error = None; } cx.notify(); let group_id = state.group_id; let remote_path = state.remote_path; let saved_bytes = encoded_content.clone(); let response = handle.write_text_file(remote_path.clone(), encoded_content); cx.spawn_in(window, async move |this, cx| { let result = response .await .unwrap_or_else(|_| Err(t!("sftp_connection_unavailable").to_string())); let _ = this.update(cx, |this, cx| { let Some(state) = this .sftp_editor_state .as_mut() .filter(|state| state.group_id == group_id && state.remote_path == remote_path) else { return; }; state.saving = false; match result { Ok(()) => { state.raw_content = saved_bytes; state.original_content = content; state.decode_had_errors = false; state.message = Some(t!("remote_file_saved").to_string()); state.error = None; } Err(error) => { state.message = None; state.error = Some(t!("save_remote_file_failed", err = error).to_string()); } } cx.notify(); }); Ok::<(), anyhow::Error>(()) }) .detach(); } pub(crate) fn show_remote_processes_dialog( &mut self, view_mode: crate::app::ServerMonitorView, window: &mut Window, cx: &mut Context, ) { if self.active_dialog.is_some() || !self.system_tab_id.as_ref().is_some_and(|tab_id| { self.tabs.iter().any(|tab| { tab.id == *tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected }) }) { return; } self.active_dialog = Some(crate::app::DialogKind::Processes); self.server_monitor_view = view_mode; self.remote_processes.clear(); self.remote_process_status = None; self.expanded_process_pid = None; Self::set_input_value(&self.remote_process_filter_input, "", window, cx); self.sort_remote_processes(); self.request_active_process_snapshot(); let view = cx.entity(); let title = t!("system_processes").to_string(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(title.clone()) .w(px(720.)) .h(px(560.)) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::Processes) { this.active_dialog = None; } cx.notify(); }); } }) .content({ let view = view.clone(); move |content, _window, cx| { content.child(view.update(cx, |this, cx| { this.render_remote_process_list(view_mode, cx) })) } }) }); } pub(crate) fn show_remote_ports_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() || !self.system_tab_id.as_ref().is_some_and(|tab_id| { self.tabs.iter().any(|tab| { tab.id == *tab_id && tab.kind == crate::terminal::TabKind::Ssh && tab.connected }) }) { return; } self.active_dialog = Some(crate::app::DialogKind::Ports); self.remote_ports.clear(); self.remote_ports_status = None; Self::set_input_value(&self.remote_port_filter_input, "", window, cx); self.request_active_port_snapshot(); let view = cx.entity(); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("network_ports").to_string()) .w(px(760.)) .h(px(560.)) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::Ports) { this.active_dialog = None; } cx.notify(); }); } }) .content({ let view = view.clone(); move |content, _window, cx| { content.child(view.update(cx, |this, cx| this.render_remote_port_list(cx))) } }) }); } pub(crate) fn show_about_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; } self.active_dialog = Some(crate::app::DialogKind::About); let view = cx.entity(); let version = env!("CARGO_PKG_VERSION"); window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("menu_about_ashell").to_string()) .w(px(440.)) .keyboard(false) .on_close({ let view = view.clone(); move |_, _, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::About) { this.active_dialog = None; } cx.notify(); }); } }) .content(move |content, _window, cx| { content.child( v_flex() .w_full() .items_center() .gap_3() .py_4() .child( div() .text_size(ui_rems(1.5)) .font_weight(FontWeight::BOLD) .child("Ashell"), ) .child(div().text_size(ui_rems(0.9)).child(format!( "{} {}", t!("version"), version ))) .child( div() .text_size(ui_rems(0.9)) .text_color(cx.theme().muted_foreground) .text_center() .child(t!("about_description")), ) .child( div() .text_size(ui_rems(0.9)) .text_color(cx.theme().muted_foreground) .text_center() .child(t!("about_feedback_hint")), ) .child( pointer_button("about-project-link") .label("https://github.com/rust-kotlin/ashell") .ghost() .on_click(|_, _, _| { if let Err(error) = open::that("https://github.com/rust-kotlin/ashell") { tracing::warn!( "failed to open Ashell project website: {error}" ); } }), ) .child( v_flex() .w_full() .mt_2() .p_3() .gap_2() .rounded_md() .bg(cx.theme().secondary.opacity(0.4)) .border_1() .border_color(cx.theme().border) .child( div() .text_size(ui_rems(0.85)) .font_weight(FontWeight::BOLD) .text_color(cx.theme().foreground) .child(t!("about_tips_title")), ) .child( v_flex() .gap_2() .text_size(ui_rems(0.8)) .text_color(cx.theme().muted_foreground) .child( h_flex() .gap_2() .items_start() .child(div().flex_none().child("•")) .child(div().flex_1().min_w(px(0.)).child(t!("about_tip_backspace"))), ) .child( h_flex() .gap_2() .items_start() .child(div().flex_none().child("•")) .child(div().flex_1().min_w(px(0.)).child(t!("about_tip_close_shortcuts"))), ) .child( h_flex() .gap_2() .items_start() .child(div().flex_none().child("•")) .child(div().flex_1().min_w(px(0.)).child(t!("about_tip_conflict_shortcuts"))), ), ), ), ) }) .footer({ let view = view.clone(); h_flex().w_full().justify_end().child( pointer_button("about-close") .primary() .label(t!("close").to_string()) .on_click(move |_, window, cx| { view.update(cx, |this, cx| { if this.active_dialog == Some(crate::app::DialogKind::About) { this.active_dialog = None; } cx.notify(); }); window.close_dialog(cx); }), ) }) }); } pub(crate) fn show_settings_dialog(&mut self, window: &mut Window, cx: &mut Context) { if self.active_dialog.is_some() { return; } self.active_dialog = Some(crate::app::DialogKind::Settings); let view = cx.entity(); // Suspend workspace commands that could interfere with recording. Quit stays active. crate::app::keybinding_recorder::unbind_all_workspace_keys(cx, &self.config); self.keybinds_suspended = true; window.open_dialog(cx, move |dialog: Dialog, _window, _| { dialog .title(t!("settings").to_string()) .w(px(840.)) .h(px(560.)) .on_close({ let view = view.clone(); move |_, _window, cx| { // Re-register all workspace keys when closing settings view.update(cx, |this, cx| { this.active_dialog = None; this.keybinds_suspended = false; this.recording_action = None; this.keybind_error = None; crate::app::keybinding_recorder::bind_workspace_keys_from_config( cx, &this.config, ); crate::app::system_menu::set_app_menus(cx); cx.notify(); }); } }) .content({ let view = view.clone(); move |content, _window, cx| { use gpui_component::setting::{Settings, SettingPage, SettingGroup, SettingItem, SettingField}; use gpui::IntoElement; let version = env!("CARGO_PKG_VERSION"); let view_clone_for_general = view.clone(); let sync_endpoint_input = view.read(cx).sync_endpoint_input.clone(); let sync_username_input = view.read(cx).sync_username_input.clone(); let sync_webdav_password_input = view.read(cx).sync_webdav_password_input.clone(); let sync_s3_endpoint_input = view.read(cx).sync_s3_endpoint_input.clone(); let sync_s3_region_input = view.read(cx).sync_s3_region_input.clone(); let sync_s3_bucket_input = view.read(cx).sync_s3_bucket_input.clone(); let sync_s3_object_key_input = view.read(cx).sync_s3_object_key_input.clone(); let sync_s3_access_key_input = view.read(cx).sync_s3_access_key_input.clone(); let sync_s3_secret_key_input = view.read(cx).sync_s3_secret_key_input.clone(); let sync_s3_session_token_input = view.read(cx).sync_s3_session_token_input.clone(); let sync_encryption_password_input = view.read(cx).sync_encryption_password_input.clone(); let focus_handle = view.read(cx).focus_handle.clone(); let terminal_group = cfg!(windows).then(|| { use crate::session::config::LocalTerminalShell; let shells = [ LocalTerminalShell::WindowsPowerShell, LocalTerminalShell::PowerShell7, LocalTerminalShell::CommandPrompt, LocalTerminalShell::GitBash, ]; SettingGroup::new() .title(t!("settings_group_terminal").to_string()) .item(SettingItem::new( t!("default_local_terminal").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { let current = view.read(cx).config.local_terminal_shell(); pointer_button("default-local-terminal-dropdown") .icon(IconName::SquareTerminal) .label(crate::session::local_terminal_shell_label(current)) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, _cx| { menu = menu.min_w(px(220.)); for shell in shells { let checked = shell == current; let available = crate::backend::local::local_terminal_shell_available(shell); let label = crate::session::local_terminal_shell_label(shell); let label = if available { label } else { format!( "{} ({})", label, t!("local_terminal_not_installed") ) }; menu = menu.item( PopupMenuItem::new(label) .checked(checked) .disabled(!available) .on_click(window.listener_for( &view, move |this, _, _, cx| { this.change_local_terminal_shell(shell, cx); }, )), ); } menu } }) .into_any_element() } }), )) }); content.child( div() .flex() .flex_col() .size_full() .track_focus(&focus_handle) .on_key_down({ let view = view.clone(); move |ev: &gpui::KeyDownEvent, window, cx| { view.update(cx, |this, cx| { let Some(action) = this.recording_action.clone() else { return; }; window.prevent_default(); cx.stop_propagation(); if ev.keystroke.key == "escape" { this.recording_action = None; crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); cx.notify(); return; } if ev.keystroke.key == "backspace" && !ev.keystroke.modifiers.control && !ev.keystroke.modifiers.alt && !ev.keystroke.modifiers.shift && !ev.keystroke.modifiers.platform && !ev.keystroke.modifiers.function { this.recording_action = None; this.keybind_error = None; this.config.set_key_binding(&action, "none"); crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); this.save_preferences_background(); cx.notify(); return; } let Some(new_key) = crate::app::keybinding_recorder::normalize_recorded_keystroke(ev) else { return; }; // Check for conflicts with other actions if let Some((_conflict_id, conflict_label)) = crate::app::keybinding_recorder::find_conflict( &this.config, &action, &new_key, ) { let formatted = crate::app::keybinding_recorder::format_keystroke(&new_key); this.recording_action = None; this.keybind_error = Some(( action.clone(), t!("keybind_conflict", key = formatted, action = conflict_label).to_string(), )); crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); cx.notify(); return; } this.recording_action = None; this.keybind_error = None; this.config.set_key_binding(&action, &new_key); crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); this.save_preferences_background(); cx.notify(); }); } }) .on_mouse_down_out({ let view = view.clone(); move |_, _window, cx| { view.update(cx, |this, cx| { if this.recording_action.is_some() { this.recording_action = None; crate::app::keybinding_recorder::restore_quit_keybinding(cx, &this.config); cx.notify(); } }); } }) .child( Settings::new("settings") .sidebar_width(px(180.)) .sidebar_style(div().bg(cx.theme().background).style()) .page( SettingPage::new(t!("settings_general").to_string()) .icon(IconName::Settings) .default_open(true) .group( SettingGroup::new() .title(t!("settings_group_appearance").to_string()) .item( SettingItem::new( t!("theme_mode").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { let (follow_system, is_dark_mode) = { let state = view.read(cx); (state.follow_system_theme, state.theme_mode.is_dark()) }; pointer_button("theme-mode-dropdown") .icon(if follow_system { IconName::Sun } else if is_dark_mode { IconName::Moon } else { IconName::Sun }) .label(if follow_system { t!("follow_system").to_string() } else if is_dark_mode { t!("use_dark_mode").to_string() } else { t!("use_light_mode").to_string() }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let (follow_system, is_dark_mode) = { let state = view.read(cx); (state.follow_system_theme, state.theme_mode.is_dark()) }; menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("follow_system").to_string()) .checked(follow_system) .on_click(window.listener_for(&view, |this, _, window, cx| { this.set_follow_system_theme(true, window, cx) })) ) .item( PopupMenuItem::new(t!("use_light_mode").to_string()) .checked(!follow_system && !is_dark_mode) .on_click(window.listener_for(&view, |this, _, window, cx| { this.switch_theme_mode(crate::app::ThemeMode::Light, window, cx) })) ) .item( PopupMenuItem::new(t!("use_dark_mode").to_string()) .checked(!follow_system && is_dark_mode) .on_click(window.listener_for(&view, |this, _, window, cx| { this.switch_theme_mode(crate::app::ThemeMode::Dark, window, cx) })) ); menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("light_theme").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { let current_theme = view.read(cx).light_theme_name.to_string(); pointer_button("light-theme-dropdown") .icon(IconName::Sun) .label(current_theme.clone()) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current_theme = view.read(cx).light_theme_name.to_string(); let themes = gpui_component::ThemeRegistry::global(cx).sorted_themes(); let light_themes: Vec<_> = themes.into_iter().filter(|t| !t.mode.is_dark()).map(|t| t.name.clone()).collect(); menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); for theme_name in light_themes { let checked = theme_name == current_theme; menu = menu.item( PopupMenuItem::new(theme_name.clone()) .checked(checked) .on_click(window.listener_for(&view, move |this, _, window, cx| { this.apply_theme(theme_name.clone(), window, cx) })) ); } menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("dark_theme").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { let current_theme = view.read(cx).dark_theme_name.to_string(); pointer_button("dark-theme-dropdown") .icon(IconName::Moon) .label(current_theme.clone()) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current_theme = view.read(cx).dark_theme_name.to_string(); let themes = gpui_component::ThemeRegistry::global(cx).sorted_themes(); let dark_themes: Vec<_> = themes.into_iter().filter(|t| t.mode.is_dark()).map(|t| t.name.clone()).collect(); menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); for theme_name in dark_themes { let checked = theme_name == current_theme; menu = menu.item( PopupMenuItem::new(theme_name.clone()) .checked(checked) .on_click(window.listener_for(&view, move |this, _, window, cx| { this.apply_theme(theme_name.clone(), window, cx) })) ); } menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( format!("{}{}", t!("title_bar_style"), t!("restart_hint")), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { let current_style = view.read(cx).config.title_bar_style(); pointer_button("title-bar-style-dropdown") .label(match current_style { crate::session::config::TitleBarStyle::Native => t!("title_bar_native").to_string(), crate::session::config::TitleBarStyle::Integrated => t!("title_bar_integrated").to_string(), }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current_style = view.read(cx).config.title_bar_style(); menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("title_bar_native").to_string()) .checked(current_style == crate::session::config::TitleBarStyle::Native) .on_click(window.listener_for(&view, |this, _, _, cx| { this.config.set_title_bar_style(crate::session::config::TitleBarStyle::Native); this.save_preferences_background(); cx.notify(); })) ) .item( PopupMenuItem::new(t!("title_bar_integrated").to_string()) .checked(current_style == crate::session::config::TitleBarStyle::Integrated) .on_click(window.listener_for(&view, |this, _, _, cx| { this.config.set_title_bar_style(crate::session::config::TitleBarStyle::Integrated); this.save_preferences_background(); cx.notify(); })) ); menu } }) .into_any_element() } }) ) ) ) .groups(terminal_group) .group( SettingGroup::new() .title(t!("settings_group_font").to_string()) .item( SettingItem::new( t!("ui_font_size").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { h_flex() .items_center() .gap_3() .child(pointer_button("ui-font-size-down").label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(-1.0, cx)))) .child(div().min_w(px(64.)).text_center().child(format!("{:.0}px", view.read(cx).ui_font_size))) .child(pointer_button("ui-font-size-up").label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_ui_font_size(1.0, cx)))) .into_any_element() } }) ) ) .item( SettingItem::new( t!("terminal_font_size").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { h_flex() .items_center() .gap_3() .child(pointer_button("terminal-font-size-down").label("-").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(-1.0, cx)))) .child(div().min_w(px(64.)).text_center().child(format!("{:.0}px", view.read(cx).terminal_font_size))) .child(pointer_button("terminal-font-size-up").label("+").on_click(window.listener_for(&view, |this, _, _, cx| this.change_terminal_font_size(1.0, cx)))) .into_any_element() } }) ) ) .item( SettingItem::new( t!("ui_font_family").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { pointer_button("ui-font-dropdown") .icon(IconName::ChevronsUpDown) .label({ let current = view.read(cx).ui_font_family.to_string(); let names = cx.text_system().all_font_names(); let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed); if current == *".SystemUIFont" || current.is_empty() || !names.contains(¤t) { t!("system_default").to_string() } else if !using_system_maple && current == "Maple Mono NF CN" { format!("Maple Mono NF CN ({})", t!("software_builtin")) } else { current } }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current = view.read(cx).ui_font_family.to_string(); let mut names = cx.text_system().all_font_names(); menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); menu = menu.item( PopupMenuItem::new(t!("system_default").to_string()) .checked(current == *".SystemUIFont" || current.is_empty()) .on_click(window.listener_for(&view, move |this, _, window, cx| { this.change_ui_font_family(".SystemUIFont", window, cx); })) ); let maple_font = "Maple Mono NF CN".to_string(); let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed); if !using_system_maple && names.contains(&maple_font) { names.retain(|n| n != &maple_font); menu = menu.item( PopupMenuItem::new(format!("{} ({})", maple_font, t!("software_builtin"))) .checked(current == maple_font) .on_click(window.listener_for(&view, move |this, _, window, cx| { this.change_ui_font_family("Maple Mono NF CN", window, cx); })) ).separator(); } for name in names { let checked = name == current; menu = menu.item( PopupMenuItem::new(name.clone()) .checked(checked) .on_click(window.listener_for(&view, move |this, _, window, cx| { this.change_ui_font_family(&name, window, cx); })) ); } menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("terminal_font_family").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { pointer_button("terminal-font-dropdown") .icon(IconName::ChevronsUpDown) .label({ let current = view.read(cx).terminal_font_family.to_string(); let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed); if !using_system_maple && current == "Maple Mono NF CN" { format!("Maple Mono NF CN ({})", t!("software_builtin")) } else { current } }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current = view.read(cx).terminal_font_family.to_string(); let mut names = cx.text_system().all_font_names(); menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); let maple_font = "Maple Mono NF CN".to_string(); let using_system_maple = crate::app::theme::USING_SYSTEM_MAPLE.load(std::sync::atomic::Ordering::Relaxed); if !using_system_maple && names.contains(&maple_font) { names.retain(|n| n != &maple_font); menu = menu.item( PopupMenuItem::new(format!("{} ({})", maple_font, t!("software_builtin"))) .checked(current == maple_font) .on_click(window.listener_for(&view, move |this, _, _window, cx| { this.change_terminal_font_family("Maple Mono NF CN", cx); })) ).separator(); } for name in names { let checked = name == current; menu = menu.item( PopupMenuItem::new(name.clone()) .checked(checked) .on_click(window.listener_for(&view, move |this, _, _window, cx| { this.change_terminal_font_family(&name, cx); })) ); } menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("local_terminal_encoding").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { let current = view.read(cx).config.local_terminal_encoding(); pointer_button("local-terminal-encoding-dropdown") .icon(IconName::Globe) .label(current.label()) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current = view.read(cx).config.local_terminal_encoding(); menu = menu.min_w(0.); for encoding in TERMINAL_ENCODINGS.iter().copied() { menu = menu.item( PopupMenuItem::new(encoding.label()) .checked(encoding == current) .on_click(window.listener_for(&view, move |this, _, _, cx| { this.change_local_terminal_encoding(encoding, cx); })), ); } menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("cursor_style").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { use crate::session::config::CursorStyle; let current = view.read(cx).cursor_style; pointer_button("cursor-style-dropdown") .icon(IconName::ChevronsUpDown) .label(match current { CursorStyle::Default => t!("cursor_style_default").to_string(), CursorStyle::Blink => t!("cursor_style_blink").to_string(), CursorStyle::Beam => t!("cursor_style_beam").to_string(), CursorStyle::BeamBlink => t!("cursor_style_beam_blink").to_string(), }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { use crate::session::config::CursorStyle; let current = view.read(cx).cursor_style; menu = menu.min_w(0.).max_h(px(320.)).scrollable(true); for style in [ CursorStyle::Default, CursorStyle::Blink, CursorStyle::Beam, CursorStyle::BeamBlink, ] { let checked = style == current; let label = match style { CursorStyle::Default => t!("cursor_style_default").to_string(), CursorStyle::Blink => t!("cursor_style_blink").to_string(), CursorStyle::Beam => t!("cursor_style_beam").to_string(), CursorStyle::BeamBlink => t!("cursor_style_beam_blink").to_string(), }; menu = menu.item( PopupMenuItem::new(label) .checked(checked) .on_click(window.listener_for(&view, move |this, _, _window, cx| { this.change_cursor_style(style, cx); })) ); } menu } }) .into_any_element() } }) ) ) ) .group( SettingGroup::new() .title(t!("settings_group_other").to_string()) .item( SettingItem::new( t!("right_click_copy_paste").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { pointer_switch("right-click-copy-paste") .checked(view.read(cx).config.right_click_copy_paste()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_right_click_copy_paste(*checked); this.save_preferences_background(); cx.notify(); })) .into_any_element() } }) ).description(t!("copy_paste_hint").to_string()) ) .item( SettingItem::new( t!("keyword_highlight").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { pointer_switch("keyword-highlight") .checked(view.read(cx).config.keyword_highlight()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_keyword_highlight(*checked); this.save_preferences_background(); cx.notify(); })) .into_any_element() } }) ) ) .item( SettingItem::new( t!("remember_tabs").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { pointer_switch("remember-tabs") .checked(view.read(cx).config.remember_tabs()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_remember_tabs(*checked); this.capture_tabs_state(); this.save_preferences_background(); cx.notify(); })) .into_any_element() } }) ) ) .item( SettingItem::new( t!("lock_layout").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, cx| { pointer_switch("lock-layout") .checked(view.read(cx).config.lock_layout()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_lock_layout(*checked); this.save_preferences_background(); cx.notify(); })) .into_any_element() } }) ).description(t!("lock_layout_hint").to_string()) ) .item( SettingItem::new( t!("monitoring_position").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { pointer_button("monitoring-position-dropdown") .icon(IconName::PanelLeftOpen) .label({ let pos = view.read(cx).config.monitoring_position().to_string(); if pos == "Sidebar" { t!("position_sidebar").to_string() } else if pos == "Hidden" { t!("position_hidden").to_string() } else { t!("position_bottom").to_string() } }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let pos = view.read(cx).config.monitoring_position().to_string(); menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("position_bottom").to_string()) .checked(pos == "Bottom") .on_click(window.listener_for(&view, |this, _, _window, cx| { this.config.set_monitoring_position("Bottom"); this.save_preferences_background(); cx.notify(); })) ) .item( PopupMenuItem::new(t!("position_sidebar").to_string()) .checked(pos == "Sidebar") .on_click(window.listener_for(&view, |this, _, _window, cx| { this.config.set_monitoring_position("Sidebar"); this.save_preferences_background(); cx.notify(); })) ) .item( PopupMenuItem::new(t!("position_hidden").to_string()) .checked(pos == "Hidden") .on_click(window.listener_for(&view, |this, _, _window, cx| { this.config.set_monitoring_position("Hidden"); this.save_preferences_background(); cx.notify(); })) ); menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("language").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, _window, cx| { pointer_button("language-dropdown") .icon(IconName::Globe) .label({ let current_locale = view.read(cx).config.locale().to_string(); if current_locale == "en" { t!("english").to_string() } else if current_locale == "zh-CN" { t!("chinese").to_string() } else { t!("follow_system").to_string() } }) .dropdown_menu_with_anchor(Anchor::BottomRight, { let view = view.clone(); move |mut menu, window, cx| { let current_locale = view.read(cx).config.locale().to_string(); menu = menu.min_w(0.) .item( PopupMenuItem::new(t!("follow_system").to_string()) .checked(current_locale == "system") .on_click(window.listener_for(&view, |this, _, window, cx| { this.set_display_language("system", window, cx) })) ) .separator() .item( PopupMenuItem::new(t!("english").to_string()) .checked(current_locale == "en") .on_click(window.listener_for(&view, |this, _, window, cx| { this.set_display_language("en", window, cx) })) ) .item( PopupMenuItem::new(t!("chinese").to_string()) .checked(current_locale == "zh-CN") .on_click(window.listener_for(&view, |this, _, window, cx| { this.set_display_language("zh-CN", window, cx) })) ); menu } }) .into_any_element() } }) ) ) .item( SettingItem::new( t!("reset_layout").to_string(), SettingField::render({ let view = view_clone_for_general.clone(); move |_, window, _cx| { pointer_button("reset-layout") .label(t!("reset").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { this.reset_layout(window, cx); })) .into_any_element() } }) ).description(t!("reset_layout_hint").to_string()) ) ) ) .page( SettingPage::new(t!("settings_config_file").to_string()) .icon(IconName::File) .group( SettingGroup::new() .title(t!("settings_config_file").to_string()) .item(SettingItem::new( t!("settings_backup_local_desc").to_string(), SettingField::render({ let view = view.clone(); move |_, window, _cx| { h_flex() .gap_2() .child( pointer_button("backup-export") .label(t!("backup_export").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { this.export_local_config(window, cx); })) ) .child( pointer_button("backup-import") .label(t!("backup_import").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { this.import_local_config(window, cx); })) ) .into_any_element() } }) )) .item(SettingItem::new( t!("import_export_connections").to_string(), SettingField::render({ let view = view.clone(); move |_, window, _cx| { h_flex() .gap_2() .child( pointer_button("settings-connections-import") .icon(IconName::ArrowDown) .label(t!("import_connections").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { this.import_connections(window, cx); })) ) .child( pointer_button("settings-connections-export") .icon(IconName::ArrowUp) .label(t!("export_connections").to_string()) .on_click(window.listener_for(&view, |this, _, window, cx| { this.export_connections(window, cx); })) ) .into_any_element() } }) )) ) ) .page( SettingPage::new(t!("settings_sync").to_string()) .icon(IconName::Globe) .group( SettingGroup::new() .title(t!("settings_sync").to_string()) .item(SettingItem::render({ let view = view.clone(); let endpoint = sync_endpoint_input.clone(); let username = sync_username_input.clone(); let webdav_password = sync_webdav_password_input.clone(); let s3_endpoint = sync_s3_endpoint_input.clone(); let s3_region = sync_s3_region_input.clone(); let s3_bucket = sync_s3_bucket_input.clone(); let s3_object_key = sync_s3_object_key_input.clone(); let s3_access_key = sync_s3_access_key_input.clone(); let s3_secret_key = sync_s3_secret_key_input.clone(); let s3_session_token = sync_s3_session_token_input.clone(); let encryption_password = sync_encryption_password_input.clone(); move |_, window, cx| { let in_progress = view.read(cx).sync_in_progress; let status = view.read(cx).sync_status.clone(); let is_s3 = view.read(cx).config.sync_backend() == "s3"; v_flex() .w_full() .gap_3() .child( h_flex() .gap_2() .child( pointer_button("sync-backend-webdav") .label("WebDAV") .when(!is_s3, |button| button.primary()) .on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("webdav", cx))) ) .child( pointer_button("sync-backend-s3") .label("S3") .when(is_s3, |button| button.primary()) .on_click(window.listener_for(&view, |this, _, _, cx| this.set_sync_backend("s3", cx))) ) ) .when(!is_s3, |this| this .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_endpoint").to_string())).child(Input::new(&endpoint).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_username").to_string())).child(Input::new(&username).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_webdav_password").to_string())).child(Input::new(&webdav_password).w_full()))) .when(is_s3, |this| this .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_endpoint").to_string())).child(Input::new(&s3_endpoint).w_full())) .child(h_flex().gap_2() .child(v_flex().flex_1().gap_1().child(div().text_sm().child(t!("sync_s3_region").to_string())).child(Input::new(&s3_region).w_full())) .child(v_flex().flex_1().gap_1().child(div().text_sm().child(t!("sync_s3_bucket").to_string())).child(Input::new(&s3_bucket).w_full()))) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_object_key").to_string())).child(Input::new(&s3_object_key).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_access_key").to_string())).child(Input::new(&s3_access_key).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_secret_key").to_string())).child(Input::new(&s3_secret_key).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_s3_session_token").to_string())).child(Input::new(&s3_session_token).w_full()))) .child(v_flex().gap_1().child(div().text_sm().child(t!("sync_encryption_password").to_string())).child(Input::new(&encryption_password).w_full())) .child(div().text_sm().text_color(cx.theme().muted_foreground).child(t!("sync_security_hint").to_string())) .child( h_flex() .gap_2() .child(pointer_button("sync-download").disabled(in_progress).label(t!("sync_download").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.download_sync_config(cx)))) .child(pointer_button("sync-upload").disabled(in_progress).label(t!("sync_upload").to_string()).on_click(window.listener_for(&view, |this, _, _, cx| this.upload_sync_config(cx)))), ) .child(div().text_sm().text_color(cx.theme().muted_foreground).child(status)) } })) ) ) .page( SettingPage::new(t!("settings_proxy").to_string()) .icon(IconName::Network) .group( SettingGroup::new() .title(t!("settings_proxy").to_string()) .item( SettingItem::new( t!("enable_proxy").to_string(), SettingField::render({ let view = view.clone(); move |_, window, cx| { pointer_switch("use-proxy") .checked(view.read(cx).config.use_proxy()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_use_proxy(*checked); this.save_preferences_background(); cx.notify(); })) .into_any_element() } }) ) ) .item( SettingItem::new( t!("read_env_proxy").to_string(), SettingField::render({ let view = view.clone(); move |_, window, cx| { pointer_switch("read-env-proxy") .checked(view.read(cx).config.read_env_proxy()) .on_click(window.listener_for(&view, |this, checked, _, cx| { this.config.set_read_env_proxy(*checked); this.save_preferences_background(); cx.notify(); })) .into_any_element() } }) ).description(t!("read_env_proxy_desc").to_string()) ) .item(SettingItem::render({ let view = view.clone(); let global_proxy_host_input = view.read(cx).global_proxy_host_input.clone(); let global_proxy_port_input = view.read(cx).global_proxy_port_input.clone(); let global_proxy_user_input = view.read(cx).global_proxy_user_input.clone(); let global_proxy_password_input = view.read(cx).global_proxy_password_input.clone(); move |_, window, cx| { let proxy_type = view.read(cx).global_proxy_type.clone(); v_flex() .w_full() .gap_3() .child(div().text_sm().font_weight(FontWeight::BOLD).child(t!("global_proxy_settings").to_string())) .child( h_flex() .gap_2() .child( pointer_button("global-proxy-type-socks5") .label("SOCKS5") .when(proxy_type == "socks5", |b| b.primary()) .on_click(window.listener_for(&view, |this, _, _, cx| { this.global_proxy_type = "socks5".to_string(); cx.notify(); })) ) .child( pointer_button("global-proxy-type-http") .label("HTTP") .when(proxy_type == "http", |b| b.primary()) .on_click(window.listener_for(&view, |this, _, _, cx| { this.global_proxy_type = "http".to_string(); cx.notify(); })) ) ) .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_host").to_string())).child(Input::new(&global_proxy_host_input).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_port").to_string())).child(Input::new(&global_proxy_port_input).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_user").to_string())).child(Input::new(&global_proxy_user_input).w_full())) .child(v_flex().gap_1().child(div().text_sm().child(t!("global_proxy_password").to_string())).child(Input::new(&global_proxy_password_input).w_full())) .child( pointer_button("save-global-proxy") .primary() .label(t!("save_proxy").to_string()) .on_click(window.listener_for(&view, |this, _, _, cx| { let host = this.global_proxy_host_input.read(cx).value().trim().to_string(); let port_str = this.global_proxy_port_input.read(cx).value(); let port = port_str.trim().parse::().ok(); let user = this.global_proxy_user_input.read(cx).value().trim().to_string(); let password = this.global_proxy_password_input.read(cx).value().to_string(); if host.is_empty() || port.is_none() { return; } this.config.set_global_proxy_type(this.global_proxy_type.clone()); this.config.set_global_proxy_host(host); this.config.set_global_proxy_port(port); this.config.set_global_proxy_user(user); this.config.set_global_proxy_password(password); this.save_preferences_background(); cx.notify(); })) ) } })) ) ) .page({ let mut page = SettingPage::new(t!("settings_key_bindings").to_string()) .icon(IconName::SquareTerminal) .default_open(true); for group in crate::app::keybinding_recorder::KeybindingsPage::render_groups(&view, cx) { page = page.group(group); } page }) .page( SettingPage::new(t!("settings_help").to_string()) .icon(IconName::BookOpen) ) .page( SettingPage::new(t!("settings_about").to_string()) .icon(IconName::Info) .group( SettingGroup::new() .item(SettingItem::render(move |_, _window, cx| { v_flex() .gap_2() .items_center() .child(div().text_size(ui_rems(1.5)).font_weight(FontWeight::BOLD).child("Ashell")) .child(div().text_size(ui_rems(0.9)).child(format!("Version {}", version))) .child( div() .text_size(ui_rems(0.9)) .text_color(cx.theme().muted_foreground) .child("A GPUI Component based SSH and local terminal client"), ) .child( div() .text_size(ui_rems(0.9)) .text_color(cx.theme().muted_foreground) .child(t!("about_feedback_hint")), ) .child( pointer_button("github-link") .label("https://github.com/rust-kotlin/ashell") .ghost() .on_click(|_, _window, _cx| { let _ = open::that("https://github.com/rust-kotlin/ashell"); }), ) .child( v_flex() .w_full() .mt_3() .p_3() .gap_2() .rounded_md() .bg(cx.theme().secondary.opacity(0.4)) .border_1() .border_color(cx.theme().border) .child( div() .text_size(ui_rems(0.85)) .font_weight(FontWeight::BOLD) .text_color(cx.theme().foreground) .child(t!("about_tips_title")), ) .child( v_flex() .gap_2() .text_size(ui_rems(0.8)) .text_color(cx.theme().muted_foreground) .child( h_flex() .gap_2() .items_start() .child(div().flex_none().child("•")) .child(div().flex_1().min_w(px(0.)).child(t!("about_tip_backspace"))), ) .child( h_flex() .gap_2() .items_start() .child(div().flex_none().child("•")) .child(div().flex_1().min_w(px(0.)).child(t!("about_tip_close_shortcuts"))), ) .child( h_flex() .gap_2() .items_start() .child(div().flex_none().child("•")) .child(div().flex_1().min_w(px(0.)).child(t!("about_tip_conflict_shortcuts"))), ), ), ) })) ) ) ) ) } }) }); } }