diff --git a/Cargo.lock b/Cargo.lock index db14b8c7ae..6fb0c1ad6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8968,6 +8968,7 @@ dependencies = [ "client", "dev_server_projects", "editor", + "file_finder", "futures 0.3.30", "fuzzy", "gpui", @@ -8988,7 +8989,6 @@ dependencies = [ "task", "terminal_view", "ui", - "ui_input", "util", "workspace", ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 3de9a1495e..c39b7c06da 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -395,6 +395,7 @@ // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], "alt-cmd-o": "projects::OpenRecent", + "ctrl-cmd-o": "projects::OpenRemote", "alt-cmd-b": "branches::OpenRecent", "ctrl-~": "workspace::NewTerminal", "cmd-s": "workspace::Save", diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index ba125b979c..4202e6e2d0 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -5,6 +5,8 @@ mod file_finder_settings; mod new_path_prompt; mod open_path_prompt; +pub use open_path_prompt::OpenPathDelegate; + use collections::HashMap; use editor::{scroll::Autoscroll, Bias, Editor}; use file_finder_settings::FileFinderSettings; diff --git a/crates/file_finder/src/open_path_prompt.rs b/crates/file_finder/src/open_path_prompt.rs index 5d977d10b9..0736d4189b 100644 --- a/crates/file_finder/src/open_path_prompt.rs +++ b/crates/file_finder/src/open_path_prompt.rs @@ -26,6 +26,20 @@ pub struct OpenPathDelegate { should_dismiss: bool, } +impl OpenPathDelegate { + pub fn new(tx: oneshot::Sender>>, lister: DirectoryLister) -> Self { + Self { + tx: Some(tx), + lister, + selected_index: 0, + directory_state: None, + matches: Vec::new(), + cancel_flag: Arc::new(AtomicBool::new(false)), + should_dismiss: true, + } + } +} + struct DirectoryState { path: String, match_candidates: Vec, @@ -48,15 +62,7 @@ impl OpenPathPrompt { cx: &mut ViewContext, ) { workspace.toggle_modal(cx, |cx| { - let delegate = OpenPathDelegate { - tx: Some(tx), - lister: lister.clone(), - selected_index: 0, - directory_state: None, - matches: Vec::new(), - cancel_flag: Arc::new(AtomicBool::new(false)), - should_dismiss: true, - }; + let delegate = OpenPathDelegate::new(tx, lister.clone()); let picker = Picker::uniform_list(delegate, cx).width(rems(34.)); let query = lister.default_query(cx); diff --git a/crates/gpui/src/shared_string.rs b/crates/gpui/src/shared_string.rs index e1fd4f1a5c..1fbd337bd0 100644 --- a/crates/gpui/src/shared_string.rs +++ b/crates/gpui/src/shared_string.rs @@ -1,5 +1,6 @@ use derive_more::{Deref, DerefMut}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use std::{borrow::Borrow, sync::Arc}; use util::arc_cow::ArcCow; @@ -16,6 +17,16 @@ impl SharedString { } } +impl JsonSchema for SharedString { + fn schema_name() -> String { + String::schema_name() + } + + fn json_schema(gen: &mut schemars::gen::SchemaGenerator) -> schemars::schema::Schema { + String::json_schema(gen) + } +} + impl Default for SharedString { fn default() -> Self { Self(ArcCow::Owned(Arc::default())) diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 98349f8c49..2d896f2ee8 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -4905,6 +4905,12 @@ impl From<(&'static str, usize)> for ElementId { } } +impl From<(SharedString, usize)> for ElementId { + fn from((name, id): (SharedString, usize)) -> Self { + ElementId::NamedInteger(name, id) + } +} + impl From<(&'static str, u64)> for ElementId { fn from((name, id): (&'static str, u64)) -> Self { ElementId::NamedInteger(name.into(), id as usize) diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index 8350be2b20..a9512606d2 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -476,7 +476,7 @@ impl Picker { } } - pub fn set_query(&self, query: impl Into>, cx: &mut ViewContext) { + pub fn set_query(&self, query: impl Into>, cx: &mut WindowContext<'_>) { if let Head::Editor(ref editor) = &self.head { editor.update(cx, |editor, cx| { editor.set_text(query, cx); diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 2eea6321a0..2b428009c4 100644 --- a/crates/recent_projects/Cargo.toml +++ b/crates/recent_projects/Cargo.toml @@ -18,6 +18,7 @@ auto_update.workspace = true release_channel.workspace = true client.workspace = true editor.workspace = true +file_finder.workspace = true futures.workspace = true fuzzy.workspace = true gpui.workspace = true @@ -36,7 +37,6 @@ smol.workspace = true task.workspace = true terminal_view.workspace = true ui.workspace = true -ui_input.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/recent_projects/src/dev_servers.rs b/crates/recent_projects/src/dev_servers.rs index b80a011ca3..62cfeab830 100644 --- a/crates/recent_projects/src/dev_servers.rs +++ b/crates/recent_projects/src/dev_servers.rs @@ -1,5 +1,6 @@ use std::collections::HashMap; use std::path::PathBuf; +use std::sync::Arc; use std::time::Duration; use anyhow::anyhow; @@ -7,39 +8,46 @@ use anyhow::Context; use anyhow::Result; use dev_server_projects::{DevServer, DevServerId, DevServerProjectId}; use editor::Editor; +use file_finder::OpenPathDelegate; +use futures::channel::oneshot; +use futures::future::Shared; +use futures::FutureExt; +use gpui::canvas; use gpui::pulsating_between; use gpui::AsyncWindowContext; use gpui::ClipboardItem; -use gpui::PathPromptOptions; use gpui::Subscription; use gpui::Task; use gpui::WeakView; use gpui::{ - Action, Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, - FocusHandle, FocusableView, Model, ScrollHandle, View, ViewContext, + Animation, AnimationExt, AnyElement, AppContext, DismissEvent, EventEmitter, FocusHandle, + FocusableView, FontWeight, Model, ScrollHandle, View, ViewContext, }; +use picker::Picker; use project::terminals::wrap_for_ssh; use project::terminals::SshCommand; -use rpc::{proto::DevServerStatus, ErrorCode, ErrorExt}; +use project::Project; +use rpc::proto::DevServerStatus; use settings::update_settings_file; use settings::Settings; use task::HideStrategy; use task::RevealStrategy; use task::SpawnInTerminal; use terminal_view::terminal_panel::TerminalPanel; -use ui::ElevationIndex; use ui::Section; -use ui::{prelude::*, IconButtonShape, List, ListItem, Modal, ModalFooter, ModalHeader, Tooltip}; -use ui_input::{FieldLabelLayout, TextField}; +use ui::{prelude::*, List, ListItem, ListSeparator, Modal, ModalHeader, Tooltip}; use util::ResultExt; +use workspace::notifications::NotificationId; use workspace::OpenOptions; -use workspace::{notifications::DetachAndPromptErr, AppState, ModalView, Workspace}; +use workspace::Toast; +use workspace::{notifications::DetachAndPromptErr, ModalView, Workspace}; use crate::open_dev_server_project; use crate::ssh_connections::connect_over_ssh; use crate::ssh_connections::open_ssh_project; use crate::ssh_connections::RemoteSettingsContent; use crate::ssh_connections::SshConnection; +use crate::ssh_connections::SshConnectionHeader; use crate::ssh_connections::SshConnectionModal; use crate::ssh_connections::SshProject; use crate::ssh_connections::SshPrompt; @@ -52,24 +60,251 @@ pub struct DevServerProjects { scroll_handle: ScrollHandle, dev_server_store: Model, workspace: WeakView, - project_path_input: View, - dev_server_name_input: View, _dev_server_subscription: Subscription, + selectable_items: SelectableItemList, } -#[derive(Default)] struct CreateDevServer { + address_editor: View, creating: Option>>, ssh_prompt: Option>, } -struct CreateDevServerProject { - dev_server_id: DevServerId, - _opening: Option, +impl CreateDevServer { + fn new(cx: &mut WindowContext<'_>) -> Self { + let address_editor = cx.new_view(Editor::single_line); + address_editor.update(cx, |this, cx| { + this.focus_handle(cx).focus(cx); + }); + Self { + address_editor, + creating: None, + ssh_prompt: None, + } + } } +struct ProjectPicker { + connection_string: SharedString, + picker: View>, + _path_task: Shared>>, +} + +type SelectedItemCallback = + Box) + 'static>; + +/// Used to implement keyboard navigation for SSH modal. +#[derive(Default)] +struct SelectableItemList { + items: Vec, + active_item: Option, +} + +struct EditNicknameState { + index: usize, + editor: View, +} + +impl EditNicknameState { + fn new(index: usize, cx: &mut WindowContext<'_>) -> Self { + let this = Self { + index, + editor: cx.new_view(Editor::single_line), + }; + let starting_text = SshSettings::get_global(cx) + .ssh_connections() + .nth(index) + .and_then(|state| state.nickname.clone()) + .filter(|text| !text.is_empty()); + this.editor.update(cx, |this, cx| { + this.set_placeholder_text("Add a nickname for this server", cx); + if let Some(starting_text) = starting_text { + this.set_text(starting_text, cx); + } + }); + this.editor.focus_handle(cx).focus(cx); + this + } +} + +impl SelectableItemList { + fn reset(&mut self) { + self.items.clear(); + } + + fn reset_selection(&mut self) { + self.active_item.take(); + } + + fn prev(&mut self, _: &mut WindowContext<'_>) { + match self.active_item.as_mut() { + Some(active_index) => { + *active_index = active_index.checked_sub(1).unwrap_or(self.items.len() - 1) + } + None => { + self.active_item = Some(self.items.len() - 1); + } + } + } + + fn next(&mut self, _: &mut WindowContext<'_>) { + match self.active_item.as_mut() { + Some(active_index) => { + if *active_index + 1 < self.items.len() { + *active_index += 1; + } else { + *active_index = 0; + } + } + None => { + self.active_item = Some(0); + } + } + } + + fn add_item(&mut self, callback: SelectedItemCallback) { + self.items.push(callback) + } + + fn is_selected(&self) -> bool { + self.active_item == self.items.len().checked_sub(1) + } + + fn confirm(&self, dev_modal: &mut DevServerProjects, cx: &mut ViewContext) { + if let Some(active_item) = self.active_item.and_then(|ix| self.items.get(ix)) { + active_item(dev_modal, cx); + } + } +} + +impl ProjectPicker { + fn new( + ix: usize, + connection_string: SharedString, + project: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> View { + let (tx, rx) = oneshot::channel(); + let lister = project::DirectoryLister::Project(project.clone()); + let query = lister.default_query(cx); + let delegate = file_finder::OpenPathDelegate::new(tx, lister); + + let picker = cx.new_view(|cx| { + let picker = Picker::uniform_list(delegate, cx) + .width(rems(34.)) + .modal(false); + picker.set_query(query, cx); + picker + }); + cx.new_view(|cx| { + let _path_task = cx + .spawn({ + let workspace = workspace.clone(); + move |_, mut cx| async move { + let Ok(Some(paths)) = rx.await else { + workspace + .update(&mut cx, |workspace, cx| { + let weak = cx.view().downgrade(); + workspace + .toggle_modal(cx, |cx| DevServerProjects::new(cx, weak)); + }) + .log_err()?; + return None; + }; + + let app_state = workspace + .update(&mut cx, |workspace, _| workspace.app_state().clone()) + .ok()?; + let options = cx + .update(|cx| (app_state.build_window_options)(None, cx)) + .log_err()?; + + cx.open_window(options, |cx| { + cx.activate_window(); + + let fs = app_state.fs.clone(); + update_settings_file::(fs, cx, { + let paths = paths + .iter() + .map(|path| path.to_string_lossy().to_string()) + .collect(); + move |setting, _| { + if let Some(server) = setting + .ssh_connections + .as_mut() + .and_then(|connections| connections.get_mut(ix)) + { + server.projects.push(SshProject { paths }) + } + } + }); + + let tasks = paths + .into_iter() + .map(|path| { + project.update(cx, |project, cx| { + project.find_or_create_worktree(&path, true, cx) + }) + }) + .collect::>(); + cx.spawn(|_| async move { + for task in tasks { + task.await?; + } + Ok(()) + }) + .detach_and_prompt_err( + "Failed to open path", + cx, + |_, _| None, + ); + + cx.new_view(|cx| { + let workspace = + Workspace::new(None, project.clone(), app_state.clone(), cx); + + workspace + .client() + .telemetry() + .report_app_event("create ssh project".to_string()); + + workspace + }) + }) + .log_err(); + Some(()) + } + }) + .shared(); + + Self { + _path_task, + picker, + connection_string, + } + }) + } +} + +impl gpui::Render for ProjectPicker { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + v_flex() + .child( + SshConnectionHeader { + connection_string: self.connection_string.clone(), + nickname: None, + } + .render(cx), + ) + .child(self.picker.clone()) + } +} enum Mode { - Default(Option), + Default, + ViewServerOptions(usize, SshConnection), + EditNickname(EditNicknameState), + ProjectPicker(View), CreateDevServer(CreateDevServer), } @@ -89,15 +324,6 @@ impl DevServerProjects { } pub fn new(cx: &mut ViewContext, workspace: WeakView) -> Self { - let project_path_input = cx.new_view(|cx| { - let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Project path (~/work/zed, /workspace/zed, …)", cx); - editor - }); - let dev_server_name_input = cx.new_view(|cx| { - TextField::new(cx, "Name", "192.168.0.1").with_label(FieldLabelLayout::Hidden) - }); - let focus_handle = cx.focus_handle(); let dev_server_store = dev_server_projects::Store::global(cx); @@ -112,131 +338,49 @@ impl DevServerProjects { }); Self { - mode: Mode::Default(None), + mode: Mode::Default, focus_handle, scroll_handle: ScrollHandle::new(), dev_server_store, - project_path_input, - dev_server_name_input, workspace, _dev_server_subscription: subscription, + selectable_items: Default::default(), } } - pub fn create_dev_server_project( - &mut self, - dev_server_id: DevServerId, + fn next_item(&mut self, _: &menu::SelectNext, cx: &mut ViewContext) { + if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) { + return; + } + self.selectable_items.next(cx); + } + fn prev_item(&mut self, _: &menu::SelectPrev, cx: &mut ViewContext) { + if !matches!(self.mode, Mode::Default | Mode::ViewServerOptions(_, _)) { + return; + } + self.selectable_items.prev(cx); + } + pub fn project_picker( + ix: usize, + connection_options: remote::SshConnectionOptions, + project: Model, cx: &mut ViewContext, - ) { - let mut path = self.project_path_input.read(cx).text(cx).trim().to_string(); + workspace: WeakView, + ) -> Self { + let mut this = Self::new(cx, workspace.clone()); + this.mode = Mode::ProjectPicker(ProjectPicker::new( + ix, + connection_options.connection_string().into(), + project, + workspace, + cx, + )); - if path.is_empty() { - return; - } - - if !path.starts_with('/') && !path.starts_with('~') { - path = format!("~/{}", path); - } - - if self - .dev_server_store - .read(cx) - .projects_for_server(dev_server_id) - .iter() - .any(|p| p.paths.iter().any(|p| p == &path)) - { - cx.spawn(|_, mut cx| async move { - cx.prompt( - gpui::PromptLevel::Critical, - "Failed to create project", - Some(&format!("{} is already open on this dev server.", path)), - &["Ok"], - ) - .await - }) - .detach_and_log_err(cx); - return; - } - - let create = { - let path = path.clone(); - self.dev_server_store.update(cx, |store, cx| { - store.create_dev_server_project(dev_server_id, path, cx) - }) - }; - - cx.spawn(|this, mut cx| async move { - let result = create.await; - this.update(&mut cx, |this, cx| { - if let Ok(result) = &result { - if let Some(dev_server_project_id) = - result.dev_server_project.as_ref().map(|p| p.id) - { - let subscription = - cx.observe(&this.dev_server_store, move |this, store, cx| { - if let Some(project_id) = store - .read(cx) - .dev_server_project(DevServerProjectId(dev_server_project_id)) - .and_then(|p| p.project_id) - { - this.project_path_input.update(cx, |editor, cx| { - editor.set_text("", cx); - }); - this.mode = Mode::Default(None); - if let Some(app_state) = AppState::global(cx).upgrade() { - workspace::join_dev_server_project( - DevServerProjectId(dev_server_project_id), - project_id, - app_state, - None, - cx, - ) - .detach_and_prompt_err( - "Could not join project", - cx, - |_, _| None, - ) - } - } - }); - - this.mode = Mode::Default(Some(CreateDevServerProject { - dev_server_id, - _opening: Some(subscription), - })); - } - } else { - this.mode = Mode::Default(Some(CreateDevServerProject { - dev_server_id, - _opening: None, - })); - } - }) - .log_err(); - result - }) - .detach_and_prompt_err("Failed to create project", cx, move |e, _| { - match e.error_code() { - ErrorCode::DevServerOffline => Some( - "The dev server is offline. Please log in and check it is connected." - .to_string(), - ), - ErrorCode::DevServerProjectPathDoesNotExist => { - Some(format!("The path `{}` does not exist on the server.", path)) - } - _ => None, - } - }); - - self.mode = Mode::Default(Some(CreateDevServerProject { - dev_server_id, - - _opening: None, - })); + this } - fn create_ssh_server(&mut self, cx: &mut ViewContext) { - let host = get_text(&self.dev_server_name_input, cx); + fn create_ssh_server(&mut self, editor: View, cx: &mut ViewContext) { + let host = get_text(&editor, cx); if host.is_empty() { return; } @@ -287,23 +431,35 @@ impl DevServerProjects { }); this.add_ssh_server(connection_options, cx); - this.mode = Mode::Default(None); + this.mode = Mode::Default; + this.selectable_items.reset_selection(); cx.notify() }) .log_err(), None => this .update(&mut cx, |this, cx| { - this.mode = Mode::CreateDevServer(CreateDevServer::default()); + this.mode = Mode::CreateDevServer(CreateDevServer::new(cx)); cx.notify() }) .log_err(), }; None }); - self.mode = Mode::CreateDevServer(CreateDevServer { - ssh_prompt: Some(ssh_prompt.clone()), - creating: Some(creating), - }); + let mut state = CreateDevServer::new(cx); + state.address_editor = editor; + state.ssh_prompt = Some(ssh_prompt.clone()); + state.creating = Some(creating); + self.mode = Mode::CreateDevServer(state); + } + + fn view_server_options( + &mut self, + (index, connection): (usize, SshConnection), + cx: &mut ViewContext, + ) { + self.selectable_items.reset_selection(); + self.mode = Mode::ViewServerOptions(index, connection); + cx.notify(); } fn create_ssh_project( @@ -331,12 +487,12 @@ impl DevServerProjects { let connect = connect_over_ssh( connection_options.dev_server_identifier(), - connection_options, + connection_options.clone(), prompt, cx, ) .prompt_err("Failed to connect", cx, |_, _| None); - cx.spawn(|workspace, mut cx| async move { + cx.spawn(move |workspace, mut cx| async move { let Some(session) = connect.await else { workspace .update(&mut cx, |workspace, cx| { @@ -346,9 +502,11 @@ impl DevServerProjects { .log_err(); return; }; - let Ok((app_state, project, paths)) = - workspace.update(&mut cx, |workspace, cx| { + + workspace + .update(&mut cx, |workspace, cx| { let app_state = workspace.app_state().clone(); + let weak = cx.view().downgrade(); let project = project::Project::ssh( session, app_state.client.clone(), @@ -358,91 +516,17 @@ impl DevServerProjects { app_state.fs.clone(), cx, ); - let paths = workspace.prompt_for_open_path( - PathPromptOptions { - files: true, - directories: true, - multiple: true, - }, - project::DirectoryLister::Project(project.clone()), - cx, - ); - (app_state, project, paths) + workspace.toggle_modal(cx, |cx| { + DevServerProjects::project_picker( + ix, + connection_options, + project, + cx, + weak, + ) + }); }) - else { - return; - }; - - let Ok(Some(paths)) = paths.await else { - workspace - .update(&mut cx, |workspace, cx| { - let weak = cx.view().downgrade(); - workspace.toggle_modal(cx, |cx| DevServerProjects::new(cx, weak)); - }) - .log_err(); - return; - }; - - let Some(options) = cx - .update(|cx| (app_state.build_window_options)(None, cx)) - .log_err() - else { - return; - }; - - cx.open_window(options, |cx| { - cx.activate_window(); - - let fs = app_state.fs.clone(); - update_settings_file::(fs, cx, { - let paths = paths - .iter() - .map(|path| path.to_string_lossy().to_string()) - .collect(); - move |setting, _| { - if let Some(server) = setting - .ssh_connections - .as_mut() - .and_then(|connections| connections.get_mut(ix)) - { - server.projects.push(SshProject { paths }) - } - } - }); - - let tasks = paths - .into_iter() - .map(|path| { - project.update(cx, |project, cx| { - project.find_or_create_worktree(&path, true, cx) - }) - }) - .collect::>(); - cx.spawn(|_| async move { - for task in tasks { - task.await?; - } - Ok(()) - }) - .detach_and_prompt_err( - "Failed to open path", - cx, - |_, _| None, - ); - - cx.new_view(|cx| { - let workspace = - Workspace::new(None, project.clone(), app_state.clone(), cx); - - workspace - .client() - .telemetry() - .report_app_event("create ssh project".to_string()); - - workspace - }) - }) - .log_err(); + .ok(); }) .detach() }) @@ -451,10 +535,12 @@ impl DevServerProjects { fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { match &self.mode { - Mode::Default(None) => {} - Mode::Default(Some(create_project)) => { - self.create_dev_server_project(create_project.dev_server_id, cx); + Mode::Default | Mode::ViewServerOptions(_, _) => { + let items = std::mem::take(&mut self.selectable_items); + items.confirm(self, cx); + self.selectable_items = items; } + Mode::ProjectPicker(_) => {} Mode::CreateDevServer(state) => { if let Some(prompt) = state.ssh_prompt.as_ref() { prompt.update(cx, |prompt, cx| { @@ -463,22 +549,41 @@ impl DevServerProjects { return; } - self.create_ssh_server(cx); + state.address_editor.update(cx, |this, _| { + this.set_read_only(true); + }); + self.create_ssh_server(state.address_editor.clone(), cx); + } + Mode::EditNickname(state) => { + let text = Some(state.editor.read(cx).text(cx)) + .filter(|text| !text.is_empty()) + .map(SharedString::from); + let index = state.index; + self.update_settings_file(cx, move |setting, _| { + if let Some(connections) = setting.ssh_connections.as_mut() { + if let Some(connection) = connections.get_mut(index) { + connection.nickname = text; + } + } + }); + self.mode = Mode::Default; + self.selectable_items.reset_selection(); + self.focus_handle.focus(cx); } } } fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { match &self.mode { - Mode::Default(None) => cx.emit(DismissEvent), + Mode::Default => cx.emit(DismissEvent), Mode::CreateDevServer(state) if state.ssh_prompt.is_some() => { - self.mode = Mode::CreateDevServer(CreateDevServer { - ..Default::default() - }); + self.mode = Mode::CreateDevServer(CreateDevServer::new(cx)); + self.selectable_items.reset_selection(); cx.notify(); } _ => { - self.mode = Mode::Default(None); + self.mode = Mode::Default; + self.selectable_items.reset_selection(); self.focus_handle(cx).focus(cx); cx.notify(); } @@ -491,126 +596,119 @@ impl DevServerProjects { ssh_connection: SshConnection, cx: &mut ViewContext, ) -> impl IntoElement { + let (main_label, aux_label) = if let Some(nickname) = ssh_connection.nickname.clone() { + let aux_label = SharedString::from(format!("({})", ssh_connection.host)); + (nickname, Some(aux_label)) + } else { + (ssh_connection.host.clone(), None) + }; v_flex() .w_full() - .px(Spacing::Small.rems(cx) + Spacing::Small.rems(cx)) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .mb_1() .child( h_flex() - .w_full() .group("ssh-server") - .justify_between() + .w_full() + .pt_0p5() + .px_2p5() + .gap_1() + .overflow_hidden() + .whitespace_nowrap() + .w_full() .child( - h_flex() - .gap_2() - .w_full() - .child( - div() - .id(("status", ix)) - .relative() - .child(Icon::new(IconName::Server).size(IconSize::Small)), - ) - .child( - h_flex() - .max_w(rems(26.)) - .overflow_hidden() - .whitespace_nowrap() - .child(Label::new(ssh_connection.host.clone())), - ), + Label::new(main_label) + .size(LabelSize::Small) + .weight(FontWeight::SEMIBOLD) + .color(Color::Muted), ) - .child( - h_flex() - .visible_on_hover("ssh-server") - .gap_1() - .child({ - IconButton::new("copy-dev-server-address", IconName::Copy) - .icon_size(IconSize::Small) - .on_click(cx.listener(move |this, _, cx| { - this.update_settings_file(cx, move |servers, cx| { - if let Some(content) = servers - .ssh_connections - .as_ref() - .and_then(|connections| { - connections - .get(ix) - .map(|connection| connection.host.clone()) - }) - { - cx.write_to_clipboard(ClipboardItem::new_string( - content, - )); - } - }); - })) - .tooltip(|cx| Tooltip::text("Copy Server Address", cx)) - }) - .child({ - IconButton::new("remove-dev-server", IconName::TrashAlt) - .icon_size(IconSize::Small) - .on_click(cx.listener(move |this, _, cx| { - this.delete_ssh_server(ix, cx) - })) - .tooltip(|cx| Tooltip::text("Remove Dev Server", cx)) - }), + .children( + aux_label.map(|label| { + Label::new(label).size(LabelSize::Small).color(Color::Muted) + }), ), ) .child( - v_flex() - .w_full() - .border_l_1() - .border_color(cx.theme().colors().border_variant) - .mb_1() - .mx_1p5() - .pl_2() - .child( - List::new() - .empty_message("No projects.") - .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| { - v_flex().gap_0p5().child(self.render_ssh_project( - ix, - &ssh_connection, - pix, - p, - cx, - )) - })) - .child( - h_flex().mt_1().pl_1().child( - Button::new(("new-remote_project", ix), "Open Folder…") - .size(ButtonSize::Default) - .layer(ElevationIndex::ModalSurface) - .icon(IconName::Plus) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, cx| { + v_flex().w_full().gap_1().mb_1().child( + List::new() + .empty_message("No projects.") + .children(ssh_connection.projects.iter().enumerate().map(|(pix, p)| { + v_flex().gap_0p5().child(self.render_ssh_project( + ix, + &ssh_connection, + pix, + p, + cx, + )) + })) + .child(h_flex().map(|this| { + self.selectable_items.add_item(Box::new({ + let ssh_connection = ssh_connection.clone(); + move |this, cx| { + this.create_ssh_project(ix, ssh_connection.clone(), cx); + } + })); + let is_selected = self.selectable_items.is_selected(); + this.child( + ListItem::new(("new-remote-project", ix)) + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Open Folder")) + .on_click(cx.listener({ + let ssh_connection = ssh_connection.clone(); + move |this, _, cx| { this.create_ssh_project(ix, ssh_connection.clone(), cx); - })), - ), - ), - ), + } + })), + ) + })) + .child(h_flex().map(|this| { + self.selectable_items.add_item(Box::new({ + let ssh_connection = ssh_connection.clone(); + move |this, cx| { + this.view_server_options((ix, ssh_connection.clone()), cx); + } + })); + let is_selected = self.selectable_items.is_selected(); + this.child( + ListItem::new(("server-options", ix)) + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Settings).color(Color::Muted)) + .child(Label::new("View Server Options")) + .on_click(cx.listener({ + let ssh_connection = ssh_connection.clone(); + move |this, _, cx| { + this.view_server_options( + (ix, ssh_connection.clone()), + cx, + ); + } + })), + ) + })), + ), ) } fn render_ssh_project( - &self, + &mut self, server_ix: usize, server: &SshConnection, ix: usize, project: &SshProject, cx: &ViewContext, ) -> impl IntoElement { - let project = project.clone(); let server = server.clone(); - ListItem::new(("remote-project", ix)) - .inset(true) - .spacing(ui::ListItemSpacing::Sparse) - .start_slot( - Icon::new(IconName::Folder) - .color(Color::Muted) - .size(IconSize::Small), - ) - .child(Label::new(project.paths.join(", "))) - .on_click(cx.listener(move |this, _, cx| { + let element_id_base = SharedString::from(format!("remote-project-{server_ix}")); + let callback = Arc::new({ + let project = project.clone(); + move |this: &mut Self, cx: &mut ViewContext| { let Some(app_state) = this .workspace .update(cx, |workspace, _| workspace.app_state().clone()) @@ -642,12 +740,32 @@ impl DevServerProjects { } }) .detach(); - })) + } + }); + self.selectable_items.add_item(Box::new({ + let callback = callback.clone(); + move |this, cx| callback(this, cx) + })); + let is_selected = self.selectable_items.is_selected(); + + ListItem::new((element_id_base, ix)) + .inset(true) + .selected(is_selected) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot( + Icon::new(IconName::Folder) + .color(Color::Muted) + .size(IconSize::Small), + ) + .child(Label::new(project.paths.join(", "))) + .on_click(cx.listener(move |this, _, cx| callback(this, cx))) .end_hover_slot::(Some( IconButton::new("remove-remote-project", IconName::TrashAlt) + .icon_size(IconSize::Small) .on_click( cx.listener(move |this, _, cx| this.delete_ssh_project(server_ix, ix, cx)), ) + .size(ButtonSize::Large) .tooltip(|cx| Tooltip::text("Delete Remote Project", cx)) .into_any_element(), )) @@ -698,10 +816,11 @@ impl DevServerProjects { .ssh_connections .get_or_insert(Default::default()) .push(SshConnection { - host: connection_options.host, + host: SharedString::from(connection_options.host), username: connection_options.username, port: connection_options.port, projects: vec![], + nickname: None, }) }); } @@ -711,16 +830,17 @@ impl DevServerProjects { state: &CreateDevServer, cx: &mut ViewContext, ) -> impl IntoElement { - let creating = state.creating.is_some(); let ssh_prompt = state.ssh_prompt.clone(); - self.dev_server_name_input.update(cx, |input, cx| { - input.editor().update(cx, |editor, cx| { - if editor.text(cx).is_empty() { - editor.set_placeholder_text("ssh me@my.server / ssh@secret-box:2222", cx); - } - }) + state.address_editor.update(cx, |editor, cx| { + if editor.text(cx).is_empty() { + editor.set_placeholder_text( + "Enter the command you use to SSH into this server: e.g., ssh me@my.server", + cx, + ); + } }); + let theme = cx.theme(); v_flex() @@ -729,50 +849,11 @@ impl DevServerProjects { .size_full() .flex_1() .child( - h_flex() + div() .p_2() - .gap_2() - .items_center() .border_b_1() .border_color(theme.colors().border_variant) - .child( - IconButton::new("cancel-dev-server-creation", IconName::ArrowLeft) - .shape(IconButtonShape::Square) - .on_click(|_, cx| { - cx.dispatch_action(menu::Cancel.boxed_clone()); - }), - ) - .child(Label::new("Connect New Dev Server")), - ) - .child( - v_flex() - .p_3() - .border_b_1() - .border_color(theme.colors().border_variant) - .child(Label::new("SSH Arguments")) - .child( - Label::new("Enter the command you use to SSH into this server.") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .child( - h_flex() - .mt_2() - .w_full() - .gap_2() - .child(self.dev_server_name_input.clone()) - .child( - Button::new("create-dev-server", "Connect Server") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .disabled(creating) - .on_click(cx.listener({ - move |this, _, cx| { - this.create_ssh_server(cx); - } - })), - ), - ), + .child(state.address_editor.clone()), ) .child( h_flex() @@ -788,10 +869,11 @@ impl DevServerProjects { h_flex() .p_2() .w_full() + .items_center() .justify_center() - .gap_1p5() + .gap_2() .child( - div().p_1().rounded_lg().bg(color).with_animation( + div().size_1p5().rounded_full().bg(color).with_animation( "pulse-ssh-waiting-for-connection", Animation::new(Duration::from_secs(2)) .repeat() @@ -809,75 +891,306 @@ impl DevServerProjects { ) } + fn render_view_options( + &mut self, + index: usize, + connection: SshConnection, + cx: &mut ViewContext, + ) -> impl IntoElement { + let connection_string = connection.host.clone(); + + div() + .size_full() + .child( + SshConnectionHeader { + connection_string: connection_string.clone(), + nickname: connection.nickname.clone(), + } + .render(cx), + ) + .child( + v_flex() + .py_1() + .child({ + self.selectable_items.add_item(Box::new({ + move |this, cx| { + this.mode = Mode::EditNickname(EditNicknameState::new(index, cx)); + cx.notify(); + } + })); + let is_selected = self.selectable_items.is_selected(); + let label = if connection.nickname.is_some() { + "Edit Nickname" + } else { + "Add Nickname to Server" + }; + ListItem::new("add-nickname") + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Pencil).color(Color::Muted)) + .child(Label::new(label)) + .on_click(cx.listener(move |this, _, cx| { + this.mode = Mode::EditNickname(EditNicknameState::new(index, cx)); + cx.notify(); + })) + }) + .child({ + let workspace = self.workspace.clone(); + fn callback( + workspace: WeakView, + connection_string: SharedString, + cx: &mut WindowContext<'_>, + ) { + cx.write_to_clipboard(ClipboardItem::new_string( + connection_string.to_string(), + )); + workspace + .update(cx, |this, cx| { + struct SshServerAddressCopiedToClipboard; + let notification = format!( + "Copied server address ({}) to clipboard", + connection_string + ); + + this.show_toast( + Toast::new( + NotificationId::identified::< + SshServerAddressCopiedToClipboard, + >( + connection_string.clone() + ), + notification, + ) + .autohide(), + cx, + ); + }) + .ok(); + } + self.selectable_items.add_item(Box::new({ + let workspace = workspace.clone(); + let connection_string = connection_string.clone(); + move |_, cx| { + callback(workspace.clone(), connection_string.clone(), cx); + } + })); + let is_selected = self.selectable_items.is_selected(); + ListItem::new("copy-server-address") + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Copy).color(Color::Muted)) + .child(Label::new("Copy Server Address")) + .end_hover_slot( + Label::new(connection_string.clone()).color(Color::Muted), + ) + .on_click({ + let connection_string = connection_string.clone(); + move |_, cx| { + callback(workspace.clone(), connection_string.clone(), cx); + } + }) + }) + .child({ + fn remove_ssh_server( + dev_servers: View, + workspace: WeakView, + index: usize, + connection_string: SharedString, + cx: &mut WindowContext<'_>, + ) { + workspace + .update(cx, |this, cx| { + struct SshServerRemoval; + let notification = format!( + "Do you really want to remove server `{}`?", + connection_string + ); + this.show_toast( + Toast::new( + NotificationId::identified::( + connection_string.clone(), + ), + notification, + ) + .on_click( + "Yes, delete it", + move |cx| { + dev_servers.update(cx, |this, cx| { + this.delete_ssh_server(index, cx); + this.mode = Mode::Default; + cx.notify(); + }) + }, + ), + cx, + ); + }) + .ok(); + } + self.selectable_items.add_item(Box::new({ + let connection_string = connection_string.clone(); + move |this, cx| { + remove_ssh_server( + cx.view().clone(), + this.workspace.clone(), + index, + connection_string.clone(), + cx, + ); + } + })); + let is_selected = self.selectable_items.is_selected(); + ListItem::new("delete-server") + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Trash).color(Color::Error)) + .child(Label::new("Delete Server").color(Color::Error)) + .on_click(cx.listener(move |this, _, cx| { + remove_ssh_server( + cx.view().clone(), + this.workspace.clone(), + index, + connection_string.clone(), + cx, + ); + })) + }) + .child(ListSeparator) + .child({ + self.selectable_items.add_item(Box::new({ + move |this, cx| { + this.mode = Mode::Default; + cx.notify(); + } + })); + let is_selected = self.selectable_items.is_selected(); + ListItem::new("go-back") + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::ArrowLeft).color(Color::Muted)) + .child(Label::new("Go Back")) + .on_click(cx.listener(|this, _, cx| { + this.mode = Mode::Default; + cx.notify() + })) + }), + ) + } + + fn render_edit_nickname( + &self, + state: &EditNicknameState, + cx: &mut ViewContext, + ) -> impl IntoElement { + let Some(connection) = SshSettings::get_global(cx) + .ssh_connections() + .nth(state.index) + else { + return v_flex(); + }; + + let connection_string = connection.host.clone(); + + v_flex() + .child( + SshConnectionHeader { + connection_string, + nickname: connection.nickname.clone(), + } + .render(cx), + ) + .child(h_flex().p_2().child(state.editor.clone())) + } + fn render_default(&mut self, cx: &mut ViewContext) -> impl IntoElement { let dev_servers = self.dev_server_store.read(cx).dev_servers(); let ssh_connections = SshSettings::get_global(cx) .ssh_connections() .collect::>(); + self.selectable_items.add_item(Box::new(|this, cx| { + this.mode = Mode::CreateDevServer(CreateDevServer::new(cx)); + cx.notify(); + })); + + let is_selected = self.selectable_items.is_selected(); + let connect_button = ListItem::new("register-dev-server-button") + .selected(is_selected) + .inset(true) + .spacing(ui::ListItemSpacing::Sparse) + .start_slot(Icon::new(IconName::Plus).color(Color::Muted)) + .child(Label::new("Connect New Server")) + .on_click(cx.listener(|this, _, cx| { + let state = CreateDevServer::new(cx); + this.mode = Mode::CreateDevServer(state); + + cx.notify(); + })); + + let footer = format!("Servers: {}", ssh_connections.len() + dev_servers.len()); + let mut modal_section = v_flex() + .id("ssh-server-list") + .overflow_y_scroll() + .size_full() + .child(connect_button) + .child(ListSeparator) + .child( + List::new() + .empty_message("No dev servers registered yet.") + .children(ssh_connections.iter().cloned().enumerate().map( + |(ix, connection)| { + self.render_ssh_connection(ix, connection, cx) + .into_any_element() + }, + )), + ) + .into_any_element(); - let footer = format!("Connections: {}", ssh_connections.len() + dev_servers.len()); Modal::new("remote-projects", Some(self.scroll_handle.clone())) .header( ModalHeader::new().child( h_flex() .justify_between() .child(Headline::new("Remote Projects (alpha)").size(HeadlineSize::XSmall)) - .child( - Button::new("register-dev-server-button", "Connect New Server") - .style(ButtonStyle::Filled) - .layer(ElevationIndex::ModalSurface) - .icon(IconName::Plus) - .icon_position(IconPosition::Start) - .icon_color(Color::Muted) - .on_click(cx.listener(|this, _, cx| { - this.mode = Mode::CreateDevServer(CreateDevServer { - ..Default::default() - }); - this.dev_server_name_input.update(cx, |text_field, cx| { - text_field.editor().update(cx, |editor, cx| { - editor.set_text("", cx); - }); - }); - cx.notify(); - })), - ), + .child(Label::new(footer).size(LabelSize::Small)), ), ) .section( Section::new().padded(false).child( - div() + v_flex() + .min_h(rems(28.)) + .size_full() + .pt_1p5() .border_y_1() .border_color(cx.theme().colors().border_variant) - .w_full() .child( - div().p_2().child( - List::new() - .empty_message("No dev servers registered yet.") - .children(ssh_connections.iter().cloned().enumerate().map( - |(ix, connection)| { - self.render_ssh_connection(ix, connection, cx) - .into_any_element() - }, - )), - ), + canvas( + |bounds, cx| { + modal_section.prepaint_as_root( + bounds.origin, + bounds.size.into(), + cx, + ); + modal_section + }, + |_, mut modal_section, cx| { + modal_section.paint(cx); + }, + ) + .size_full() + .min_h_full() + .flex_1(), ), ), ) - .footer( - ModalFooter::new() - .start_slot(div().child(Label::new(footer).size(LabelSize::Small))), - ) } } -fn get_text(element: &View, cx: &mut WindowContext) -> String { - element - .read(cx) - .editor() - .read(cx) - .text(cx) - .trim() - .to_string() +fn get_text(element: &View, cx: &mut WindowContext) -> String { + element.read(cx).text(cx).trim().to_string() } impl ModalView for DevServerProjects {} @@ -892,27 +1205,36 @@ impl EventEmitter for DevServerProjects {} impl Render for DevServerProjects { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + self.selectable_items.reset(); div() .track_focus(&self.focus_handle) .elevation_3(cx) .key_context("DevServerModal") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::prev_item)) + .on_action(cx.listener(Self::next_item)) .capture_any_mouse_down(cx.listener(|this, _, cx| { this.focus_handle(cx).focus(cx); })) .on_mouse_down_out(cx.listener(|this, _, cx| { - if matches!(this.mode, Mode::Default(None)) { + if matches!(this.mode, Mode::Default) { cx.emit(DismissEvent) } })) .w(rems(34.)) - .max_h(rems(40.)) .child(match &self.mode { - Mode::Default(_) => self.render_default(cx).into_any_element(), + Mode::Default => self.render_default(cx).into_any_element(), + Mode::ViewServerOptions(index, connection) => self + .render_view_options(*index, connection.clone(), cx) + .into_any_element(), + Mode::ProjectPicker(element) => element.clone().into_any_element(), Mode::CreateDevServer(state) => { self.render_create_dev_server(state, cx).into_any_element() } + Mode::EditNickname(state) => { + self.render_edit_nickname(state, cx).into_any_element() + } }) } } diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index a78a1dbcdf..6eb27aa25a 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -16,9 +16,9 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; use ui::{ - div, h_flex, prelude::*, v_flex, ActiveTheme, ButtonCommon, Clickable, Color, Icon, IconButton, - IconName, IconSize, InteractiveElement, IntoElement, Label, LabelCommon, Styled, Tooltip, - ViewContext, VisualContext, WindowContext, + div, h_flex, prelude::*, v_flex, ActiveTheme, Color, Icon, IconName, IconSize, + InteractiveElement, IntoElement, Label, LabelCommon, Styled, ViewContext, VisualContext, + WindowContext, }; use workspace::{AppState, ModalView, Workspace}; @@ -35,17 +35,20 @@ impl SshSettings { #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] pub struct SshConnection { - pub host: String, + pub host: SharedString, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, #[serde(skip_serializing_if = "Option::is_none")] pub port: Option, pub projects: Vec, + /// Name to use for this server in UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub nickname: Option, } impl From for SshConnectionOptions { fn from(val: SshConnection) -> Self { SshConnectionOptions { - host: val.host, + host: val.host.into(), username: val.username, port: val.port, password: None, @@ -87,7 +90,10 @@ pub struct SshConnectionModal { } impl SshPrompt { - pub fn new(connection_options: &SshConnectionOptions, cx: &mut ViewContext) -> Self { + pub(crate) fn new( + connection_options: &SshConnectionOptions, + cx: &mut ViewContext, + ) -> Self { let connection_string = connection_options.connection_string().into(); Self { connection_string, @@ -231,12 +237,57 @@ impl SshConnectionModal { } } +pub(crate) struct SshConnectionHeader { + pub(crate) connection_string: SharedString, + pub(crate) nickname: Option, +} + +impl RenderOnce for SshConnectionHeader { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let theme = cx.theme(); + + let mut header_color = theme.colors().text; + header_color.fade_out(0.96); + + let (main_label, meta_label) = if let Some(nickname) = self.nickname { + (nickname, Some(format!("({})", self.connection_string))) + } else { + (self.connection_string, None) + }; + + h_flex() + .p_1() + .rounded_t_md() + .w_full() + .gap_2() + .justify_center() + .border_b_1() + .border_color(theme.colors().border_variant) + .bg(header_color) + .child(Icon::new(IconName::Server).size(IconSize::XSmall)) + .child( + h_flex() + .gap_1() + .child( + Label::new(main_label) + .size(ui::LabelSize::Small) + .single_line(), + ) + .children(meta_label.map(|label| { + Label::new(label) + .size(ui::LabelSize::Small) + .single_line() + .color(Color::Muted) + })), + ) + } +} + impl Render for SshConnectionModal { fn render(&mut self, cx: &mut ui::ViewContext) -> impl ui::IntoElement { let connection_string = self.prompt.read(cx).connection_string.clone(); let theme = cx.theme(); - let mut header_color = cx.theme().colors().text; - header_color.fade_out(0.96); + let body_color = theme.colors().editor_background; v_flex() @@ -248,36 +299,11 @@ impl Render for SshConnectionModal { .border_1() .border_color(theme.colors().border) .child( - h_flex() - .relative() - .p_1() - .rounded_t_md() - .border_b_1() - .border_color(theme.colors().border) - .bg(header_color) - .justify_between() - .child( - div().absolute().left_0p5().top_0p5().child( - IconButton::new("ssh-connection-cancel", IconName::ArrowLeft) - .icon_size(IconSize::XSmall) - .on_click(cx.listener(move |this, _, cx| { - this.dismiss(&Default::default(), cx); - })) - .tooltip(|cx| Tooltip::for_action("Back", &menu::Cancel, cx)), - ), - ) - .child( - h_flex() - .w_full() - .gap_2() - .justify_center() - .child(Icon::new(IconName::Server).size(IconSize::XSmall)) - .child( - Label::new(connection_string) - .size(ui::LabelSize::Small) - .single_line(), - ), - ), + SshConnectionHeader { + connection_string, + nickname: None, + } + .render(cx), ) .child( h_flex() diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 4e196b8bbf..d0307d5625 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -293,15 +293,20 @@ impl TitleBar { let meta = SharedString::from(meta); - let indicator = div() + let indicator = h_flex() + // We're using the circle inside a circle approach because, otherwise, by using borders + // we'd get a very thin, leaking indicator color, which is not what we want. .absolute() .size_2p5() .right_0() .bottom_0() + .bg(indicator_border_color) + .size_2p5() .rounded_full() - .border_2() - .border_color(indicator_border_color) - .bg(indicator_color.color(cx)); + .items_center() + .justify_center() + .overflow_hidden() + .child(Indicator::dot().color(indicator_color)); Some( div() diff --git a/crates/ui/src/components/list/list_item.rs b/crates/ui/src/components/list/list_item.rs index 37076737a6..dd2467f7b0 100644 --- a/crates/ui/src/components/list/list_item.rs +++ b/crates/ui/src/components/list/list_item.rs @@ -193,6 +193,7 @@ impl RenderOnce for ListItem { .id("inner_list_item") .w_full() .relative() + .items_center() .gap_1() .px(Spacing::Medium.rems(cx)) .map(|this| match self.spacing { @@ -247,7 +248,7 @@ impl RenderOnce for ListItem { .flex_grow() .flex_shrink_0() .flex_basis(relative(0.25)) - .gap(Spacing::Small.rems(cx)) + .gap(Spacing::Medium.rems(cx)) .map(|list_content| { if self.overflow_x { list_content