diff --git a/Cargo.lock b/Cargo.lock index 74ffb82f68..ba868ebbae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6510,6 +6510,7 @@ dependencies = [ "theme2", "ui2", "util", + "workspace2", ] [[package]] @@ -7019,6 +7020,29 @@ dependencies = [ "workspace", ] +[[package]] +name = "project_symbols2" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor2", + "futures 0.3.28", + "fuzzy2", + "gpui2", + "language2", + "lsp2", + "ordered-float 2.10.0", + "picker2", + "postage", + "project2", + "settings2", + "smol", + "text2", + "theme2", + "util", + "workspace2", +] + [[package]] name = "prometheus" version = "0.13.3" @@ -12081,6 +12105,7 @@ dependencies = [ "postage", "project2", "project_panel2", + "project_symbols2", "quick_action_bar2", "rand 0.8.5", "recent_projects2", diff --git a/Cargo.toml b/Cargo.toml index 2190066df5..95cf2ae78c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -90,6 +90,7 @@ members = [ "crates/project_panel", "crates/project_panel2", "crates/project_symbols", + "crates/project_symbols2", "crates/quick_action_bar2", "crates/recent_projects", "crates/recent_projects2", diff --git a/crates/collab_ui2/src/chat_panel.rs b/crates/collab_ui2/src/chat_panel.rs index 587efbe95f..f3f2a37171 100644 --- a/crates/collab_ui2/src/chat_panel.rs +++ b/crates/collab_ui2/src/chat_panel.rs @@ -21,10 +21,7 @@ use settings::{Settings, SettingsStore}; use std::sync::Arc; use theme::ActiveTheme as _; use time::{OffsetDateTime, UtcOffset}; -use ui::{ - h_stack, prelude::WindowContext, v_stack, Avatar, Button, ButtonCommon as _, Clickable, Icon, - IconButton, Label, Tooltip, -}; +use ui::{prelude::*, Avatar, Button, Icon, IconButton, Label, Tooltip}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, @@ -382,13 +379,18 @@ impl ChatPanel { .child(text.element("body".into(), cx)) .child( div() - .invisible() .absolute() .top_1() .right_2() .w_8() - .group_hover("", |this| this.visible()) - .child(render_remove(message_id_to_remove, cx)), + .visible_on_hover("") + .children(message_id_to_remove.map(|message_id| { + IconButton::new(("remove", message_id), Icon::XCircle).on_click( + cx.listener(move |this, _, cx| { + this.remove_message(message_id, cx); + }), + ) + })), ) .into_any() } @@ -528,18 +530,6 @@ impl ChatPanel { } } -fn render_remove(message_id_to_remove: Option, cx: &mut ViewContext) -> AnyElement { - if let Some(message_id) = message_id_to_remove { - IconButton::new(("remove", message_id), Icon::XCircle) - .on_click(cx.listener(move |this, _, cx| { - this.remove_message(message_id, cx); - })) - .into_any_element() - } else { - div().into_any_element() - } -} - impl EventEmitter for ChatPanel {} impl Render for ChatPanel { diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index cd7b2b9e9d..298c7682eb 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -176,11 +176,11 @@ use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ actions, canvas, div, fill, img, impl_actions, overlay, point, prelude::*, px, rems, - serde_json, size, Action, AppContext, AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, - Div, EventEmitter, FocusHandle, Focusable, FocusableView, Hsla, InteractiveElement, - IntoElement, Length, Model, MouseDownEvent, ParentElement, Pixels, Point, PromptLevel, Quad, - Render, RenderOnce, ScrollHandle, SharedString, Size, Stateful, Styled, Subscription, Task, - View, ViewContext, VisualContext, WeakView, + serde_json, size, Action, AnyElement, AppContext, AsyncWindowContext, Bounds, ClipboardItem, + DismissEvent, Div, EventEmitter, FocusHandle, Focusable, FocusableView, Hsla, + InteractiveElement, IntoElement, Length, Model, MouseDownEvent, ParentElement, Pixels, Point, + PromptLevel, Quad, Render, RenderOnce, ScrollHandle, SharedString, Size, Stateful, Styled, + Subscription, Task, View, ViewContext, VisualContext, WeakView, }; use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; @@ -402,7 +402,7 @@ impl CollabPanel { let filter_editor = cx.build_view(|cx| { let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Filter channels, contacts", cx); + editor.set_placeholder_text("Filter...", cx); editor }); @@ -1156,25 +1156,21 @@ impl CollabPanel { let tooltip = format!("Follow {}", user.github_login); ListItem::new(SharedString::from(user.github_login.clone())) - .left_child(Avatar::new(user.avatar_uri.clone())) - .child( - h_stack() - .w_full() - .justify_between() - .child(Label::new(user.github_login.clone())) - .child(if is_pending { - Label::new("Calling").color(Color::Muted).into_any_element() - } else if is_current_user { - IconButton::new("leave-call", Icon::ArrowRight) - .on_click(cx.listener(move |this, _, cx| { - Self::leave_call(cx); - })) - .tooltip(|cx| Tooltip::text("Leave Call", cx)) - .into_any_element() - } else { - div().into_any_element() - }), - ) + .start_slot(Avatar::new(user.avatar_uri.clone())) + .child(Label::new(user.github_login.clone())) + .end_slot(if is_pending { + Label::new("Calling").color(Color::Muted).into_any_element() + } else if is_current_user { + IconButton::new("leave-call", Icon::Exit) + .style(ButtonStyle::Subtle) + .on_click(cx.listener(move |this, _, cx| { + Self::leave_call(cx); + })) + .tooltip(|cx| Tooltip::text("Leave Call", cx)) + .into_any_element() + } else { + div().into_any_element() + }) .when_some(peer_id, |this, peer_id| { this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx)) .on_click(cx.listener(move |this, _, cx| { @@ -1212,8 +1208,12 @@ impl CollabPanel { .detach_and_log_err(cx); }); })) - .left_child(render_tree_branch(is_last, cx)) - .child(IconButton::new(0, Icon::Folder)) + .start_slot( + h_stack() + .gap_1() + .child(render_tree_branch(is_last, cx)) + .child(IconButton::new(0, Icon::Folder)), + ) .child(Label::new(project_name.clone())) .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx)) @@ -1305,8 +1305,12 @@ impl CollabPanel { let id = peer_id.map_or(usize::MAX, |id| id.as_u64() as usize); ListItem::new(("screen", id)) - .left_child(render_tree_branch(is_last, cx)) - .child(IconButton::new(0, Icon::Screen)) + .start_slot( + h_stack() + .gap_1() + .child(render_tree_branch(is_last, cx)) + .child(IconButton::new(0, Icon::Screen)), + ) .child(Label::new("Screen")) .when_some(peer_id, |this, _| { this.on_click(cx.listener(move |this, _, cx| { @@ -1372,9 +1376,13 @@ impl CollabPanel { .on_click(cx.listener(move |this, _, cx| { this.open_channel_notes(channel_id, cx); })) - .left_child(render_tree_branch(false, cx)) - .child(IconButton::new(0, Icon::File)) - .child(Label::new("notes")) + .start_slot( + h_stack() + .gap_1() + .child(render_tree_branch(false, cx)) + .child(IconButton::new(0, Icon::File)), + ) + .child(div().h_7().w_full().child(Label::new("notes"))) .tooltip(move |cx| Tooltip::text("Open Channel Notes", cx)) } @@ -1387,8 +1395,12 @@ impl CollabPanel { .on_click(cx.listener(move |this, _, cx| { this.join_channel_chat(channel_id, cx); })) - .left_child(render_tree_branch(true, cx)) - .child(IconButton::new(0, Icon::MessageBubbles)) + .start_slot( + h_stack() + .gap_1() + .child(render_tree_branch(false, cx)) + .child(IconButton::new(0, Icon::MessageBubbles)), + ) .child(Label::new("chat")) .tooltip(move |cx| Tooltip::text("Open Chat", cx)) } @@ -2149,11 +2161,6 @@ impl CollabPanel { fn render_signed_in(&mut self, cx: &mut ViewContext) -> Div { v_stack() .size_full() - .child( - div() - .p_2() - .child(div().rounded(px(2.0)).child(self.filter_editor.clone())), - ) .child( v_stack() .size_full() @@ -2223,6 +2230,14 @@ impl CollabPanel { } })), ) + .child( + div().p_2().child( + div() + .border_primary(cx) + .border_t() + .child(self.filter_editor.clone()), + ), + ) } fn render_header( @@ -2275,21 +2290,27 @@ impl CollabPanel { Section::ActiveCall => channel_link.map(|channel_link| { let channel_link_copy = channel_link.clone(); IconButton::new("channel-link", Icon::Copy) + .icon_size(IconSize::Small) + .size(ButtonSize::None) + .visible_on_hover("section-header") .on_click(move |_, cx| { let item = ClipboardItem::new(channel_link_copy.clone()); cx.write_to_clipboard(item) }) .tooltip(|cx| Tooltip::text("Copy channel link", cx)) + .into_any_element() }), Section::Contacts => Some( IconButton::new("add-contact", Icon::Plus) .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx))) - .tooltip(|cx| Tooltip::text("Search for new contact", cx)), + .tooltip(|cx| Tooltip::text("Search for new contact", cx)) + .into_any_element(), ), Section::Channels => Some( IconButton::new("add-channel", Icon::Plus) .on_click(cx.listener(|this, _, cx| this.new_root_channel(cx))) - .tooltip(|cx| Tooltip::text("Create a channel", cx)), + .tooltip(|cx| Tooltip::text("Create a channel", cx)) + .into_any_element(), ), _ => None, }; @@ -2304,25 +2325,18 @@ impl CollabPanel { h_stack() .w_full() - .map(|el| { - if can_collapse { - el.child( - ListItem::new(text.clone()) - .child(div().w_full().child(Label::new(text))) - .selected(is_selected) - .toggle(Some(!is_collapsed)) - .on_click(cx.listener(move |this, _, cx| { - this.toggle_section_expanded(section, cx) - })), - ) - } else { - el.child( - ListHeader::new(text) - .when_some(button, |el, button| el.meta(button)) - .selected(is_selected), - ) - } - }) + .group("section-header") + .child( + ListHeader::new(text) + .toggle(if can_collapse { + Some(!is_collapsed) + } else { + None + }) + .inset(true) + .end_slot::(button) + .selected(is_selected), + ) .when(section == Section::Channels, |el| { el.drag_over::(|style| { style.bg(cx.theme().colors().ghost_element_hover) @@ -2363,25 +2377,20 @@ impl CollabPanel { }) .when(!calling, |el| { el.child( - div() - .id("remove_contact") - .invisible() - .group_hover("", |style| style.visible()) - .child( - IconButton::new("remove_contact", Icon::Close) - .icon_color(Color::Muted) - .tooltip(|cx| Tooltip::text("Remove Contact", cx)) - .on_click(cx.listener({ - let github_login = github_login.clone(); - move |this, _, cx| { - this.remove_contact(user_id, &github_login, cx); - } - })), - ), + IconButton::new("remove_contact", Icon::Close) + .icon_color(Color::Muted) + .visible_on_hover("") + .tooltip(|cx| Tooltip::text("Remove Contact", cx)) + .on_click(cx.listener({ + let github_login = github_login.clone(); + move |this, _, cx| { + this.remove_contact(user_id, &github_login, cx); + } + })), ) }), ) - .left_child( + .start_slot( // todo!() handle contacts with no avatar Avatar::new(contact.user.avatar_uri.clone()) .availability_indicator(if online { Some(!busy) } else { None }), @@ -2460,7 +2469,7 @@ impl CollabPanel { .child(Label::new(github_login.clone())) .child(h_stack().children(controls)), ) - .left_avatar(user.avatar_uri.clone()) + .start_slot(Avatar::new(user.avatar_uri.clone())) } fn render_contact_placeholder( @@ -2541,6 +2550,8 @@ impl CollabPanel { div() .id(channel_id as usize) .group("") + .flex() + .w_full() .on_drag({ let channel = channel.clone(); move |cx| { @@ -2566,67 +2577,10 @@ impl CollabPanel { ) .child( ListItem::new(channel_id as usize) - .indent_level(depth) + // Offset the indent depth by one to give us room to show the disclosure. + .indent_level(depth + 1) .indent_step_size(cx.rem_size() * 14.0 / 16.0) // @todo()! @nate this is to step over the disclosure toggle - .left_icon(if is_public { Icon::Public } else { Icon::Hash }) .selected(is_selected || is_active) - .child( - h_stack() - .w_full() - .justify_between() - .child( - h_stack() - .id(channel_id as usize) - .child(Label::new(channel.name.clone())) - .children(face_pile.map(|face_pile| face_pile.render(cx))), - ) - .child( - h_stack() - .child( - div() - .id("channel_chat") - .when(!has_messages_notification, |el| el.invisible()) - .group_hover("", |style| style.visible()) - .child( - IconButton::new( - "channel_chat", - Icon::MessageBubbles, - ) - .icon_color(if has_messages_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, cx| { - this.join_channel_chat(channel_id, cx) - })) - .tooltip(|cx| { - Tooltip::text("Open channel chat", cx) - }), - ), - ) - .child( - div() - .id("channel_notes") - .when(!has_notes_notification, |el| el.invisible()) - .group_hover("", |style| style.visible()) - .child( - IconButton::new("channel_notes", Icon::File) - .icon_color(if has_notes_notification { - Color::Default - } else { - Color::Muted - }) - .on_click(cx.listener(move |this, _, cx| { - this.open_channel_notes(channel_id, cx) - })) - .tooltip(|cx| { - Tooltip::text("Open channel notes", cx) - }), - ), - ), - ), - ) .toggle(disclosed) .on_toggle( cx.listener(move |this, _, cx| { @@ -2646,7 +2600,49 @@ impl CollabPanel { move |this, event: &MouseDownEvent, cx| { this.deploy_channel_context_menu(event.position, channel_id, ix, cx) }, - )), + )) + .start_slot( + IconElement::new(if is_public { Icon::Public } else { Icon::Hash }) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child( + h_stack() + .id(channel_id as usize) + .child(Label::new(channel.name.clone())) + .children(face_pile.map(|face_pile| face_pile.render(cx))), + ) + .end_slot( + h_stack() + .child( + IconButton::new("channel_chat", Icon::MessageBubbles) + .icon_color(if has_messages_notification { + Color::Default + } else { + Color::Muted + }) + .when(!has_messages_notification, |this| { + this.visible_on_hover("") + }) + .on_click(cx.listener(move |this, _, cx| { + this.join_channel_chat(channel_id, cx) + })) + .tooltip(|cx| Tooltip::text("Open channel chat", cx)), + ) + .child( + IconButton::new("channel_notes", Icon::File) + .icon_color(if has_notes_notification { + Color::Default + } else { + Color::Muted + }) + .when(!has_notes_notification, |this| this.visible_on_hover("")) + .on_click(cx.listener(move |this, _, cx| { + this.open_channel_notes(channel_id, cx) + })) + .tooltip(|cx| Tooltip::text("Open channel notes", cx)), + ), + ), ) .tooltip(|cx| Tooltip::text("Join channel", cx)) @@ -2962,7 +2958,11 @@ impl CollabPanel { let item = ListItem::new("channel-editor") .inset(false) .indent_level(depth) - .left_icon(Icon::Hash); + .start_slot( + IconElement::new(Icon::Hash) + .size(IconSize::Small) + .color(Color::Muted), + ); if let Some(pending_name) = self .channel_editing_state diff --git a/crates/editor2/src/editor.rs b/crates/editor2/src/editor.rs index 0d19b53d29..89b5fd2efb 100644 --- a/crates/editor2/src/editor.rs +++ b/crates/editor2/src/editor.rs @@ -9763,19 +9763,15 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend .px_1p5() .child(HighlightedLabel::new(line.clone(), highlights.clone())) .child( - div() - .border() - .border_color(gpui::red()) - .invisible() - .group_hover(group_id, |style| style.visible()) - .child( - IconButton::new(copy_id.clone(), Icon::Copy) - .icon_color(Color::Muted) - .size(ButtonSize::Compact) - .style(ButtonStyle::Transparent) - .on_click(cx.listener(move |_, _, cx| write_to_clipboard)) - .tooltip(|cx| Tooltip::text("Copy diagnostic message", cx)), - ), + div().border().border_color(gpui::red()).child( + IconButton::new(copy_id.clone(), Icon::Copy) + .icon_color(Color::Muted) + .size(ButtonSize::Compact) + .style(ButtonStyle::Transparent) + .visible_on_hover(group_id) + .on_click(cx.listener(move |_, _, cx| write_to_clipboard)) + .tooltip(|cx| Tooltip::text("Copy diagnostic message", cx)), + ), ) })) .into_any_element() diff --git a/crates/feedback2/src/feedback_modal.rs b/crates/feedback2/src/feedback_modal.rs index e8715034c2..e5c1ccdc9d 100644 --- a/crates/feedback2/src/feedback_modal.rs +++ b/crates/feedback2/src/feedback_modal.rs @@ -53,7 +53,7 @@ pub struct FeedbackModal { email_address_editor: View, awaiting_submission: bool, user_submitted: bool, - discarded: bool, + user_discarded: bool, character_count: i32, } @@ -71,7 +71,7 @@ impl ModalView for FeedbackModal { return true; } - if self.discarded { + if self.user_discarded { return true; } @@ -85,7 +85,7 @@ impl ModalView for FeedbackModal { cx.spawn(move |this, mut cx| async move { if answer.await.ok() == Some(0) { this.update(&mut cx, |this, cx| { - this.discarded = true; + this.user_discarded = true; cx.emit(DismissEvent) }) .log_err(); @@ -184,7 +184,7 @@ impl FeedbackModal { email_address_editor, awaiting_submission: false, user_submitted: false, - discarded: false, + user_discarded: false, character_count: 0, } } @@ -309,7 +309,6 @@ impl FeedbackModal { Ok(()) } - // TODO: Escape button calls dismiss fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { cx.emit(DismissEvent) } diff --git a/crates/gpui2/src/elements/div.rs b/crates/gpui2/src/elements/div.rs index 4f18d5884c..1954e3086c 100644 --- a/crates/gpui2/src/elements/div.rs +++ b/crates/gpui2/src/elements/div.rs @@ -893,7 +893,7 @@ impl Interactivity { if style .background .as_ref() - .is_some_and(|fill| fill.color().is_some_and(|color| !color.is_transparent())) + .is_some_and(|fill| fill.color().is_some()) { cx.with_z_index(style.z_index.unwrap_or(0), |cx| cx.add_opaque_layer(bounds)) } @@ -1273,78 +1273,84 @@ impl Interactivity { let mut style = Style::default(); style.refine(&self.base_style); - if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { - if let Some(in_focus_style) = self.in_focus_style.as_ref() { - if focus_handle.within_focused(cx) { - style.refine(in_focus_style); + cx.with_z_index(style.z_index.unwrap_or(0), |cx| { + if let Some(focus_handle) = self.tracked_focus_handle.as_ref() { + if let Some(in_focus_style) = self.in_focus_style.as_ref() { + if focus_handle.within_focused(cx) { + style.refine(in_focus_style); + } } - } - if let Some(focus_style) = self.focus_style.as_ref() { - if focus_handle.is_focused(cx) { - style.refine(focus_style); - } - } - } - - if let Some(bounds) = bounds { - let mouse_position = cx.mouse_position(); - if let Some(group_hover) = self.group_hover_style.as_ref() { - if let Some(group_bounds) = GroupBounds::get(&group_hover.group, cx) { - if group_bounds.contains(&mouse_position) - && cx.was_top_layer(&mouse_position, cx.stacking_order()) - { - style.refine(&group_hover.style); + if let Some(focus_style) = self.focus_style.as_ref() { + if focus_handle.is_focused(cx) { + style.refine(focus_style); } } } - if let Some(hover_style) = self.hover_style.as_ref() { - if bounds - .intersect(&cx.content_mask().bounds) - .contains(&mouse_position) - && cx.was_top_layer(&mouse_position, cx.stacking_order()) - { - style.refine(hover_style); - } - } - if let Some(drag) = cx.active_drag.take() { - for (state_type, group_drag_style) in &self.group_drag_over_styles { - if let Some(group_bounds) = GroupBounds::get(&group_drag_style.group, cx) { - if *state_type == drag.view.entity_type() - && group_bounds.contains(&mouse_position) + if let Some(bounds) = bounds { + let mouse_position = cx.mouse_position(); + if let Some(group_hover) = self.group_hover_style.as_ref() { + if let Some(group_bounds) = GroupBounds::get(&group_hover.group, cx) { + if group_bounds.contains(&mouse_position) + && cx.was_top_layer(&mouse_position, cx.stacking_order()) { - style.refine(&group_drag_style.style); + style.refine(&group_hover.style); } } } - - for (state_type, drag_over_style) in &self.drag_over_styles { - if *state_type == drag.view.entity_type() - && bounds - .intersect(&cx.content_mask().bounds) - .contains(&mouse_position) + if let Some(hover_style) = self.hover_style.as_ref() { + if bounds + .intersect(&cx.content_mask().bounds) + .contains(&mouse_position) + && cx.was_top_layer(&mouse_position, cx.stacking_order()) { - style.refine(drag_over_style); + style.refine(hover_style); } } - cx.active_drag = Some(drag); - } - } + if let Some(drag) = cx.active_drag.take() { + for (state_type, group_drag_style) in &self.group_drag_over_styles { + if let Some(group_bounds) = GroupBounds::get(&group_drag_style.group, cx) { + if *state_type == drag.view.entity_type() + && group_bounds.contains(&mouse_position) + { + style.refine(&group_drag_style.style); + } + } + } - let clicked_state = element_state.clicked_state.borrow(); - if clicked_state.group { - if let Some(group) = self.group_active_style.as_ref() { - style.refine(&group.style) - } - } + for (state_type, drag_over_style) in &self.drag_over_styles { + if *state_type == drag.view.entity_type() + && bounds + .intersect(&cx.content_mask().bounds) + .contains(&mouse_position) + && cx.was_top_layer_under_active_drag( + &mouse_position, + cx.stacking_order(), + ) + { + style.refine(drag_over_style); + } + } - if let Some(active_style) = self.active_style.as_ref() { - if clicked_state.element { - style.refine(active_style) + cx.active_drag = Some(drag); + } } - } + + let clicked_state = element_state.clicked_state.borrow(); + if clicked_state.group { + if let Some(group) = self.group_active_style.as_ref() { + style.refine(&group.style) + } + } + + if let Some(active_style) = self.active_style.as_ref() { + if clicked_state.element { + style.refine(active_style) + } + } + }); style } diff --git a/crates/gpui2/src/scene.rs b/crates/gpui2/src/scene.rs index ca0a50546e..68c068dfe9 100644 --- a/crates/gpui2/src/scene.rs +++ b/crates/gpui2/src/scene.rs @@ -17,6 +17,7 @@ pub type LayerId = u32; pub type DrawOrder = u32; pub(crate) struct SceneBuilder { + last_order: Option<(StackingOrder, LayerId)>, layers_by_order: BTreeMap, splitter: BspSplitter<(PrimitiveKind, usize)>, shadows: Vec, @@ -31,6 +32,7 @@ pub(crate) struct SceneBuilder { impl Default for SceneBuilder { fn default() -> Self { SceneBuilder { + last_order: None, layers_by_order: BTreeMap::new(), splitter: BspSplitter::new(), shadows: Vec::new(), @@ -52,6 +54,7 @@ impl SceneBuilder { layer_z_values[*layer_id as usize] = ix as f32 / self.layers_by_order.len() as f32; } self.layers_by_order.clear(); + self.last_order = None; // Add all primitives to the BSP splitter to determine draw order self.splitter.reset(); @@ -156,14 +159,7 @@ impl SceneBuilder { return; } - let layer_id = if let Some(layer_id) = self.layers_by_order.get(order) { - *layer_id - } else { - let next_id = self.layers_by_order.len() as LayerId; - self.layers_by_order.insert(order.clone(), next_id); - next_id - }; - + let layer_id = self.layer_id_for_order(order); match primitive { Primitive::Shadow(mut shadow) => { shadow.order = layer_id; @@ -196,6 +192,24 @@ impl SceneBuilder { } } } + + fn layer_id_for_order(&mut self, order: &StackingOrder) -> u32 { + if let Some((last_order, last_layer_id)) = self.last_order.as_ref() { + if last_order == order { + return *last_layer_id; + } + }; + + let layer_id = if let Some(layer_id) = self.layers_by_order.get(order) { + *layer_id + } else { + let next_id = self.layers_by_order.len() as LayerId; + self.layers_by_order.insert(order.clone(), next_id); + next_id + }; + self.last_order = Some((order.clone(), layer_id)); + layer_id + } } pub struct Scene { diff --git a/crates/picker2/Cargo.toml b/crates/picker2/Cargo.toml index 3c4d21ad50..e94702ff9c 100644 --- a/crates/picker2/Cargo.toml +++ b/crates/picker2/Cargo.toml @@ -16,6 +16,7 @@ menu = { package = "menu2", path = "../menu2" } settings = { package = "settings2", path = "../settings2" } util = { path = "../util" } theme = { package = "theme2", path = "../theme2" } +workspace = { package = "workspace2", path = "../workspace2"} parking_lot.workspace = true diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index db5eebff53..f7911094b5 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -1,11 +1,12 @@ use editor::Editor; use gpui::{ div, prelude::*, rems, uniform_list, AnyElement, AppContext, DismissEvent, Div, EventEmitter, - FocusHandle, FocusableView, MouseButton, MouseDownEvent, Render, Task, UniformListScrollHandle, - View, ViewContext, WindowContext, + FocusHandle, FocusableView, Length, MouseButton, MouseDownEvent, Render, Task, + UniformListScrollHandle, View, ViewContext, WindowContext, }; use std::{cmp, sync::Arc}; use ui::{prelude::*, v_stack, Color, Divider, Label}; +use workspace::ModalView; pub struct Picker { pub delegate: D, @@ -13,6 +14,7 @@ pub struct Picker { editor: View, pending_update_matches: Option>, confirm_on_update: Option, + width: Option, } pub trait PickerDelegate: Sized + 'static { @@ -55,11 +57,17 @@ impl Picker { scroll_handle: UniformListScrollHandle::new(), pending_update_matches: None, confirm_on_update: None, + width: None, }; this.update_matches("".to_string(), cx); this } + pub fn width(mut self, width: impl Into) -> Self { + self.width = Some(width.into()); + self + } + pub fn focus(&self, cx: &mut WindowContext) { self.editor.update(cx, |editor, cx| editor.focus(cx)); } @@ -197,6 +205,7 @@ impl Picker { } impl EventEmitter for Picker {} +impl ModalView for Picker {} impl Render for Picker { type Element = Div; @@ -221,6 +230,9 @@ impl Render for Picker { div() .key_context("picker") .size_full() + .when_some(self.width, |el, width| { + el.w(width) + }) .overflow_hidden() .elevation_3(cx) .on_action(cx.listener(Self::select_next)) @@ -271,7 +283,6 @@ impl Render for Picker { }, ) .track_scroll(self.scroll_handle.clone()) - .p_1() ) .max_h_72() .overflow_hidden(), diff --git a/crates/project_symbols2/Cargo.toml b/crates/project_symbols2/Cargo.toml new file mode 100644 index 0000000000..e11dd373a8 --- /dev/null +++ b/crates/project_symbols2/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "project_symbols2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/project_symbols.rs" +doctest = false + +[dependencies] +editor = { package = "editor2", path = "../editor2" } +fuzzy = {package = "fuzzy2", path = "../fuzzy2" } +gpui = {package = "gpui2", path = "../gpui2" } +picker = {package = "picker2", path = "../picker2" } +project = { package = "project2", path = "../project2" } +text = {package = "text2", path = "../text2" } +settings = {package = "settings2", path = "../settings2" } +workspace = {package = "workspace2", path = "../workspace2" } +theme = { package = "theme2", path = "../theme2" } +util = { path = "../util" } + +anyhow.workspace = true +ordered-float.workspace = true +postage.workspace = true +smol.workspace = true + +[dev-dependencies] +futures.workspace = true +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +settings = { package = "settings2", path = "../settings2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +language = { package = "language2", path = "../language2", features = ["test-support"] } +lsp = { package = "lsp2", path = "../lsp2", features = ["test-support"] } +project = { package = "project2", path = "../project2", features = ["test-support"] } +theme = { package = "theme2", path = "../theme2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } diff --git a/crates/project_symbols2/src/project_symbols.rs b/crates/project_symbols2/src/project_symbols.rs new file mode 100644 index 0000000000..da67fc888f --- /dev/null +++ b/crates/project_symbols2/src/project_symbols.rs @@ -0,0 +1,411 @@ +use editor::{scroll::autoscroll::Autoscroll, styled_runs_for_code_label, Bias, Editor}; +use fuzzy::{StringMatch, StringMatchCandidate}; +use gpui::{ + actions, rems, AppContext, DismissEvent, FontWeight, Model, ParentElement, StyledText, Task, + View, ViewContext, WeakView, +}; +use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; +use project::{Project, Symbol}; +use std::{borrow::Cow, cmp::Reverse, sync::Arc}; +use theme::ActiveTheme; +use util::ResultExt; +use workspace::{ + ui::{v_stack, Color, Label, LabelCommon, LabelLike, ListItem, Selectable}, + Workspace, +}; + +actions!(project_symbols, [Toggle]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views( + |workspace: &mut Workspace, _: &mut ViewContext| { + workspace.register_action(|workspace, _: &Toggle, cx| { + let project = workspace.project().clone(); + let handle = cx.view().downgrade(); + workspace.toggle_modal(cx, move |cx| { + let delegate = ProjectSymbolsDelegate::new(handle, project); + Picker::new(delegate, cx).width(rems(34.)) + }) + }); + }, + ) + .detach(); +} + +pub type ProjectSymbols = View>; + +pub struct ProjectSymbolsDelegate { + workspace: WeakView, + project: Model, + selected_match_index: usize, + symbols: Vec, + visible_match_candidates: Vec, + external_match_candidates: Vec, + show_worktree_root_name: bool, + matches: Vec, +} + +impl ProjectSymbolsDelegate { + fn new(workspace: WeakView, project: Model) -> Self { + Self { + workspace, + project, + selected_match_index: 0, + symbols: Default::default(), + visible_match_candidates: Default::default(), + external_match_candidates: Default::default(), + matches: Default::default(), + show_worktree_root_name: false, + } + } + + fn filter(&mut self, query: &str, cx: &mut ViewContext>) { + const MAX_MATCHES: usize = 100; + let mut visible_matches = cx.background_executor().block(fuzzy::match_strings( + &self.visible_match_candidates, + query, + false, + MAX_MATCHES, + &Default::default(), + cx.background_executor().clone(), + )); + let mut external_matches = cx.background_executor().block(fuzzy::match_strings( + &self.external_match_candidates, + query, + false, + MAX_MATCHES - visible_matches.len().min(MAX_MATCHES), + &Default::default(), + cx.background_executor().clone(), + )); + let sort_key_for_match = |mat: &StringMatch| { + let symbol = &self.symbols[mat.candidate_id]; + ( + Reverse(OrderedFloat(mat.score)), + &symbol.label.text[symbol.label.filter_range.clone()], + ) + }; + + visible_matches.sort_unstable_by_key(sort_key_for_match); + external_matches.sort_unstable_by_key(sort_key_for_match); + let mut matches = visible_matches; + matches.append(&mut external_matches); + + for mat in &mut matches { + let symbol = &self.symbols[mat.candidate_id]; + let filter_start = symbol.label.filter_range.start; + for position in &mut mat.positions { + *position += filter_start; + } + } + + self.matches = matches; + self.set_selected_index(0, cx); + } +} + +impl PickerDelegate for ProjectSymbolsDelegate { + type ListItem = ListItem; + fn placeholder_text(&self) -> Arc { + "Search project symbols...".into() + } + + fn confirm(&mut self, secondary: bool, cx: &mut ViewContext>) { + if let Some(symbol) = self + .matches + .get(self.selected_match_index) + .map(|mat| self.symbols[mat.candidate_id].clone()) + { + let buffer = self.project.update(cx, |project, cx| { + project.open_buffer_for_symbol(&symbol, cx) + }); + let symbol = symbol.clone(); + let workspace = self.workspace.clone(); + cx.spawn(|_, mut cx| async move { + let buffer = buffer.await?; + workspace.update(&mut cx, |workspace, cx| { + let position = buffer + .read(cx) + .clip_point_utf16(symbol.range.start, Bias::Left); + + let editor = if secondary { + workspace.split_project_item::(buffer, cx) + } else { + workspace.open_project_item::(buffer, cx) + }; + + editor.update(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::center()), cx, |s| { + s.select_ranges([position..position]) + }); + }); + })?; + Ok::<_, anyhow::Error>(()) + }) + .detach_and_log_err(cx); + cx.emit(DismissEvent); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn selected_index(&self) -> usize { + self.selected_match_index + } + + fn set_selected_index(&mut self, ix: usize, _cx: &mut ViewContext>) { + self.selected_match_index = ix; + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + self.filter(&query, cx); + self.show_worktree_root_name = self.project.read(cx).visible_worktrees(cx).count() > 1; + let symbols = self + .project + .update(cx, |project, cx| project.symbols(&query, cx)); + cx.spawn(|this, mut cx| async move { + let symbols = symbols.await.log_err(); + if let Some(symbols) = symbols { + this.update(&mut cx, |this, cx| { + let delegate = &mut this.delegate; + let project = delegate.project.read(cx); + let (visible_match_candidates, external_match_candidates) = symbols + .iter() + .enumerate() + .map(|(id, symbol)| { + StringMatchCandidate::new( + id, + symbol.label.text[symbol.label.filter_range.clone()].to_string(), + ) + }) + .partition(|candidate| { + project + .entry_for_path(&symbols[candidate.id].path, cx) + .map_or(false, |e| !e.is_ignored) + }); + + delegate.visible_match_candidates = visible_match_candidates; + delegate.external_match_candidates = external_match_candidates; + delegate.symbols = symbols; + delegate.filter(&query, cx); + }) + .log_err(); + } + }) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let string_match = &self.matches[ix]; + let symbol = &self.symbols[string_match.candidate_id]; + let syntax_runs = styled_runs_for_code_label(&symbol.label, cx.theme().syntax()); + + let mut path = symbol.path.path.to_string_lossy(); + if self.show_worktree_root_name { + let project = self.project.read(cx); + if let Some(worktree) = project.worktree_for_id(symbol.path.worktree_id, cx) { + path = Cow::Owned(format!( + "{}{}{}", + worktree.read(cx).root_name(), + std::path::MAIN_SEPARATOR, + path.as_ref() + )); + } + } + let label = symbol.label.text.clone(); + let path = path.to_string().clone(); + + let highlights = gpui::combine_highlights( + string_match + .positions + .iter() + .map(|pos| (*pos..pos + 1, FontWeight::BOLD.into())), + syntax_runs.map(|(range, mut highlight)| { + // Ignore font weight for syntax highlighting, as we'll use it + // for fuzzy matches. + highlight.font_weight = None; + (range, highlight) + }), + ); + + Some( + ListItem::new(ix).inset(true).selected(selected).child( + // todo!() combine_syntax_and_fuzzy_match_highlights() + v_stack() + .child( + LabelLike::new().child( + StyledText::new(label) + .with_highlights(&cx.text_style().clone(), highlights), + ), + ) + .child(Label::new(path).color(Color::Muted)), + ), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::StreamExt; + use gpui::{serde_json::json, TestAppContext, VisualContext}; + use language::{FakeLspAdapter, Language, LanguageConfig}; + use project::FakeFs; + use settings::SettingsStore; + use std::{path::Path, sync::Arc}; + + #[gpui::test] + async fn test_project_symbols(cx: &mut TestAppContext) { + init_test(cx); + + let mut language = Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + None, + ); + let mut fake_servers = language + .set_fake_lsp_adapter(Arc::::default()) + .await; + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree("/dir", json!({ "test.rs": "" })).await; + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + project.update(cx, |project, _| project.languages().add(Arc::new(language))); + + let _buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/dir/test.rs", cx) + }) + .await + .unwrap(); + + // Set up fake language server to return fuzzy matches against + // a fixed set of symbol names. + let fake_symbols = [ + symbol("one", "/external"), + symbol("ton", "/dir/test.rs"), + symbol("uno", "/dir/test.rs"), + ]; + let fake_server = fake_servers.next().await.unwrap(); + fake_server.handle_request::( + move |params: lsp::WorkspaceSymbolParams, cx| { + let executor = cx.background_executor().clone(); + let fake_symbols = fake_symbols.clone(); + async move { + let candidates = fake_symbols + .iter() + .enumerate() + .map(|(id, symbol)| StringMatchCandidate::new(id, symbol.name.clone())) + .collect::>(); + let matches = if params.query.is_empty() { + Vec::new() + } else { + fuzzy::match_strings( + &candidates, + ¶ms.query, + true, + 100, + &Default::default(), + executor.clone(), + ) + .await + }; + + Ok(Some(lsp::WorkspaceSymbolResponse::Flat( + matches + .into_iter() + .map(|mat| fake_symbols[mat.candidate_id].clone()) + .collect(), + ))) + } + }, + ); + + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx)); + + // Create the project symbols view. + let symbols = cx.build_view(|cx| { + Picker::new( + ProjectSymbolsDelegate::new(workspace.downgrade(), project.clone()), + cx, + ) + }); + + // Spawn multiples updates before the first update completes, + // such that in the end, there are no matches. Testing for regression: + // https://github.com/zed-industries/zed/issues/861 + symbols.update(cx, |p, cx| { + p.update_matches("o".to_string(), cx); + p.update_matches("on".to_string(), cx); + p.update_matches("onex".to_string(), cx); + }); + + cx.run_until_parked(); + symbols.update(cx, |symbols, _| { + assert_eq!(symbols.delegate.matches.len(), 0); + }); + + // Spawn more updates such that in the end, there are matches. + symbols.update(cx, |p, cx| { + p.update_matches("one".to_string(), cx); + p.update_matches("on".to_string(), cx); + }); + + cx.run_until_parked(); + symbols.update(cx, |symbols, _| { + let delegate = &symbols.delegate; + assert_eq!(delegate.matches.len(), 2); + assert_eq!(delegate.matches[0].string, "ton"); + assert_eq!(delegate.matches[1].string, "one"); + }); + + // Spawn more updates such that in the end, there are again no matches. + symbols.update(cx, |p, cx| { + p.update_matches("o".to_string(), cx); + p.update_matches("".to_string(), cx); + }); + + cx.run_until_parked(); + symbols.update(cx, |symbols, _| { + assert_eq!(symbols.delegate.matches.len(), 0); + }); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let store = SettingsStore::test(cx); + cx.set_global(store); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + }); + } + + fn symbol(name: &str, path: impl AsRef) -> lsp::SymbolInformation { + #[allow(deprecated)] + lsp::SymbolInformation { + name: name.to_string(), + kind: lsp::SymbolKind::FUNCTION, + tags: None, + deprecated: None, + container_name: None, + location: lsp::Location::new( + lsp::Url::from_file_path(path.as_ref()).unwrap(), + lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 0)), + ), + } + } +} diff --git a/crates/ui2/src/components/button/button_like.rs b/crates/ui2/src/components/button/button_like.rs index 7203b3494f..8255490476 100644 --- a/crates/ui2/src/components/button/button_like.rs +++ b/crates/ui2/src/components/button/button_like.rs @@ -2,7 +2,6 @@ use gpui::{relative, DefiniteLength}; use gpui::{rems, transparent_black, AnyElement, AnyView, ClickEvent, Div, Hsla, Rems, Stateful}; use smallvec::SmallVec; -use crate::h_stack; use crate::prelude::*; pub trait ButtonCommon: Clickable + Disableable { @@ -250,6 +249,7 @@ impl ButtonSize { /// This is also used to build the prebuilt buttons. #[derive(IntoElement)] pub struct ButtonLike { + base: Div, id: ElementId, pub(super) style: ButtonStyle, pub(super) disabled: bool, @@ -264,6 +264,7 @@ pub struct ButtonLike { impl ButtonLike { pub fn new(id: impl Into) -> Self { Self { + base: div(), id: id.into(), style: ButtonStyle::default(), disabled: false, @@ -331,6 +332,13 @@ impl ButtonCommon for ButtonLike { } } +impl VisibleOnHover for ButtonLike { + fn visible_on_hover(mut self, group_name: impl Into) -> Self { + self.base = self.base.visible_on_hover(group_name); + self + } +} + impl ParentElement for ButtonLike { fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { &mut self.children @@ -341,7 +349,8 @@ impl RenderOnce for ButtonLike { type Rendered = Stateful
; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - h_stack() + self.base + .h_flex() .id(self.id.clone()) .group("") .flex_none() diff --git a/crates/ui2/src/components/button/icon_button.rs b/crates/ui2/src/components/button/icon_button.rs index f49120e90c..3a53bb6cb0 100644 --- a/crates/ui2/src/components/button/icon_button.rs +++ b/crates/ui2/src/components/button/icon_button.rs @@ -98,6 +98,13 @@ impl ButtonCommon for IconButton { } } +impl VisibleOnHover for IconButton { + fn visible_on_hover(mut self, group_name: impl Into) -> Self { + self.base = self.base.visible_on_hover(group_name); + self + } +} + impl RenderOnce for IconButton { type Rendered = ButtonLike; diff --git a/crates/ui2/src/components/context_menu.rs b/crates/ui2/src/components/context_menu.rs index 3e54298514..250272b198 100644 --- a/crates/ui2/src/components/context_menu.rs +++ b/crates/ui2/src/components/context_menu.rs @@ -255,6 +255,9 @@ impl Render for ContextMenu { }; ListItem::new(label.clone()) + .inset(true) + .selected(Some(ix) == self.selected_index) + .on_click(move |_, cx| handler(cx)) .child( h_stack() .w_full() @@ -265,8 +268,6 @@ impl Render for ContextMenu { .map(|binding| div().ml_1().child(binding)) })), ) - .selected(Some(ix) == self.selected_index) - .on_click(move |_, cx| handler(cx)) .into_any_element() } }, diff --git a/crates/ui2/src/components/disclosure.rs b/crates/ui2/src/components/disclosure.rs index 7d9a69bb3a..7d0f911d96 100644 --- a/crates/ui2/src/components/disclosure.rs +++ b/crates/ui2/src/components/disclosure.rs @@ -1,6 +1,7 @@ -use crate::{prelude::*, Color, Icon, IconButton, IconSize}; use gpui::ClickEvent; +use crate::{prelude::*, Color, Icon, IconButton, IconSize}; + #[derive(IntoElement)] pub struct Disclosure { is_open: bool, diff --git a/crates/ui2/src/components/list/list_header.rs b/crates/ui2/src/components/list/list_header.rs index 933a1a95d7..d082574a92 100644 --- a/crates/ui2/src/components/list/list_header.rs +++ b/crates/ui2/src/components/list/list_header.rs @@ -1,12 +1,18 @@ -use crate::{h_stack, prelude::*, Disclosure, Icon, IconElement, IconSize, Label}; +use crate::{h_stack, prelude::*, Disclosure, Label}; use gpui::{AnyElement, ClickEvent, Div}; -use smallvec::SmallVec; #[derive(IntoElement)] pub struct ListHeader { + /// The label of the header. label: SharedString, - left_icon: Option, - meta: SmallVec<[AnyElement; 2]>, + /// A slot for content that appears before the label, like an icon or avatar. + start_slot: Option, + /// A slot for content that appears after the label, usually on the other side of the header. + /// This might be a button, a disclosure arrow, a face pile, etc. + end_slot: Option, + /// A slot for content that appears on hover after the label + /// It will obscure the `end_slot` when visible. + end_hover_slot: Option, toggle: Option, on_toggle: Option>, inset: bool, @@ -17,8 +23,9 @@ impl ListHeader { pub fn new(label: impl Into) -> Self { Self { label: label.into(), - left_icon: None, - meta: SmallVec::new(), + start_slot: None, + end_slot: None, + end_hover_slot: None, inset: false, toggle: None, on_toggle: None, @@ -39,13 +46,23 @@ impl ListHeader { self } - pub fn left_icon(mut self, left_icon: impl Into>) -> Self { - self.left_icon = left_icon.into(); + pub fn start_slot(mut self, start_slot: impl Into>) -> Self { + self.start_slot = start_slot.into().map(IntoElement::into_any_element); self } - pub fn meta(mut self, meta: impl IntoElement) -> Self { - self.meta.push(meta.into_any_element()); + pub fn end_slot(mut self, end_slot: impl Into>) -> Self { + self.end_slot = end_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn end_hover_slot(mut self, end_hover_slot: impl Into>) -> Self { + self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element); + self + } + + pub fn inset(mut self, inset: bool) -> Self { + self.inset = inset; self } } @@ -61,9 +78,9 @@ impl RenderOnce for ListHeader { type Rendered = Div; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - h_stack().w_full().relative().child( + h_stack().w_full().relative().group("list_header").child( div() - .h_5() + .h_7() .when(self.inset, |this| this.px_2()) .when(self.selected, |this| { this.bg(cx.theme().colors().ghost_element_selected) @@ -77,24 +94,29 @@ impl RenderOnce for ListHeader { .child( h_stack() .gap_1() + .children( + self.toggle + .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), + ) .child( div() .flex() .gap_1() .items_center() - .children(self.left_icon.map(|i| { - IconElement::new(i) - .color(Color::Muted) - .size(IconSize::Small) - })) + .children(self.start_slot) .child(Label::new(self.label.clone()).color(Color::Muted)), - ) - .children( - self.toggle - .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), ), ) - .child(h_stack().gap_2().items_center().children(self.meta)), + .child(h_stack().children(self.end_slot)) + .when_some(self.end_hover_slot, |this, end_hover_slot| { + this.child( + div() + .absolute() + .right_0() + .visible_on_hover("list_header") + .child(end_hover_slot), + ) + }), ) } } diff --git a/crates/ui2/src/components/list/list_item.rs b/crates/ui2/src/components/list/list_item.rs index 28a8b8cecb..481d96d242 100644 --- a/crates/ui2/src/components/list/list_item.rs +++ b/crates/ui2/src/components/list/list_item.rs @@ -1,19 +1,24 @@ -use crate::{prelude::*, Avatar, Disclosure, Icon, IconElement, IconSize}; use gpui::{ - px, AnyElement, AnyView, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels, - Stateful, + px, AnyElement, AnyView, ClickEvent, Div, MouseButton, MouseDownEvent, Pixels, Stateful, }; use smallvec::SmallVec; +use crate::{prelude::*, Disclosure}; + #[derive(IntoElement)] pub struct ListItem { id: ElementId, selected: bool, - // TODO: Reintroduce this - // disclosure_control_style: DisclosureControlVisibility, indent_level: usize, indent_step_size: Pixels, - left_slot: Option, + /// A slot for content that appears before the children, like an icon or avatar. + start_slot: Option, + /// A slot for content that appears after the children, usually on the other side of the header. + /// This might be a button, a disclosure arrow, a face pile, etc. + end_slot: Option, + /// A slot for content that appears on hover after the children + /// It will obscure the `end_slot` when visible. + end_hover_slot: Option, toggle: Option, inset: bool, on_click: Option>, @@ -30,7 +35,9 @@ impl ListItem { selected: false, indent_level: 0, indent_step_size: px(12.), - left_slot: None, + start_slot: None, + end_slot: None, + end_hover_slot: None, toggle: None, inset: false, on_click: None, @@ -87,23 +94,18 @@ impl ListItem { self } - pub fn left_child(mut self, left_content: impl IntoElement) -> Self { - self.left_slot = Some(left_content.into_any_element()); + pub fn start_slot(mut self, start_slot: impl Into>) -> Self { + self.start_slot = start_slot.into().map(IntoElement::into_any_element); self } - pub fn left_icon(mut self, left_icon: Icon) -> Self { - self.left_slot = Some( - IconElement::new(left_icon) - .size(IconSize::Small) - .color(Color::Muted) - .into_any_element(), - ); + pub fn end_slot(mut self, end_slot: impl Into>) -> Self { + self.end_slot = end_slot.into().map(IntoElement::into_any_element); self } - pub fn left_avatar(mut self, left_avatar: impl Into) -> Self { - self.left_slot = Some(Avatar::new(left_avatar).into_any_element()); + pub fn end_hover_slot(mut self, end_hover_slot: impl Into>) -> Self { + self.end_hover_slot = end_hover_slot.into().map(IntoElement::into_any_element); self } } @@ -125,49 +127,102 @@ impl RenderOnce for ListItem { type Rendered = Stateful
; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - div() + h_stack() .id(self.id) + .w_full() .relative() - // TODO: Add focus state - // .when(self.state == InteractionState::Focused, |this| { - // this.border() - // .border_color(cx.theme().colors().border_focused) - // }) - .when(self.inset, |this| this.rounded_md()) - .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) - .active(|style| style.bg(cx.theme().colors().ghost_element_active)) - .when(self.selected, |this| { - this.bg(cx.theme().colors().ghost_element_selected) + // When an item is inset draw the indent spacing outside of the item + .when(self.inset, |this| { + this.ml(self.indent_level as f32 * self.indent_step_size) + .px_1() }) - .when_some(self.on_click, |this, on_click| { - this.cursor_pointer().on_click(move |event, cx| { - // HACK: GPUI currently fires `on_click` with any mouse button, - // but we only care about the left button. - if event.down.button == MouseButton::Left { - (on_click)(event, cx) - } - }) + .when(!self.inset, |this| { + this + // TODO: Add focus state + // .when(self.state == InteractionState::Focused, |this| { + // this.border() + // .border_color(cx.theme().colors().border_focused) + // }) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) }) - .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { - this.on_mouse_down(MouseButton::Right, move |event, cx| { - (on_mouse_down)(event, cx) - }) - }) - .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) .child( - div() - .when(self.inset, |this| this.px_2()) - .ml(self.indent_level as f32 * self.indent_step_size) - .flex() - .gap_1() - .items_center() + h_stack() + .id("inner_list_item") + .w_full() .relative() - .children( - self.toggle - .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), + .gap_1() + .px_2() + .group("list_item") + .when(self.inset, |this| { + this + // TODO: Add focus state + // .when(self.state == InteractionState::Focused, |this| { + // this.border() + // .border_color(cx.theme().colors().border_focused) + // }) + .hover(|style| style.bg(cx.theme().colors().ghost_element_hover)) + .active(|style| style.bg(cx.theme().colors().ghost_element_active)) + .when(self.selected, |this| { + this.bg(cx.theme().colors().ghost_element_selected) + }) + }) + .when_some(self.on_click, |this, on_click| { + this.cursor_pointer().on_click(on_click) + }) + .when_some(self.on_secondary_mouse_down, |this, on_mouse_down| { + this.on_mouse_down(MouseButton::Right, move |event, cx| { + (on_mouse_down)(event, cx) + }) + }) + .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) + .map(|this| { + if self.inset { + this.rounded_md() + } else { + // When an item is not inset draw the indent spacing inside of the item + this.ml(self.indent_level as f32 * self.indent_step_size) + } + }) + .children(self.toggle.map(|is_open| { + div() + .flex() + .absolute() + .left(rems(-1.)) + .visible_on_hover("") + .child(Disclosure::new(is_open).on_toggle(self.on_toggle)) + })) + .child( + h_stack() + .flex_1() + .gap_1() + .children(self.start_slot) + .children(self.children), ) - .children(self.left_slot) - .children(self.children), + .when_some(self.end_slot, |this, end_slot| { + this.justify_between().child( + h_stack() + .when(self.end_hover_slot.is_some(), |this| { + this.visible() + .group_hover("list_item", |this| this.invisible()) + }) + .child(end_slot), + ) + }) + .when_some(self.end_hover_slot, |this, end_hover_slot| { + this.child( + h_stack() + .h_full() + .absolute() + .right_2() + .top_0() + .visible_on_hover("list_item") + .child(end_hover_slot), + ) + }), ) } } diff --git a/crates/ui2/src/components/list/list_separator.rs b/crates/ui2/src/components/list/list_separator.rs index 0398a110e9..346b13ddaa 100644 --- a/crates/ui2/src/components/list/list_separator.rs +++ b/crates/ui2/src/components/list/list_separator.rs @@ -9,6 +9,10 @@ impl RenderOnce for ListSeparator { type Rendered = Div; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - div().h_px().w_full().bg(cx.theme().colors().border_variant) + div() + .h_px() + .w_full() + .my_1() + .bg(cx.theme().colors().border_variant) } } diff --git a/crates/ui2/src/components/list/list_sub_header.rs b/crates/ui2/src/components/list/list_sub_header.rs index 17f07b7b0b..07a99dabe5 100644 --- a/crates/ui2/src/components/list/list_sub_header.rs +++ b/crates/ui2/src/components/list/list_sub_header.rs @@ -6,7 +6,7 @@ use crate::{h_stack, Icon, IconElement, IconSize, Label}; #[derive(IntoElement)] pub struct ListSubHeader { label: SharedString, - left_icon: Option, + start_slot: Option, inset: bool, } @@ -14,13 +14,13 @@ impl ListSubHeader { pub fn new(label: impl Into) -> Self { Self { label: label.into(), - left_icon: None, + start_slot: None, inset: false, } } pub fn left_icon(mut self, left_icon: Option) -> Self { - self.left_icon = left_icon; + self.start_slot = left_icon; self } } @@ -44,7 +44,7 @@ impl RenderOnce for ListSubHeader { .flex() .gap_1() .items_center() - .children(self.left_icon.map(|i| { + .children(self.start_slot.map(|i| { IconElement::new(i) .color(Color::Muted) .size(IconSize::Small) diff --git a/crates/ui2/src/components/stories/list_header.rs b/crates/ui2/src/components/stories/list_header.rs index 056eaa2762..3c80afdde3 100644 --- a/crates/ui2/src/components/stories/list_header.rs +++ b/crates/ui2/src/components/stories/list_header.rs @@ -15,19 +15,19 @@ impl Render for ListHeaderStory { .child(Story::label("Default")) .child(ListHeader::new("Section 1")) .child(Story::label("With left icon")) - .child(ListHeader::new("Section 2").left_icon(Icon::Bell)) + .child(ListHeader::new("Section 2").start_slot(IconElement::new(Icon::Bell))) .child(Story::label("With left icon and meta")) .child( ListHeader::new("Section 3") - .left_icon(Icon::BellOff) - .meta(IconButton::new("action_1", Icon::Bolt)), + .start_slot(IconElement::new(Icon::BellOff)) + .end_slot(IconButton::new("action_1", Icon::Bolt)), ) .child(Story::label("With multiple meta")) .child( ListHeader::new("Section 4") - .meta(IconButton::new("action_1", Icon::Bolt)) - .meta(IconButton::new("action_2", Icon::ExclamationTriangle)) - .meta(IconButton::new("action_3", Icon::Plus)), + .end_slot(IconButton::new("action_1", Icon::Bolt)) + .end_slot(IconButton::new("action_2", Icon::ExclamationTriangle)) + .end_slot(IconButton::new("action_3", Icon::Plus)), ) } } diff --git a/crates/ui2/src/components/stories/list_item.rs b/crates/ui2/src/components/stories/list_item.rs index 91e95348fd..b070be663e 100644 --- a/crates/ui2/src/components/stories/list_item.rs +++ b/crates/ui2/src/components/stories/list_item.rs @@ -1,7 +1,7 @@ use gpui::{Div, Render}; use story::Story; -use crate::prelude::*; +use crate::{prelude::*, Avatar}; use crate::{Icon, ListItem}; pub struct ListItemStory; @@ -9,24 +9,80 @@ pub struct ListItemStory; impl Render for ListItemStory { type Element = Div; - fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { Story::container() + .bg(cx.theme().colors().background) .child(Story::title_for::()) .child(Story::label("Default")) .child(ListItem::new("hello_world").child("Hello, world!")) - .child(Story::label("With left icon")) + .child(Story::label("Inset")) .child( - ListItem::new("with_left_icon") + ListItem::new("inset_list_item") + .inset(true) + .start_slot( + IconElement::new(Icon::Bell) + .size(IconSize::Small) + .color(Color::Muted), + ) .child("Hello, world!") - .left_icon(Icon::Bell), + .end_slot( + IconElement::new(Icon::Bell) + .size(IconSize::Small) + .color(Color::Muted), + ), ) - .child(Story::label("With left avatar")) + .child(Story::label("With start slot icon")) + .child( + ListItem::new("with start slot_icon") + .child("Hello, world!") + .start_slot( + IconElement::new(Icon::Bell) + .size(IconSize::Small) + .color(Color::Muted), + ), + ) + .child(Story::label("With start slot avatar")) + .child( + ListItem::new("with_start slot avatar") + .child("Hello, world!") + .start_slot(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1714999?v=4", + ))), + ) + .child(Story::label("With end slot")) .child( ListItem::new("with_left_avatar") .child("Hello, world!") - .left_avatar(SharedString::from( + .end_slot(Avatar::new(SharedString::from( "https://avatars.githubusercontent.com/u/1714999?v=4", - )), + ))), + ) + .child(Story::label("With end hover slot")) + .child( + ListItem::new("with_end_hover_slot") + .child("Hello, world!") + .end_slot( + h_stack() + .gap_2() + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))) + .child(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1789?v=4", + ))), + ) + .end_hover_slot(Avatar::new(SharedString::from( + "https://avatars.githubusercontent.com/u/1714999?v=4", + ))), ) .child(Story::label("With `on_click`")) .child( diff --git a/crates/ui2/src/components/tab.rs b/crates/ui2/src/components/tab.rs index be1ce8dd12..8114a322e3 100644 --- a/crates/ui2/src/components/tab.rs +++ b/crates/ui2/src/components/tab.rs @@ -158,7 +158,6 @@ impl RenderOnce for Tab { ) .child( h_stack() - .invisible() .w_3() .h_3() .justify_center() @@ -167,7 +166,7 @@ impl RenderOnce for Tab { TabCloseSide::Start => this.left_1(), TabCloseSide::End => this.right_1(), }) - .group_hover("", |style| style.visible()) + .visible_on_hover("") .children(self.end_slot), ) .children(self.children), diff --git a/crates/ui2/src/prelude.rs b/crates/ui2/src/prelude.rs index 076d34644c..a0a0adeb1d 100644 --- a/crates/ui2/src/prelude.rs +++ b/crates/ui2/src/prelude.rs @@ -9,6 +9,7 @@ pub use crate::clickable::*; pub use crate::disableable::*; pub use crate::fixed::*; pub use crate::selectable::*; +pub use crate::visible_on_hover::*; pub use crate::{h_stack, v_stack}; pub use crate::{Button, ButtonSize, ButtonStyle, IconButton}; pub use crate::{ButtonCommon, Color, StyledExt}; diff --git a/crates/ui2/src/styled_ext.rs b/crates/ui2/src/styled_ext.rs index ed81c2cd0a..3358968c72 100644 --- a/crates/ui2/src/styled_ext.rs +++ b/crates/ui2/src/styled_ext.rs @@ -118,16 +118,26 @@ pub trait StyledExt: Styled + Sized { elevated(self, cx, ElevationIndex::ModalSurface) } + /// The theme's primary border color. + fn border_primary(self, cx: &mut WindowContext) -> Self { + self.border_color(cx.theme().colors().border) + } + + /// The theme's secondary or muted border color. + fn border_muted(self, cx: &mut WindowContext) -> Self { + self.border_color(cx.theme().colors().border_variant) + } + fn debug_bg_red(self) -> Self { - self.bg(gpui::red()) + self.bg(hsla(0. / 360., 1., 0.5, 1.)) } fn debug_bg_green(self) -> Self { - self.bg(gpui::green()) + self.bg(hsla(120. / 360., 1., 0.5, 1.)) } fn debug_bg_blue(self) -> Self { - self.bg(gpui::blue()) + self.bg(hsla(240. / 360., 1., 0.5, 1.)) } fn debug_bg_yellow(self) -> Self { diff --git a/crates/ui2/src/ui2.rs b/crates/ui2/src/ui2.rs index 6c5669741b..5c79199100 100644 --- a/crates/ui2/src/ui2.rs +++ b/crates/ui2/src/ui2.rs @@ -21,6 +21,7 @@ mod selectable; mod styled_ext; mod styles; pub mod utils; +mod visible_on_hover; pub use clickable::*; pub use components::*; @@ -30,3 +31,4 @@ pub use prelude::*; pub use selectable::*; pub use styled_ext::*; pub use styles::*; +pub use visible_on_hover::*; diff --git a/crates/ui2/src/visible_on_hover.rs b/crates/ui2/src/visible_on_hover.rs new file mode 100644 index 0000000000..aefa7ac10c --- /dev/null +++ b/crates/ui2/src/visible_on_hover.rs @@ -0,0 +1,15 @@ +use gpui::{InteractiveElement, SharedString, Styled}; + +pub trait VisibleOnHover { + /// Sets the element to only be visible when the specified group is hovered. + /// + /// Pass `""` as the `group_name` to use the global group. + fn visible_on_hover(self, group_name: impl Into) -> Self; +} + +impl VisibleOnHover for E { + fn visible_on_hover(self, group_name: impl Into) -> Self { + self.invisible() + .group_hover(group_name, |style| style.visible()) + } +} diff --git a/crates/workspace2/src/pane.rs b/crates/workspace2/src/pane.rs index bcbadc4e53..a55469fbad 100644 --- a/crates/workspace2/src/pane.rs +++ b/crates/workspace2/src/pane.rs @@ -1759,6 +1759,34 @@ impl Pane { }) .log_err(); } + + fn handle_split_tab_drop( + &mut self, + dragged_tab: &View, + split_direction: SplitDirection, + cx: &mut ViewContext<'_, Pane>, + ) { + let dragged_tab = dragged_tab.read(cx); + let item_id = dragged_tab.item_id; + let from_pane = dragged_tab.pane.clone(); + let to_pane = cx.view().clone(); + self.workspace + .update(cx, |workspace, cx| { + cx.defer(move |workspace, cx| { + let item = from_pane + .read(cx) + .items() + .find(|item| item.item_id() == item_id) + .map(|item| item.boxed_clone()); + if let Some(item) = item { + if let Some(item) = item.clone_on_split(workspace.database_id(), cx) { + workspace.split_item(split_direction, item, cx); + } + } + }); + }) + .log_err(); + } } impl FocusableView for Pane { @@ -1852,7 +1880,54 @@ impl Render for Pane { .child(self.render_tab_bar(cx)) .child(self.toolbar.clone()) .child(if let Some(item) = self.active_item() { - div().flex().flex_1().child(item.to_any()) + let mut drag_target_color = cx.theme().colors().text; + drag_target_color.a = 0.5; + + div() + .flex() + .flex_1() + .relative() + .child(item.to_any()) + .child( + div() + .absolute() + .full() + .z_index(1) + .drag_over::(|style| style.bg(drag_target_color)) + .on_drop(cx.listener( + move |this, dragged_tab: &View, cx| { + this.handle_tab_drop(dragged_tab, this.active_item_index(), cx) + }, + )), + ) + .children( + [ + (SplitDirection::Up, 2), + (SplitDirection::Down, 2), + (SplitDirection::Left, 3), + (SplitDirection::Right, 3), + ] + .into_iter() + .map(|(direction, z_index)| { + let div = div() + .absolute() + .z_index(z_index) + .invisible() + .bg(drag_target_color) + .drag_over::(|style| style.visible()) + .on_drop(cx.listener( + move |this, dragged_tab: &View, cx| { + this.handle_split_tab_drop(dragged_tab, direction, cx) + }, + )); + match direction { + SplitDirection::Up => div.top_0().left_0().right_0().h_32(), + SplitDirection::Down => div.left_0().bottom_0().right_0().h_32(), + SplitDirection::Left => div.top_0().left_0().bottom_0().w_32(), + SplitDirection::Right => div.top_0().bottom_0().right_0().w_32(), + } + }), + ) } else { h_stack() .items_center() diff --git a/crates/zed2/Cargo.toml b/crates/zed2/Cargo.toml index 859afee4f7..6646eb5ffc 100644 --- a/crates/zed2/Cargo.toml +++ b/crates/zed2/Cargo.toml @@ -55,7 +55,7 @@ outline = { package = "outline2", path = "../outline2" } # plugin_runtime = { path = "../plugin_runtime",optional = true } project = { package = "project2", path = "../project2" } project_panel = { package = "project_panel2", path = "../project_panel2" } -# project_symbols = { path = "../project_symbols" } +project_symbols = { package = "project_symbols2", path = "../project_symbols2" } quick_action_bar = { package = "quick_action_bar2", path = "../quick_action_bar2" } recent_projects = { package = "recent_projects2", path = "../recent_projects2" } rope = { package = "rope2", path = "../rope2"} diff --git a/crates/zed2/src/main.rs b/crates/zed2/src/main.rs index bbb8382cb2..ca8cd7a2a2 100644 --- a/crates/zed2/src/main.rs +++ b/crates/zed2/src/main.rs @@ -205,7 +205,7 @@ fn main() { go_to_line::init(cx); file_finder::init(cx); outline::init(cx); - // project_symbols::init(cx); + project_symbols::init(cx); project_panel::init(Assets, cx); channel::init(&client, user_store.clone(), cx); // diagnostics::init(cx); diff --git a/docker-compose.sql b/docker-compose.sql new file mode 100644 index 0000000000..9cbd0bf0d1 --- /dev/null +++ b/docker-compose.sql @@ -0,0 +1 @@ +create database zed; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000000..78faf21a60 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,16 @@ +version: "3.7" + +services: + postgres: + image: postgres:15 + container_name: zed_postgres + ports: + - 5432:5432 + environment: + POSTGRES_HOST_AUTH_METHOD: trust + volumes: + - postgres_data:/var/lib/postgresql/data + - ./docker-compose.sql:/docker-entrypoint-initdb.d/init.sql + +volumes: + postgres_data: diff --git a/script/storybook b/script/storybook new file mode 100755 index 0000000000..bcabdef0af --- /dev/null +++ b/script/storybook @@ -0,0 +1,15 @@ +#!/bin/bash + +# This script takes a single text input and replaces 'list_item' with the input in a cargo run command + +# Check if an argument is provided +if [ "$#" -ne 1 ]; then + echo "Usage: $0 " + exit 1 +fi + +# Assign the argument to a variable +COMPONENT_NAME="$1" + +# Run the cargo command with the provided component name +cargo run -p storybook2 -- components/"$COMPONENT_NAME"