diff --git a/Cargo.lock b/Cargo.lock index fcaa533f732436b0bff335cfa223338e30a49865..c2efbdd863d43b4aaf8aee4a95c4c2fac085bace 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1095,6 +1095,23 @@ dependencies = [ "workspace", ] +[[package]] +name = "breadcrumbs2" +version = "0.1.0" +dependencies = [ + "collections", + "editor2", + "gpui2", + "itertools 0.10.5", + "language2", + "project2", + "search2", + "settings2", + "theme2", + "ui2", + "workspace2", +] + [[package]] name = "bromberg_sl2" version = "0.6.0" @@ -1205,7 +1222,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-broadcast", - "async-trait", "audio2", "client2", "collections", @@ -1225,9 +1241,7 @@ dependencies = [ "serde_json", "settings2", "smallvec", - "ui2", "util", - "workspace2", ] [[package]] @@ -1688,7 +1702,7 @@ dependencies = [ [[package]] name = "collab" -version = "0.29.0" +version = "0.29.1" dependencies = [ "anyhow", "async-trait", @@ -2098,7 +2112,7 @@ dependencies = [ "lsp2", "node_runtime", "parking_lot 0.11.2", - "rpc", + "rpc2", "serde", "serde_derive", "settings2", @@ -2126,6 +2140,25 @@ dependencies = [ "workspace", ] +[[package]] +name = "copilot_button2" +version = "0.1.0" +dependencies = [ + "anyhow", + "copilot2", + "editor2", + "fs2", + "futures 0.3.28", + "gpui2", + "language2", + "settings2", + "smol", + "theme2", + "util", + "workspace2", + "zed_actions2", +] + [[package]] name = "core-foundation" version = "0.9.3" @@ -4805,6 +4838,24 @@ dependencies = [ "workspace", ] +[[package]] +name = "language_selector2" +version = "0.1.0" +dependencies = [ + "anyhow", + "editor2", + "fuzzy2", + "gpui2", + "language2", + "picker2", + "project2", + "settings2", + "theme2", + "ui2", + "util", + "workspace2", +] + [[package]] name = "language_tools" version = "0.1.0" @@ -6141,6 +6192,26 @@ dependencies = [ "workspace", ] +[[package]] +name = "outline2" +version = "0.1.0" +dependencies = [ + "editor2", + "fuzzy2", + "gpui2", + "language2", + "ordered-float 2.10.0", + "picker2", + "postage", + "settings2", + "smol", + "text2", + "theme2", + "ui2", + "util", + "workspace2", +] + [[package]] name = "overload" version = "0.1.1" @@ -8192,6 +8263,57 @@ dependencies = [ "workspace", ] +[[package]] +name = "semantic_index2" +version = "0.1.0" +dependencies = [ + "ai2", + "anyhow", + "async-trait", + "client2", + "collections", + "ctor", + "env_logger 0.9.3", + "futures 0.3.28", + "globset", + "gpui2", + "language2", + "lazy_static", + "log", + "ndarray", + "node_runtime", + "ordered-float 2.10.0", + "parking_lot 0.11.2", + "postage", + "pretty_assertions", + "project2", + "rand 0.8.5", + "rpc2", + "rusqlite", + "rust-embed", + "schemars", + "serde", + "serde_json", + "settings2", + "sha1", + "smol", + "tempdir", + "tiktoken-rs", + "tree-sitter", + "tree-sitter-cpp", + "tree-sitter-elixir", + "tree-sitter-json 0.20.0", + "tree-sitter-lua", + "tree-sitter-php", + "tree-sitter-ruby", + "tree-sitter-rust", + "tree-sitter-toml", + "tree-sitter-typescript", + "unindent", + "util", + "workspace2", +] + [[package]] name = "semver" version = "1.0.18" @@ -11491,7 +11613,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-recursion 1.0.5", - "async-trait", "bincode", "call2", "client2", @@ -11757,6 +11878,7 @@ dependencies = [ "audio2", "auto_update2", "backtrace", + "breadcrumbs2", "call2", "channel2", "chrono", @@ -11766,6 +11888,7 @@ dependencies = [ "collections", "command_palette2", "copilot2", + "copilot_button2", "ctor", "db2", "diagnostics2", @@ -11786,6 +11909,7 @@ dependencies = [ "isahc", "journal2", "language2", + "language_selector2", "lazy_static", "libc", "log", @@ -11793,6 +11917,7 @@ dependencies = [ "menu2", "node_runtime", "num_cpus", + "outline2", "parking_lot 0.11.2", "postage", "project2", diff --git a/Cargo.toml b/Cargo.toml index 5dc30ca40b1687200ce69dded3fdf8e54ca03b5f..610a4dc11e03cc24c86db033d7b5b95c25ab64ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/auto_update", "crates/auto_update2", "crates/breadcrumbs", + "crates/breadcrumbs2", "crates/call", "crates/call2", "crates/channel", @@ -60,6 +61,7 @@ members = [ "crates/language", "crates/language2", "crates/language_selector", + "crates/language_selector2", "crates/language_tools", "crates/live_kit_client", "crates/live_kit_server", @@ -74,6 +76,7 @@ members = [ "crates/notifications", "crates/notifications2", "crates/outline", + "crates/outline2", "crates/picker", "crates/picker2", "crates/plugin", @@ -92,6 +95,8 @@ members = [ "crates/rpc2", "crates/search", "crates/search2", + "crates/semantic_index", + "crates/semantic_index2", "crates/settings", "crates/settings2", "crates/snippet", @@ -111,7 +116,6 @@ members = [ "crates/theme_selector2", "crates/ui2", "crates/util", - "crates/semantic_index", "crates/story", "crates/vim", "crates/vcs_menu", diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg new file mode 100644 index 0000000000000000000000000000000000000000..8b755e806395253b093a9d11b2bf8b775c0f2d35 --- /dev/null +++ b/assets/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index ef6a655bdcead3cd64f29e9aa25b90d0d4e4d626..2a8d19f8829039d759c92e79b6acebe79e55b143 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -530,12 +530,17 @@ "alt-cmd-shift-c": "project_panel::CopyRelativePath", "f2": "project_panel::Rename", "enter": "project_panel::Rename", - "space": "project_panel::Open", "backspace": "project_panel::Delete", "alt-cmd-r": "project_panel::RevealInFinder", "alt-shift-f": "project_panel::NewSearchInDirectory" } }, + { + "context": "ProjectPanel && not_editing", + "bindings": { + "space": "project_panel::Open" + } + }, { "context": "CollabPanel && not_editing", "bindings": { diff --git a/crates/ai2/src/auth.rs b/crates/ai2/src/auth.rs index baa1fe7b83299ef66db4ecf0d0403b1ac92dc5bc..1ea49bd615999a7f0318d3e205d3f86cee9c64a8 100644 --- a/crates/ai2/src/auth.rs +++ b/crates/ai2/src/auth.rs @@ -7,7 +7,7 @@ pub enum ProviderCredential { NotNeeded, } -pub trait CredentialProvider { +pub trait CredentialProvider: Send + Sync { fn has_credentials(&self) -> bool; fn retrieve_credentials(&self, cx: &mut AppContext) -> ProviderCredential; fn save_credentials(&self, cx: &mut AppContext, credential: ProviderCredential); diff --git a/crates/ai2/src/providers/open_ai/embedding.rs b/crates/ai2/src/providers/open_ai/embedding.rs index 8f62c8dc0d440675146cdcae6f1e4a803de15cec..d5fe4e8c5842709c587b9898862e0a2461461ed2 100644 --- a/crates/ai2/src/providers/open_ai/embedding.rs +++ b/crates/ai2/src/providers/open_ai/embedding.rs @@ -35,7 +35,7 @@ pub struct OpenAIEmbeddingProvider { model: OpenAILanguageModel, credential: Arc>, pub client: Arc, - pub executor: Arc, + pub executor: BackgroundExecutor, rate_limit_count_rx: watch::Receiver>, rate_limit_count_tx: Arc>>>, } @@ -66,7 +66,7 @@ struct OpenAIEmbeddingUsage { } impl OpenAIEmbeddingProvider { - pub fn new(client: Arc, executor: Arc) -> Self { + pub fn new(client: Arc, executor: BackgroundExecutor) -> Self { let (rate_limit_count_tx, rate_limit_count_rx) = watch::channel_with(None); let rate_limit_count_tx = Arc::new(Mutex::new(rate_limit_count_tx)); diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index cac8bf6c54f8b335df756c13b08fb056b6378dd5..e472e8c8dfc7f3f7f2dc072825efea4fd89b41de 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1218,6 +1218,31 @@ impl View for AssistantPanel { let style = &theme.assistant; if let Some(api_key_editor) = self.api_key_editor.as_ref() { Flex::column() + .with_child( + Text::new( + "To use the assistant panel or inline assistant, you need to add your OpenAI api key.", + style.api_key_prompt.text.clone(), + ), + ) + .with_child( + Text::new( + " - Having a subscription for another service like GitHub Copilot won't work.", + style.api_key_prompt.text.clone(), + ), + ) + .with_child( + Text::new( + " - You can create a api key at: platform.openai.com/api-keys", + style.api_key_prompt.text.clone(), + ), + ) + .with_child( + Text::new( + " ", + style.api_key_prompt.text.clone(), + ) + .aligned(), + ) .with_child( Text::new( "Paste your OpenAI API key and press Enter to use the assistant", @@ -1231,6 +1256,20 @@ impl View for AssistantPanel { .with_style(style.api_key_editor.container) .aligned(), ) + .with_child( + Text::new( + " ", + style.api_key_prompt.text.clone(), + ) + .aligned(), + ) + .with_child( + Text::new( + "Click on the Z button in the status bar to close this panel.", + style.api_key_prompt.text.clone(), + ) + .aligned(), + ) .contained() .with_style(style.api_key_prompt.container) .aligned() diff --git a/crates/auto_update2/src/auto_update.rs b/crates/auto_update2/src/auto_update.rs index d2eab15d09967a84c5c11004ec70419795e0bf01..72dbe32b5a6fbf53304896d9d5d2fbb64d44f8d3 100644 --- a/crates/auto_update2/src/auto_update.rs +++ b/crates/auto_update2/src/auto_update.rs @@ -102,7 +102,7 @@ pub fn init(http_client: Arc, server_url: String, cx: &mut AppCo }) .detach(); - if let Some(version) = *ZED_APP_VERSION { + if let Some(version) = ZED_APP_VERSION.or_else(|| cx.app_metadata().app_version) { let auto_updater = cx.build_model(|cx| { let updater = AutoUpdater::new(version, http_client, server_url); diff --git a/crates/breadcrumbs2/Cargo.toml b/crates/breadcrumbs2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..8555afe980b88093d2c4bab387bf90207f7f1c66 --- /dev/null +++ b/crates/breadcrumbs2/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "breadcrumbs2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/breadcrumbs.rs" +doctest = false + +[dependencies] +collections = { path = "../collections" } +editor = { package = "editor2", path = "../editor2" } +gpui = { package = "gpui2", path = "../gpui2" } +ui = { package = "ui2", path = "../ui2" } +language = { package = "language2", path = "../language2" } +project = { package = "project2", path = "../project2" } +search = { package = "search2", path = "../search2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +workspace = { package = "workspace2", path = "../workspace2" } +# outline = { path = "../outline" } +itertools = "0.10" + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } diff --git a/crates/breadcrumbs2/src/breadcrumbs.rs b/crates/breadcrumbs2/src/breadcrumbs.rs new file mode 100644 index 0000000000000000000000000000000000000000..75195a315930e2525ed1b01aea36f57e6d30b699 --- /dev/null +++ b/crates/breadcrumbs2/src/breadcrumbs.rs @@ -0,0 +1,204 @@ +use gpui::{ + Component, Element, EventEmitter, IntoElement, ParentElement, Render, StyledText, Subscription, + ViewContext, WeakView, +}; +use itertools::Itertools; +use theme::ActiveTheme; +use ui::{ButtonCommon, ButtonLike, ButtonStyle, Clickable, Disableable, Label}; +use workspace::{ + item::{ItemEvent, ItemHandle}, + ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, +}; + +pub enum Event { + UpdateLocation, +} + +pub struct Breadcrumbs { + pane_focused: bool, + active_item: Option>, + subscription: Option, + _workspace: WeakView, +} + +impl Breadcrumbs { + pub fn new(workspace: &Workspace) -> Self { + Self { + pane_focused: false, + active_item: Default::default(), + subscription: Default::default(), + _workspace: workspace.weak_handle(), + } + } +} + +impl EventEmitter for Breadcrumbs {} +impl EventEmitter for Breadcrumbs {} + +impl Render for Breadcrumbs { + type Element = Component; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let button = ButtonLike::new("breadcrumbs") + .style(ButtonStyle::Transparent) + .disabled(true); + + let active_item = match &self.active_item { + Some(active_item) => active_item, + None => return button.into_element(), + }; + let not_editor = active_item.downcast::().is_none(); + + let breadcrumbs = match active_item.breadcrumbs(cx.theme(), cx) { + Some(breadcrumbs) => breadcrumbs, + None => return button.into_element(), + } + .into_iter() + .map(|breadcrumb| { + StyledText::new(breadcrumb.text) + .with_highlights(&cx.text_style(), breadcrumb.highlights.unwrap_or_default()) + .into_any() + }); + + let button = button.children(Itertools::intersperse_with(breadcrumbs, || { + Label::new(" › ").into_any_element() + })); + + if not_editor || !self.pane_focused { + return button.into_element(); + } + + // let this = cx.view().downgrade(); + button + .style(ButtonStyle::Filled) + .disabled(false) + .on_click(move |_, _cx| { + todo!("outline::toggle"); + // this.update(cx, |this, cx| { + // if let Some(workspace) = this.workspace.upgrade() { + // workspace.update(cx, |_workspace, _cx| { + // outline::toggle(workspace, &Default::default(), cx) + // }) + // } + // }) + // .ok(); + }) + .into_element() + } +} + +// impl View for Breadcrumbs { +// fn ui_name() -> &'static str { +// "Breadcrumbs" +// } + +// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { +// let active_item = match &self.active_item { +// Some(active_item) => active_item, +// None => return Empty::new().into_any(), +// }; +// let not_editor = active_item.downcast::().is_none(); + +// let theme = theme::current(cx).clone(); +// let style = &theme.workspace.toolbar.breadcrumbs; + +// let breadcrumbs = match active_item.breadcrumbs(&theme, cx) { +// Some(breadcrumbs) => breadcrumbs, +// None => return Empty::new().into_any(), +// } +// .into_iter() +// .map(|breadcrumb| { +// Text::new( +// breadcrumb.text, +// theme.workspace.toolbar.breadcrumbs.default.text.clone(), +// ) +// .with_highlights(breadcrumb.highlights.unwrap_or_default()) +// .into_any() +// }); + +// let crumbs = Flex::row() +// .with_children(Itertools::intersperse_with(breadcrumbs, || { +// Label::new(" › ", style.default.text.clone()).into_any() +// })) +// .constrained() +// .with_height(theme.workspace.toolbar.breadcrumb_height) +// .contained(); + +// if not_editor || !self.pane_focused { +// return crumbs +// .with_style(style.default.container) +// .aligned() +// .left() +// .into_any(); +// } + +// MouseEventHandler::new::(0, cx, |state, _| { +// let style = style.style_for(state); +// crumbs.with_style(style.container) +// }) +// .on_click(MouseButton::Left, |_, this, cx| { +// if let Some(workspace) = this.workspace.upgrade(cx) { +// workspace.update(cx, |workspace, cx| { +// outline::toggle(workspace, &Default::default(), cx) +// }) +// } +// }) +// .with_tooltip::( +// 0, +// "Show symbol outline".to_owned(), +// Some(Box::new(outline::Toggle)), +// theme.tooltip.clone(), +// cx, +// ) +// .aligned() +// .left() +// .into_any() +// } +// } + +impl ToolbarItemView for Breadcrumbs { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) -> ToolbarItemLocation { + cx.notify(); + self.active_item = None; + if let Some(item) = active_pane_item { + let this = cx.view().downgrade(); + self.subscription = Some(item.subscribe_to_item_events( + cx, + Box::new(move |event, cx| { + if let ItemEvent::UpdateBreadcrumbs = event { + this.update(cx, |_, cx| { + cx.emit(Event::UpdateLocation); + cx.notify(); + }) + .ok(); + } + }), + )); + self.active_item = Some(item.boxed_clone()); + item.breadcrumb_location(cx) + } else { + ToolbarItemLocation::Hidden + } + } + + // fn location_for_event( + // &self, + // _: &Event, + // current_location: ToolbarItemLocation, + // cx: &AppContext, + // ) -> ToolbarItemLocation { + // if let Some(active_item) = self.active_item.as_ref() { + // active_item.breadcrumb_location(cx) + // } else { + // current_location + // } + // } + + fn pane_focus_update(&mut self, pane_focused: bool, _: &mut ViewContext) { + self.pane_focused = pane_focused; + } +} diff --git a/crates/call2/Cargo.toml b/crates/call2/Cargo.toml index 8dc37f68dd7bd9b91c1d1fab240448eb25119cbf..c2d95c8b52b2705763c537e71285f521aea3148d 100644 --- a/crates/call2/Cargo.toml +++ b/crates/call2/Cargo.toml @@ -31,9 +31,7 @@ media = { path = "../media" } project = { package = "project2", path = "../project2" } settings = { package = "settings2", path = "../settings2" } util = { path = "../util" } -ui = {package = "ui2", path = "../ui2"} -workspace = {package = "workspace2", path = "../workspace2"} -async-trait.workspace = true + anyhow.workspace = true async-broadcast = "0.4" futures.workspace = true diff --git a/crates/call2/src/call2.rs b/crates/call2/src/call2.rs index a93305772312cab3624f995e0dd49751554867e1..14cb28c32d6b932c8db9f2fa54b5c0125bbf8011 100644 --- a/crates/call2/src/call2.rs +++ b/crates/call2/src/call2.rs @@ -1,32 +1,25 @@ pub mod call_settings; pub mod participant; pub mod room; -mod shared_screen; use anyhow::{anyhow, Result}; -use async_trait::async_trait; use audio::Audio; use call_settings::CallSettings; -use client::{ - proto::{self, PeerId}, - Client, TelemetrySettings, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE, -}; +use client::{proto, Client, TelemetrySettings, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; use collections::HashSet; use futures::{channel::oneshot, future::Shared, Future, FutureExt}; use gpui::{ - AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, PromptLevel, - Subscription, Task, View, ViewContext, VisualContext, WeakModel, WindowHandle, + AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Subscription, Task, + WeakModel, }; -pub use participant::ParticipantLocation; use postage::watch; use project::Project; use room::Event; -pub use room::Room; use settings::Settings; -use shared_screen::SharedScreen; use std::sync::Arc; -use util::ResultExt; -use workspace::{item::ItemHandle, CallHandler, Pane, Workspace}; + +pub use participant::ParticipantLocation; +pub use room::Room; pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { CallSettings::register(cx); @@ -334,55 +327,12 @@ impl ActiveCall { pub fn join_channel( &mut self, channel_id: u64, - requesting_window: Option>, cx: &mut ModelContext, ) -> Task>>> { if let Some(room) = self.room().cloned() { if room.read(cx).channel_id() == Some(channel_id) { - return cx.spawn(|_, _| async move { - todo!(); - // let future = room.update(&mut cx, |room, cx| { - // room.most_active_project(cx).map(|(host, project)| { - // room.join_project(project, host, app_state.clone(), cx) - // }) - // }) - - // if let Some(future) = future { - // future.await?; - // } - - // Ok(Some(room)) - }); - } - - let should_prompt = room.update(cx, |room, _| { - room.channel_id().is_some() - && room.is_sharing_project() - && room.remote_participants().len() > 0 - }); - if should_prompt && requesting_window.is_some() { - return cx.spawn(|this, mut cx| async move { - let answer = requesting_window.unwrap().update(&mut cx, |_, cx| { - cx.prompt( - PromptLevel::Warning, - "Leaving this call will unshare your current project.\nDo you want to switch channels?", - &["Yes, Join Channel", "Cancel"], - ) - })?; - if answer.await? == 1 { - return Ok(None); - } - - room.update(&mut cx, |room, cx| room.clear_state(cx))?; - - this.update(&mut cx, |this, cx| { - this.join_channel(channel_id, requesting_window, cx) - })? - .await - }); - } - - if room.read(cx).channel_id().is_some() { + return Task::ready(Ok(Some(room))); + } else { room.update(cx, |room, cx| room.clear_state(cx)); } } @@ -555,197 +505,6 @@ pub fn report_call_event_for_channel( ) } -pub struct Call { - active_call: Option<(Model, Vec)>, -} - -impl Call { - pub fn new(cx: &mut ViewContext<'_, Workspace>) -> Box { - let mut active_call = None; - if cx.has_global::>() { - let call = cx.global::>().clone(); - let subscriptions = vec![cx.subscribe(&call, Self::on_active_call_event)]; - active_call = Some((call, subscriptions)); - } - Box::new(Self { active_call }) - } - fn on_active_call_event( - workspace: &mut Workspace, - _: Model, - event: &room::Event, - cx: &mut ViewContext, - ) { - match event { - room::Event::ParticipantLocationChanged { participant_id } - | room::Event::RemoteVideoTracksChanged { participant_id } => { - workspace.leader_updated(*participant_id, cx); - } - _ => {} - } - } -} - -#[async_trait(?Send)] -impl CallHandler for Call { - fn peer_state( - &mut self, - leader_id: PeerId, - project: &Model, - cx: &mut ViewContext, - ) -> Option<(bool, bool)> { - let (call, _) = self.active_call.as_ref()?; - let room = call.read(cx).room()?.read(cx); - let participant = room.remote_participant_for_peer_id(leader_id)?; - - let leader_in_this_app; - let leader_in_this_project; - match participant.location { - ParticipantLocation::SharedProject { project_id } => { - leader_in_this_app = true; - leader_in_this_project = Some(project_id) == project.read(cx).remote_id(); - } - ParticipantLocation::UnsharedProject => { - leader_in_this_app = true; - leader_in_this_project = false; - } - ParticipantLocation::External => { - leader_in_this_app = false; - leader_in_this_project = false; - } - }; - - Some((leader_in_this_project, leader_in_this_app)) - } - - fn shared_screen_for_peer( - &self, - peer_id: PeerId, - pane: &View, - cx: &mut ViewContext, - ) -> Option> { - let (call, _) = self.active_call.as_ref()?; - let room = call.read(cx).room()?.read(cx); - let participant = room.remote_participant_for_peer_id(peer_id)?; - let track = participant.video_tracks.values().next()?.clone(); - let user = participant.user.clone(); - for item in pane.read(cx).items_of_type::() { - if item.read(cx).peer_id == peer_id { - return Some(Box::new(item)); - } - } - - Some(Box::new(cx.build_view(|cx| { - SharedScreen::new(&track, peer_id, user.clone(), cx) - }))) - } - fn room_id(&self, cx: &AppContext) -> Option { - Some(self.active_call.as_ref()?.0.read(cx).room()?.read(cx).id()) - } - fn hang_up(&self, cx: &mut AppContext) -> Task> { - let Some((call, _)) = self.active_call.as_ref() else { - return Task::ready(Err(anyhow!("Cannot exit a call; not in a call"))); - }; - - call.update(cx, |this, cx| this.hang_up(cx)) - } - fn active_project(&self, cx: &AppContext) -> Option> { - ActiveCall::global(cx).read(cx).location().cloned() - } - fn invite( - &mut self, - called_user_id: u64, - initial_project: Option>, - cx: &mut AppContext, - ) -> Task> { - ActiveCall::global(cx).update(cx, |this, cx| { - this.invite(called_user_id, initial_project, cx) - }) - } - fn remote_participants(&self, cx: &AppContext) -> Option, PeerId)>> { - self.active_call - .as_ref() - .map(|call| { - call.0.read(cx).room().map(|room| { - room.read(cx) - .remote_participants() - .iter() - .map(|participant| { - (participant.1.user.clone(), participant.1.peer_id.clone()) - }) - .collect() - }) - }) - .flatten() - } - fn is_muted(&self, cx: &AppContext) -> Option { - self.active_call - .as_ref() - .map(|call| { - call.0 - .read(cx) - .room() - .map(|room| room.read(cx).is_muted(cx)) - }) - .flatten() - } - fn toggle_mute(&self, cx: &mut AppContext) { - self.active_call.as_ref().map(|call| { - call.0.update(cx, |this, cx| { - this.room().map(|room| { - let room = room.clone(); - cx.spawn(|_, mut cx| async move { - room.update(&mut cx, |this, cx| this.toggle_mute(cx))?? - .await - }) - .detach_and_log_err(cx); - }) - }) - }); - } - fn toggle_screen_share(&self, cx: &mut AppContext) { - self.active_call.as_ref().map(|call| { - call.0.update(cx, |this, cx| { - this.room().map(|room| { - room.update(cx, |this, cx| { - if this.is_screen_sharing() { - this.unshare_screen(cx).log_err(); - } else { - let t = this.share_screen(cx); - cx.spawn(move |_, _| async move { - t.await.log_err(); - }) - .detach(); - } - }) - }) - }) - }); - } - fn toggle_deafen(&self, cx: &mut AppContext) { - self.active_call.as_ref().map(|call| { - call.0.update(cx, |this, cx| { - this.room().map(|room| { - room.update(cx, |this, cx| { - this.toggle_deafen(cx).log_err(); - }) - }) - }) - }); - } - fn is_deafened(&self, cx: &AppContext) -> Option { - self.active_call - .as_ref() - .map(|call| { - call.0 - .read(cx) - .room() - .map(|room| room.read(cx).is_deafened()) - }) - .flatten() - .flatten() - } -} - #[cfg(test)] mod test { use gpui::TestAppContext; diff --git a/crates/call2/src/participant.rs b/crates/call2/src/participant.rs index 325a4f812b2f58c1b1bb0cc56f042e891df435d4..11a58b4b098cc6a255f8c1b061d76cf44c64684b 100644 --- a/crates/call2/src/participant.rs +++ b/crates/call2/src/participant.rs @@ -4,7 +4,7 @@ use client::{proto, User}; use collections::HashMap; use gpui::WeakModel; pub use live_kit_client::Frame; -pub(crate) use live_kit_client::{RemoteAudioTrack, RemoteVideoTrack}; +pub use live_kit_client::{RemoteAudioTrack, RemoteVideoTrack}; use project::Project; use std::sync::Arc; diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index a14088cc50066283771b50bd3a33d92f750ab8fc..c1666e9c1dc75f5ced617d254e96044d817f0807 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -346,7 +346,7 @@ impl Drop for PendingEntitySubscription { } } -#[derive(Copy, Clone)] +#[derive(Debug, Copy, Clone)] pub struct TelemetrySettings { pub diagnostics: bool, pub metrics: bool, diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index bbaf521e1532c32a62d59dfaa9cc31de5600c014..33c3c14ddd6552fc53a873694bcd1541f945ebe0 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -3,7 +3,7 @@ authors = ["Nathan Sobo "] default-run = "collab" edition = "2021" name = "collab" -version = "0.29.0" +version = "0.29.1" publish = false [[bin]] diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 68b06e435ddbe3db64962721f8f3e2b0360d32a9..780fb783bc84c9b0f741117122fc737689e98ab2 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -1220,6 +1220,13 @@ impl Database { self.check_user_is_channel_admin(&new_parent, admin_id, &*tx) .await?; + if new_parent + .ancestors_including_self() + .any(|id| id == channel.id) + { + Err(anyhow!("cannot move a channel into one of its descendants"))?; + } + new_parent_path = new_parent.path(); new_parent_channel = Some(new_parent); } else { diff --git a/crates/collab/src/db/tests/channel_tests.rs b/crates/collab/src/db/tests/channel_tests.rs index 43526c7f249e5254dd6861e3dfbbdee42bae1883..324917bbdd829f9dd1796ebf8536dd0baeafdb4f 100644 --- a/crates/collab/src/db/tests/channel_tests.rs +++ b/crates/collab/src/db/tests/channel_tests.rs @@ -450,6 +450,20 @@ async fn test_db_channel_moving_bugs(db: &Arc) { (livestreaming_id, &[projects_id]), ], ); + + // Can't move a channel into its ancestor + db.move_channel(projects_id, Some(livestreaming_id), user_id) + .await + .unwrap_err(); + let result = db.get_channels_for_user(user_id).await.unwrap(); + assert_channel_tree( + result.channels, + &[ + (zed_id, &[]), + (projects_id, &[]), + (livestreaming_id, &[projects_id]), + ], + ); } test_both_dbs!( diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index a28f2ae87f0984241ca7df30fac0807d4e0fa31b..97509d730faf6f78cce728ce4e091985b297c430 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -4,8 +4,10 @@ use collab_ui::notifications::project_shared_notification::ProjectSharedNotifica use editor::{Editor, ExcerptRange, MultiBuffer}; use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle}; use live_kit_client::MacOSDisplay; +use project::project_settings::ProjectSettings; use rpc::proto::PeerId; use serde_json::json; +use settings::SettingsStore; use std::{borrow::Cow, sync::Arc}; use workspace::{ dock::{test::TestPanel, DockPosition}, @@ -1602,6 +1604,141 @@ async fn test_following_across_workspaces( }); } +#[gpui::test] +async fn test_following_into_excluded_file( + deterministic: Arc, + mut cx_a: &mut TestAppContext, + mut cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + for cx in [&mut cx_a, &mut cx_b] { + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |project_settings| { + project_settings.file_scan_exclusions = Some(vec!["**/.git".to_string()]); + }); + }); + }); + } + server + .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) + .await; + let active_call_a = cx_a.read(ActiveCall::global); + let active_call_b = cx_b.read(ActiveCall::global); + + cx_a.update(editor::init); + cx_b.update(editor::init); + + client_a + .fs() + .insert_tree( + "/a", + json!({ + ".git": { + "COMMIT_EDITMSG": "write your commit message here", + }, + "1.txt": "one\none\none", + "2.txt": "two\ntwo\ntwo", + "3.txt": "three\nthree\nthree", + }), + ) + .await; + let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; + active_call_a + .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) + .await + .unwrap(); + + let project_id = active_call_a + .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) + .await + .unwrap(); + let project_b = client_b.build_remote_project(project_id, cx_b).await; + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + + let window_a = client_a.build_workspace(&project_a, cx_a); + let workspace_a = window_a.root(cx_a); + let peer_id_a = client_a.peer_id().unwrap(); + let window_b = client_b.build_workspace(&project_b, cx_b); + let workspace_b = window_b.root(cx_b); + + // Client A opens editors for a regular file and an excluded file. + let editor_for_regular = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, "1.txt"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + let editor_for_excluded_a = workspace_a + .update(cx_a, |workspace, cx| { + workspace.open_path((worktree_id, ".git/COMMIT_EDITMSG"), None, true, cx) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + // Client A updates their selections in those editors + editor_for_regular.update(cx_a, |editor, cx| { + editor.handle_input("a", cx); + editor.handle_input("b", cx); + editor.handle_input("c", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![3..2]); + }); + editor_for_excluded_a.update(cx_a, |editor, cx| { + editor.select_all(&Default::default(), cx); + editor.handle_input("new commit message", cx); + editor.select_left(&Default::default(), cx); + assert_eq!(editor.selections.ranges(cx), vec![18..17]); + }); + + // When client B starts following client A, currently visible file is replicated + workspace_b + .update(cx_b, |workspace, cx| { + workspace.follow(peer_id_a, cx).unwrap() + }) + .await + .unwrap(); + + let editor_for_excluded_b = workspace_b.read_with(cx_b, |workspace, cx| { + workspace + .active_item(cx) + .unwrap() + .downcast::() + .unwrap() + }); + assert_eq!( + cx_b.read(|cx| editor_for_excluded_b.project_path(cx)), + Some((worktree_id, ".git/COMMIT_EDITMSG").into()) + ); + assert_eq!( + editor_for_excluded_b.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), + vec![18..17] + ); + + // Changes from B to the excluded file are replicated in A's editor + editor_for_excluded_b.update(cx_b, |editor, cx| { + editor.handle_input("\nCo-Authored-By: B ", cx); + }); + deterministic.run_until_parked(); + editor_for_excluded_a.update(cx_a, |editor, cx| { + assert_eq!( + editor.text(cx), + "new commit messag\nCo-Authored-By: B " + ); + }); +} + fn visible_push_notifications( cx: &mut TestAppContext, ) -> Vec> { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index c5820b539526c94879edfd2a06412c7271ab3252..ad4c59e3773eb3de099d402ac736e6364daed688 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2981,11 +2981,10 @@ async fn test_fs_operations( let entry = project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "c.txt"), false, cx) - .unwrap() + project.create_entry((worktree_id, "c.txt"), false, cx) }) .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { assert_eq!( @@ -3010,7 +3009,6 @@ async fn test_fs_operations( .update(cx_b, |project, cx| { project.rename_entry(entry.id, Path::new("d.txt"), cx) }) - .unwrap() .await .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -3034,11 +3032,10 @@ async fn test_fs_operations( let dir_entry = project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR"), true, cx) - .unwrap() + project.create_entry((worktree_id, "DIR"), true, cx) }) .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { assert_eq!( @@ -3061,25 +3058,19 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/e.txt"), false, cx) - .unwrap() + project.create_entry((worktree_id, "DIR/e.txt"), false, cx) }) .await .unwrap(); project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/SUBDIR"), true, cx) - .unwrap() + project.create_entry((worktree_id, "DIR/SUBDIR"), true, cx) }) .await .unwrap(); project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) - .unwrap() + project.create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) }) .await .unwrap(); @@ -3120,9 +3111,7 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project - .copy_entry(entry.id, Path::new("f.txt"), cx) - .unwrap() + project.copy_entry(entry.id, Path::new("f.txt"), cx) }) .await .unwrap(); diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 42a2b7927581f26a6d341ed9ed1d0683b43c89f6..f839333c95aedb94f55e24ce775acd23839e4a90 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -665,7 +665,6 @@ impl RandomizedTest for ProjectCollaborationTest { ensure_project_shared(&project, client, cx).await; project .update(cx, |p, cx| p.create_entry(project_path, is_dir, cx)) - .unwrap() .await?; } diff --git a/crates/collab2/src/db/queries/channels.rs b/crates/collab2/src/db/queries/channels.rs index 68b06e435ddbe3db64962721f8f3e2b0360d32a9..780fb783bc84c9b0f741117122fc737689e98ab2 100644 --- a/crates/collab2/src/db/queries/channels.rs +++ b/crates/collab2/src/db/queries/channels.rs @@ -1220,6 +1220,13 @@ impl Database { self.check_user_is_channel_admin(&new_parent, admin_id, &*tx) .await?; + if new_parent + .ancestors_including_self() + .any(|id| id == channel.id) + { + Err(anyhow!("cannot move a channel into one of its descendants"))?; + } + new_parent_path = new_parent.path(); new_parent_channel = Some(new_parent); } else { diff --git a/crates/collab2/src/db/tests/channel_tests.rs b/crates/collab2/src/db/tests/channel_tests.rs index 43526c7f249e5254dd6861e3dfbbdee42bae1883..8a7a19ed3aacbfd6c0859b122dd470535458a150 100644 --- a/crates/collab2/src/db/tests/channel_tests.rs +++ b/crates/collab2/src/db/tests/channel_tests.rs @@ -420,8 +420,6 @@ async fn test_db_channel_moving_bugs(db: &Arc) { .await .unwrap(); - // Dag is: zed - projects - livestreaming - // Move to same parent should be a no-op assert!(db .move_channel(projects_id, Some(zed_id), user_id) @@ -450,6 +448,20 @@ async fn test_db_channel_moving_bugs(db: &Arc) { (livestreaming_id, &[projects_id]), ], ); + + // Can't move a channel into its ancestor + db.move_channel(projects_id, Some(livestreaming_id), user_id) + .await + .unwrap_err(); + let result = db.get_channels_for_user(user_id).await.unwrap(); + assert_channel_tree( + result.channels, + &[ + (zed_id, &[]), + (projects_id, &[]), + (livestreaming_id, &[projects_id]), + ], + ); } test_both_dbs!( diff --git a/crates/collab2/src/tests/channel_tests.rs b/crates/collab2/src/tests/channel_tests.rs index 43d18ee7d13b850b634c67a0414831a64b455d5c..8ce5d99b80d3c630a81181e5f03f78d385186a10 100644 --- a/crates/collab2/src/tests/channel_tests.rs +++ b/crates/collab2/src/tests/channel_tests.rs @@ -364,8 +364,7 @@ async fn test_joining_channel_ancestor_member( let active_call_b = cx_b.read(ActiveCall::global); assert!(active_call_b - .update(cx_b, |active_call, cx| active_call - .join_channel(sub_id, None, cx)) + .update(cx_b, |active_call, cx| active_call.join_channel(sub_id, cx)) .await .is_ok()); } @@ -395,9 +394,7 @@ async fn test_channel_room( let active_call_b = cx_b.read(ActiveCall::global); active_call_a - .update(cx_a, |active_call, cx| { - active_call.join_channel(zed_id, None, cx) - }) + .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); @@ -445,9 +442,7 @@ async fn test_channel_room( }); active_call_b - .update(cx_b, |active_call, cx| { - active_call.join_channel(zed_id, None, cx) - }) + .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); @@ -564,16 +559,12 @@ async fn test_channel_room( }); active_call_a - .update(cx_a, |active_call, cx| { - active_call.join_channel(zed_id, None, cx) - }) + .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); active_call_b - .update(cx_b, |active_call, cx| { - active_call.join_channel(zed_id, None, cx) - }) + .update(cx_b, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); @@ -617,9 +608,7 @@ async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppCo let active_call_a = cx_a.read(ActiveCall::global); active_call_a - .update(cx_a, |active_call, cx| { - active_call.join_channel(zed_id, None, cx) - }) + .update(cx_a, |active_call, cx| active_call.join_channel(zed_id, cx)) .await .unwrap(); @@ -638,7 +627,7 @@ async fn test_channel_jumping(executor: BackgroundExecutor, cx_a: &mut TestAppCo active_call_a .update(cx_a, |active_call, cx| { - active_call.join_channel(rust_id, None, cx) + active_call.join_channel(rust_id, cx) }) .await .unwrap(); @@ -804,7 +793,7 @@ async fn test_call_from_channel( let active_call_b = cx_b.read(ActiveCall::global); active_call_a - .update(cx_a, |call, cx| call.join_channel(channel_id, None, cx)) + .update(cx_a, |call, cx| call.join_channel(channel_id, cx)) .await .unwrap(); @@ -1297,7 +1286,7 @@ async fn test_guest_access( // Non-members should not be allowed to join assert!(active_call_b - .update(cx_b, |call, cx| call.join_channel(channel_a, None, cx)) + .update(cx_b, |call, cx| call.join_channel(channel_a, cx)) .await .is_err()); @@ -1319,7 +1308,7 @@ async fn test_guest_access( // Client B joins channel A as a guest active_call_b - .update(cx_b, |call, cx| call.join_channel(channel_a, None, cx)) + .update(cx_b, |call, cx| call.join_channel(channel_a, cx)) .await .unwrap(); @@ -1352,7 +1341,7 @@ async fn test_guest_access( assert_channels_list_shape(client_b.channel_store(), cx_b, &[]); active_call_b - .update(cx_b, |call, cx| call.join_channel(channel_b, None, cx)) + .update(cx_b, |call, cx| call.join_channel(channel_b, cx)) .await .unwrap(); @@ -1383,7 +1372,7 @@ async fn test_invite_access( // should not be allowed to join assert!(active_call_b - .update(cx_b, |call, cx| call.join_channel(channel_b_id, None, cx)) + .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx)) .await .is_err()); @@ -1401,7 +1390,7 @@ async fn test_invite_access( .unwrap(); active_call_b - .update(cx_b, |call, cx| call.join_channel(channel_b_id, None, cx)) + .update(cx_b, |call, cx| call.join_channel(channel_b_id, cx)) .await .unwrap(); diff --git a/crates/collab2/src/tests/following_tests.rs b/crates/collab2/src/tests/following_tests.rs index 61d14c25c426cb93f4101aeb56ac2119d8efe0f7..5178df408f95b8809495836576c3f1c74159cf85 100644 --- a/crates/collab2/src/tests/following_tests.rs +++ b/crates/collab2/src/tests/following_tests.rs @@ -4,10 +4,12 @@ // use call::ActiveCall; // use collab_ui::notifications::project_shared_notification::ProjectSharedNotification; // use editor::{Editor, ExcerptRange, MultiBuffer}; -// use gpui::{BackgroundExecutor, TestAppContext, View}; +// use gpui::{point, BackgroundExecutor, TestAppContext, View, VisualTestContext, WindowContext}; // use live_kit_client::MacOSDisplay; +// use project::project_settings::ProjectSettings; // use rpc::proto::PeerId; // use serde_json::json; +// use settings::SettingsStore; // use std::borrow::Cow; // use workspace::{ // dock::{test::TestPanel, DockPosition}, @@ -24,7 +26,7 @@ // cx_c: &mut TestAppContext, // cx_d: &mut TestAppContext, // ) { -// let mut server = TestServer::start(&executor).await; +// let mut server = TestServer::start(executor.clone()).await; // let client_a = server.create_client(cx_a, "user_a").await; // let client_b = server.create_client(cx_b, "user_b").await; // let client_c = server.create_client(cx_c, "user_c").await; @@ -71,12 +73,22 @@ // .unwrap(); // let window_a = client_a.build_workspace(&project_a, cx_a); -// let workspace_a = window_a.root(cx_a); +// let workspace_a = window_a.root(cx_a).unwrap(); // let window_b = client_b.build_workspace(&project_b, cx_b); -// let workspace_b = window_b.root(cx_b); +// let workspace_b = window_b.root(cx_b).unwrap(); + +// todo!("could be wrong") +// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); +// let cx_a = &mut cx_a; +// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); +// let cx_b = &mut cx_b; +// let mut cx_c = VisualTestContext::from_window(*window_c, cx_c); +// let cx_c = &mut cx_c; +// let mut cx_d = VisualTestContext::from_window(*window_d, cx_d); +// let cx_d = &mut cx_d; // // Client A opens some editors. -// let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); +// let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); // let editor_a1 = workspace_a // .update(cx_a, |workspace, cx| { // workspace.open_path((worktree_id, "1.txt"), None, true, cx) @@ -132,8 +144,8 @@ // .await // .unwrap(); -// cx_c.foreground().run_until_parked(); -// let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { +// cx_c.executor().run_until_parked(); +// let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { // workspace // .active_item(cx) // .unwrap() @@ -145,19 +157,19 @@ // Some((worktree_id, "2.txt").into()) // ); // assert_eq!( -// editor_b2.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), +// editor_b2.update(cx_b, |editor, cx| editor.selections.ranges(cx)), // vec![2..1] // ); // assert_eq!( -// editor_b1.read_with(cx_b, |editor, cx| editor.selections.ranges(cx)), +// editor_b1.update(cx_b, |editor, cx| editor.selections.ranges(cx)), // vec![3..2] // ); -// cx_c.foreground().run_until_parked(); +// cx_c.executor().run_until_parked(); // let active_call_c = cx_c.read(ActiveCall::global); // let project_c = client_c.build_remote_project(project_id, cx_c).await; // let window_c = client_c.build_workspace(&project_c, cx_c); -// let workspace_c = window_c.root(cx_c); +// let workspace_c = window_c.root(cx_c).unwrap(); // active_call_c // .update(cx_c, |call, cx| call.set_location(Some(&project_c), cx)) // .await @@ -172,10 +184,13 @@ // .await // .unwrap(); -// cx_d.foreground().run_until_parked(); +// cx_d.executor().run_until_parked(); // let active_call_d = cx_d.read(ActiveCall::global); // let project_d = client_d.build_remote_project(project_id, cx_d).await; -// let workspace_d = client_d.build_workspace(&project_d, cx_d).root(cx_d); +// let workspace_d = client_d +// .build_workspace(&project_d, cx_d) +// .root(cx_d) +// .unwrap(); // active_call_d // .update(cx_d, |call, cx| call.set_location(Some(&project_d), cx)) // .await @@ -183,7 +198,7 @@ // drop(project_d); // // All clients see that clients B and C are following client A. -// cx_c.foreground().run_until_parked(); +// cx_c.executor().run_until_parked(); // for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { // assert_eq!( // followers_by_leader(project_id, cx), @@ -198,7 +213,7 @@ // }); // // All clients see that clients B is following client A. -// cx_c.foreground().run_until_parked(); +// cx_c.executor().run_until_parked(); // for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { // assert_eq!( // followers_by_leader(project_id, cx), @@ -216,7 +231,7 @@ // .unwrap(); // // All clients see that clients B and C are following client A. -// cx_c.foreground().run_until_parked(); +// cx_c.executor().run_until_parked(); // for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { // assert_eq!( // followers_by_leader(project_id, cx), @@ -240,7 +255,7 @@ // .unwrap(); // // All clients see that D is following C -// cx_d.foreground().run_until_parked(); +// cx_d.executor().run_until_parked(); // for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { // assert_eq!( // followers_by_leader(project_id, cx), @@ -257,7 +272,7 @@ // cx_c.drop_last(workspace_c); // // Clients A and B see that client B is following A, and client C is not present in the followers. -// cx_c.foreground().run_until_parked(); +// cx_c.executor().run_until_parked(); // for (name, cx) in [("A", &cx_a), ("B", &cx_b), ("C", &cx_c), ("D", &cx_d)] { // assert_eq!( // followers_by_leader(project_id, cx), @@ -271,12 +286,15 @@ // workspace.activate_item(&editor_a1, cx) // }); // executor.run_until_parked(); -// workspace_b.read_with(cx_b, |workspace, cx| { -// assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); +// workspace_b.update(cx_b, |workspace, cx| { +// assert_eq!( +// workspace.active_item(cx).unwrap().item_id(), +// editor_b1.item_id() +// ); // }); // // When client A opens a multibuffer, client B does so as well. -// let multibuffer_a = cx_a.add_model(|cx| { +// let multibuffer_a = cx_a.build_model(|cx| { // let buffer_a1 = project_a.update(cx, |project, cx| { // project // .get_open_buffer(&(worktree_id, "1.txt").into(), cx) @@ -308,12 +326,12 @@ // }); // let multibuffer_editor_a = workspace_a.update(cx_a, |workspace, cx| { // let editor = -// cx.add_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); +// cx.build_view(|cx| Editor::for_multibuffer(multibuffer_a, Some(project_a.clone()), cx)); // workspace.add_item(Box::new(editor.clone()), cx); // editor // }); // executor.run_until_parked(); -// let multibuffer_editor_b = workspace_b.read_with(cx_b, |workspace, cx| { +// let multibuffer_editor_b = workspace_b.update(cx_b, |workspace, cx| { // workspace // .active_item(cx) // .unwrap() @@ -321,8 +339,8 @@ // .unwrap() // }); // assert_eq!( -// multibuffer_editor_a.read_with(cx_a, |editor, cx| editor.text(cx)), -// multibuffer_editor_b.read_with(cx_b, |editor, cx| editor.text(cx)), +// multibuffer_editor_a.update(cx_a, |editor, cx| editor.text(cx)), +// multibuffer_editor_b.update(cx_b, |editor, cx| editor.text(cx)), // ); // // When client A navigates back and forth, client B does so as well. @@ -333,8 +351,11 @@ // .await // .unwrap(); // executor.run_until_parked(); -// workspace_b.read_with(cx_b, |workspace, cx| { -// assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); +// workspace_b.update(cx_b, |workspace, cx| { +// assert_eq!( +// workspace.active_item(cx).unwrap().item_id(), +// editor_b1.item_id() +// ); // }); // workspace_a @@ -344,8 +365,11 @@ // .await // .unwrap(); // executor.run_until_parked(); -// workspace_b.read_with(cx_b, |workspace, cx| { -// assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b2.id()); +// workspace_b.update(cx_b, |workspace, cx| { +// assert_eq!( +// workspace.active_item(cx).unwrap().item_id(), +// editor_b2.item_id() +// ); // }); // workspace_a @@ -355,8 +379,11 @@ // .await // .unwrap(); // executor.run_until_parked(); -// workspace_b.read_with(cx_b, |workspace, cx| { -// assert_eq!(workspace.active_item(cx).unwrap().id(), editor_b1.id()); +// workspace_b.update(cx_b, |workspace, cx| { +// assert_eq!( +// workspace.active_item(cx).unwrap().item_id(), +// editor_b1.item_id() +// ); // }); // // Changes to client A's editor are reflected on client B. @@ -364,20 +391,20 @@ // editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2])); // }); // executor.run_until_parked(); -// editor_b1.read_with(cx_b, |editor, cx| { +// editor_b1.update(cx_b, |editor, cx| { // assert_eq!(editor.selections.ranges(cx), &[1..1, 2..2]); // }); // editor_a1.update(cx_a, |editor, cx| editor.set_text("TWO", cx)); // executor.run_until_parked(); -// editor_b1.read_with(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO")); +// editor_b1.update(cx_b, |editor, cx| assert_eq!(editor.text(cx), "TWO")); // editor_a1.update(cx_a, |editor, cx| { // editor.change_selections(None, cx, |s| s.select_ranges([3..3])); -// editor.set_scroll_position(vec2f(0., 100.), cx); +// editor.set_scroll_position(point(0., 100.), cx); // }); // executor.run_until_parked(); -// editor_b1.read_with(cx_b, |editor, cx| { +// editor_b1.update(cx_b, |editor, cx| { // assert_eq!(editor.selections.ranges(cx), &[3..3]); // }); @@ -390,11 +417,11 @@ // }); // executor.run_until_parked(); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, cx| workspace +// workspace_b.update(cx_b, |workspace, cx| workspace // .active_item(cx) // .unwrap() -// .id()), -// editor_b1.id() +// .item_id()), +// editor_b1.item_id() // ); // // Client A starts following client B. @@ -405,15 +432,15 @@ // .await // .unwrap(); // assert_eq!( -// workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), +// workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), // Some(peer_id_b) // ); // assert_eq!( -// workspace_a.read_with(cx_a, |workspace, cx| workspace +// workspace_a.update(cx_a, |workspace, cx| workspace // .active_item(cx) // .unwrap() -// .id()), -// editor_a1.id() +// .item_id()), +// editor_a1.item_id() // ); // // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. @@ -432,7 +459,7 @@ // .await // .unwrap(); // executor.run_until_parked(); -// let shared_screen = workspace_a.read_with(cx_a, |workspace, cx| { +// let shared_screen = workspace_a.update(cx_a, |workspace, cx| { // workspace // .active_item(cx) // .expect("no active item") @@ -446,8 +473,11 @@ // .await // .unwrap(); // executor.run_until_parked(); -// workspace_a.read_with(cx_a, |workspace, cx| { -// assert_eq!(workspace.active_item(cx).unwrap().id(), editor_a1.id()) +// workspace_a.update(cx_a, |workspace, cx| { +// assert_eq!( +// workspace.active_item(cx).unwrap().item_id(), +// editor_a1.item_id() +// ) // }); // // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. @@ -455,26 +485,26 @@ // workspace.activate_item(&multibuffer_editor_b, cx) // }); // executor.run_until_parked(); -// workspace_a.read_with(cx_a, |workspace, cx| { +// workspace_a.update(cx_a, |workspace, cx| { // assert_eq!( -// workspace.active_item(cx).unwrap().id(), -// multibuffer_editor_a.id() +// workspace.active_item(cx).unwrap().item_id(), +// multibuffer_editor_a.item_id() // ) // }); // // Client B activates a panel, and the previously-opened screen-sharing item gets activated. -// let panel = window_b.add_view(cx_b, |_| TestPanel::new(DockPosition::Left)); +// let panel = window_b.build_view(cx_b, |_| TestPanel::new(DockPosition::Left)); // workspace_b.update(cx_b, |workspace, cx| { // workspace.add_panel(panel, cx); // workspace.toggle_panel_focus::(cx); // }); // executor.run_until_parked(); // assert_eq!( -// workspace_a.read_with(cx_a, |workspace, cx| workspace +// workspace_a.update(cx_a, |workspace, cx| workspace // .active_item(cx) // .unwrap() -// .id()), -// shared_screen.id() +// .item_id()), +// shared_screen.item_id() // ); // // Toggling the focus back to the pane causes client A to return to the multibuffer. @@ -482,16 +512,16 @@ // workspace.toggle_panel_focus::(cx); // }); // executor.run_until_parked(); -// workspace_a.read_with(cx_a, |workspace, cx| { +// workspace_a.update(cx_a, |workspace, cx| { // assert_eq!( -// workspace.active_item(cx).unwrap().id(), -// multibuffer_editor_a.id() +// workspace.active_item(cx).unwrap().item_id(), +// multibuffer_editor_a.item_id() // ) // }); // // Client B activates an item that doesn't implement following, // // so the previously-opened screen-sharing item gets activated. -// let unfollowable_item = window_b.add_view(cx_b, |_| TestItem::new()); +// let unfollowable_item = window_b.build_view(cx_b, |_| TestItem::new()); // workspace_b.update(cx_b, |workspace, cx| { // workspace.active_pane().update(cx, |pane, cx| { // pane.add_item(Box::new(unfollowable_item), true, true, None, cx) @@ -499,18 +529,18 @@ // }); // executor.run_until_parked(); // assert_eq!( -// workspace_a.read_with(cx_a, |workspace, cx| workspace +// workspace_a.update(cx_a, |workspace, cx| workspace // .active_item(cx) // .unwrap() -// .id()), -// shared_screen.id() +// .item_id()), +// shared_screen.item_id() // ); // // Following interrupts when client B disconnects. // client_b.disconnect(&cx_b.to_async()); // executor.advance_clock(RECONNECT_TIMEOUT); // assert_eq!( -// workspace_a.read_with(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), +// workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), // None // ); // } @@ -521,7 +551,7 @@ // cx_a: &mut TestAppContext, // cx_b: &mut TestAppContext, // ) { -// let mut server = TestServer::start(&executor).await; +// let mut server = TestServer::start(executor.clone()).await; // let client_a = server.create_client(cx_a, "user_a").await; // let client_b = server.create_client(cx_b, "user_b").await; // server @@ -560,13 +590,19 @@ // .await // .unwrap(); -// let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); -// let pane_a = workspace_a.read_with(cx_a, |workspace, _| workspace.active_pane().clone()); +// let workspace_a = client_a +// .build_workspace(&project_a, cx_a) +// .root(cx_a) +// .unwrap(); +// let pane_a = workspace_a.update(cx_a, |workspace, _| workspace.active_pane().clone()); -// let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); -// let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); +// let workspace_b = client_b +// .build_workspace(&project_b, cx_b) +// .root(cx_b) +// .unwrap(); +// let pane_b = workspace_b.update(cx_b, |workspace, _| workspace.active_pane().clone()); -// let client_b_id = project_a.read_with(cx_a, |project, _| { +// let client_b_id = project_a.update(cx_a, |project, _| { // project.collaborators().values().next().unwrap().peer_id // }); @@ -584,7 +620,7 @@ // .await // .unwrap(); -// let pane_paths = |pane: &ViewHandle, cx: &mut TestAppContext| { +// let pane_paths = |pane: &View, cx: &mut TestAppContext| { // pane.update(cx, |pane, cx| { // pane.items() // .map(|item| { @@ -642,7 +678,7 @@ // cx_a: &mut TestAppContext, // cx_b: &mut TestAppContext, // ) { -// let mut server = TestServer::start(&executor).await; +// let mut server = TestServer::start(executor.clone()).await; // let client_a = server.create_client(cx_a, "user_a").await; // let client_b = server.create_client(cx_b, "user_b").await; // server @@ -685,7 +721,10 @@ // .unwrap(); // // Client A opens a file. -// let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); +// let workspace_a = client_a +// .build_workspace(&project_a, cx_a) +// .root(cx_a) +// .unwrap(); // workspace_a // .update(cx_a, |workspace, cx| { // workspace.open_path((worktree_id, "1.txt"), None, true, cx) @@ -696,7 +735,10 @@ // .unwrap(); // // Client B opens a different file. -// let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); +// let workspace_b = client_b +// .build_workspace(&project_b, cx_b) +// .root(cx_b) +// .unwrap(); // workspace_b // .update(cx_b, |workspace, cx| { // workspace.open_path((worktree_id, "2.txt"), None, true, cx) @@ -1167,7 +1209,7 @@ // cx_b: &mut TestAppContext, // ) { // // 2 clients connect to a server. -// let mut server = TestServer::start(&executor).await; +// let mut server = TestServer::start(executor.clone()).await; // let client_a = server.create_client(cx_a, "user_a").await; // let client_b = server.create_client(cx_b, "user_b").await; // server @@ -1207,8 +1249,17 @@ // .await // .unwrap(); +// todo!("could be wrong") +// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); +// let cx_a = &mut cx_a; +// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); +// let cx_b = &mut cx_b; + // // Client A opens some editors. -// let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); +// let workspace_a = client_a +// .build_workspace(&project_a, cx_a) +// .root(cx_a) +// .unwrap(); // let _editor_a1 = workspace_a // .update(cx_a, |workspace, cx| { // workspace.open_path((worktree_id, "1.txt"), None, true, cx) @@ -1219,9 +1270,12 @@ // .unwrap(); // // Client B starts following client A. -// let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); -// let pane_b = workspace_b.read_with(cx_b, |workspace, _| workspace.active_pane().clone()); -// let leader_id = project_b.read_with(cx_b, |project, _| { +// let workspace_b = client_b +// .build_workspace(&project_b, cx_b) +// .root(cx_b) +// .unwrap(); +// let pane_b = workspace_b.update(cx_b, |workspace, _| workspace.active_pane().clone()); +// let leader_id = project_b.update(cx_b, |project, _| { // project.collaborators().values().next().unwrap().peer_id // }); // workspace_b @@ -1231,10 +1285,10 @@ // .await // .unwrap(); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // Some(leader_id) // ); -// let editor_b2 = workspace_b.read_with(cx_b, |workspace, cx| { +// let editor_b2 = workspace_b.update(cx_b, |workspace, cx| { // workspace // .active_item(cx) // .unwrap() @@ -1245,7 +1299,7 @@ // // When client B moves, it automatically stops following client A. // editor_b2.update(cx_b, |editor, cx| editor.move_right(&editor::MoveRight, cx)); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // None // ); @@ -1256,14 +1310,14 @@ // .await // .unwrap(); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // Some(leader_id) // ); // // When client B edits, it automatically stops following client A. // editor_b2.update(cx_b, |editor, cx| editor.insert("X", cx)); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // None // ); @@ -1274,16 +1328,16 @@ // .await // .unwrap(); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // Some(leader_id) // ); // // When client B scrolls, it automatically stops following client A. // editor_b2.update(cx_b, |editor, cx| { -// editor.set_scroll_position(vec2f(0., 3.), cx) +// editor.set_scroll_position(point(0., 3.), cx) // }); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // None // ); @@ -1294,7 +1348,7 @@ // .await // .unwrap(); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // Some(leader_id) // ); @@ -1303,13 +1357,13 @@ // workspace.split_and_clone(pane_b.clone(), SplitDirection::Right, cx) // }); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // Some(leader_id) // ); // workspace_b.update(cx_b, |workspace, cx| workspace.activate_next_pane(cx)); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // Some(leader_id) // ); @@ -1321,7 +1375,7 @@ // .await // .unwrap(); // assert_eq!( -// workspace_b.read_with(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), +// workspace_b.update(cx_b, |workspace, _| workspace.leader_for_pane(&pane_b)), // None // ); // } @@ -1332,7 +1386,7 @@ // cx_a: &mut TestAppContext, // cx_b: &mut TestAppContext, // ) { -// let mut server = TestServer::start(&executor).await; +// let mut server = TestServer::start(executor.clone()).await; // let client_a = server.create_client(cx_a, "user_a").await; // let client_b = server.create_client(cx_b, "user_b").await; // server @@ -1345,20 +1399,26 @@ // client_a.fs().insert_tree("/a", json!({})).await; // let (project_a, _) = client_a.build_local_project("/a", cx_a).await; -// let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); +// let workspace_a = client_a +// .build_workspace(&project_a, cx_a) +// .root(cx_a) +// .unwrap(); // let project_id = active_call_a // .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) // .await // .unwrap(); // let project_b = client_b.build_remote_project(project_id, cx_b).await; -// let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); +// let workspace_b = client_b +// .build_workspace(&project_b, cx_b) +// .root(cx_b) +// .unwrap(); // executor.run_until_parked(); -// let client_a_id = project_b.read_with(cx_b, |project, _| { +// let client_a_id = project_b.update(cx_b, |project, _| { // project.collaborators().values().next().unwrap().peer_id // }); -// let client_b_id = project_a.read_with(cx_a, |project, _| { +// let client_b_id = project_a.update(cx_a, |project, _| { // project.collaborators().values().next().unwrap().peer_id // }); @@ -1370,13 +1430,13 @@ // }); // futures::try_join!(a_follow_b, b_follow_a).unwrap(); -// workspace_a.read_with(cx_a, |workspace, _| { +// workspace_a.update(cx_a, |workspace, _| { // assert_eq!( // workspace.leader_for_pane(workspace.active_pane()), // Some(client_b_id) // ); // }); -// workspace_b.read_with(cx_b, |workspace, _| { +// workspace_b.update(cx_b, |workspace, _| { // assert_eq!( // workspace.leader_for_pane(workspace.active_pane()), // Some(client_a_id) @@ -1398,7 +1458,7 @@ // // b opens a different file in project 2, a follows b // // b opens a different file in project 1, a cannot follow b // // b shares the project, a joins the project and follows b -// let mut server = TestServer::start(&executor).await; +// let mut server = TestServer::start(executor.clone()).await; // let client_a = server.create_client(cx_a, "user_a").await; // let client_b = server.create_client(cx_b, "user_b").await; // cx_a.update(editor::init); @@ -1435,8 +1495,14 @@ // let (project_a, worktree_id_a) = client_a.build_local_project("/a", cx_a).await; // let (project_b, worktree_id_b) = client_b.build_local_project("/b", cx_b).await; -// let workspace_a = client_a.build_workspace(&project_a, cx_a).root(cx_a); -// let workspace_b = client_b.build_workspace(&project_b, cx_b).root(cx_b); +// let workspace_a = client_a +// .build_workspace(&project_a, cx_a) +// .root(cx_a) +// .unwrap(); +// let workspace_b = client_b +// .build_workspace(&project_b, cx_b) +// .root(cx_b) +// .unwrap(); // cx_a.update(|cx| collab_ui::init(&client_a.app_state, cx)); // cx_b.update(|cx| collab_ui::init(&client_b.app_state, cx)); @@ -1455,6 +1521,12 @@ // .await // .unwrap(); +// todo!("could be wrong") +// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); +// let cx_a = &mut cx_a; +// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); +// let cx_b = &mut cx_b; + // workspace_a // .update(cx_a, |workspace, cx| { // workspace.open_path((worktree_id_a, "w.rs"), None, true, cx) @@ -1476,11 +1548,12 @@ // let workspace_b_project_a = cx_b // .windows() // .iter() -// .max_by_key(|window| window.id()) +// .max_by_key(|window| window.item_id()) // .unwrap() // .downcast::() // .unwrap() -// .root(cx_b); +// .root(cx_b) +// .unwrap(); // // assert that b is following a in project a in w.rs // workspace_b_project_a.update(cx_b, |workspace, cx| { @@ -1534,7 +1607,7 @@ // workspace.leader_for_pane(workspace.active_pane()) // ); // let item = workspace.active_pane().read(cx).active_item().unwrap(); -// assert_eq!(item.tab_description(0, cx).unwrap(), Cow::Borrowed("x.rs")); +// assert_eq!(item.tab_description(0, cx).unwrap(), "x.rs".into()); // }); // // b moves to y.rs in b's project, a is still following but can't yet see @@ -1578,11 +1651,12 @@ // let workspace_a_project_b = cx_a // .windows() // .iter() -// .max_by_key(|window| window.id()) +// .max_by_key(|window| window.item_id()) // .unwrap() // .downcast::() // .unwrap() -// .root(cx_a); +// .root(cx_a) +// .unwrap(); // workspace_a_project_b.update(cx_a, |workspace, cx| { // assert_eq!(workspace.project().read(cx).remote_id(), Some(project_b_id)); @@ -1596,12 +1670,151 @@ // }); // } +// #[gpui::test] +// async fn test_following_into_excluded_file( +// executor: BackgroundExecutor, +// mut cx_a: &mut TestAppContext, +// mut cx_b: &mut TestAppContext, +// ) { +// let mut server = TestServer::start(executor.clone()).await; +// let client_a = server.create_client(cx_a, "user_a").await; +// let client_b = server.create_client(cx_b, "user_b").await; +// for cx in [&mut cx_a, &mut cx_b] { +// cx.update(|cx| { +// cx.update_global::(|store, cx| { +// store.update_user_settings::(cx, |project_settings| { +// project_settings.file_scan_exclusions = Some(vec!["**/.git".to_string()]); +// }); +// }); +// }); +// } +// server +// .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)]) +// .await; +// let active_call_a = cx_a.read(ActiveCall::global); +// let active_call_b = cx_b.read(ActiveCall::global); + +// cx_a.update(editor::init); +// cx_b.update(editor::init); + +// client_a +// .fs() +// .insert_tree( +// "/a", +// json!({ +// ".git": { +// "COMMIT_EDITMSG": "write your commit message here", +// }, +// "1.txt": "one\none\none", +// "2.txt": "two\ntwo\ntwo", +// "3.txt": "three\nthree\nthree", +// }), +// ) +// .await; +// let (project_a, worktree_id) = client_a.build_local_project("/a", cx_a).await; +// active_call_a +// .update(cx_a, |call, cx| call.set_location(Some(&project_a), cx)) +// .await +// .unwrap(); + +// let project_id = active_call_a +// .update(cx_a, |call, cx| call.share_project(project_a.clone(), cx)) +// .await +// .unwrap(); +// let project_b = client_b.build_remote_project(project_id, cx_b).await; +// active_call_b +// .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) +// .await +// .unwrap(); + +// let window_a = client_a.build_workspace(&project_a, cx_a); +// let workspace_a = window_a.root(cx_a).unwrap(); +// let peer_id_a = client_a.peer_id().unwrap(); +// let window_b = client_b.build_workspace(&project_b, cx_b); +// let workspace_b = window_b.root(cx_b).unwrap(); + +// todo!("could be wrong") +// let mut cx_a = VisualTestContext::from_window(*window_a, cx_a); +// let cx_a = &mut cx_a; +// let mut cx_b = VisualTestContext::from_window(*window_b, cx_b); +// let cx_b = &mut cx_b; + +// // Client A opens editors for a regular file and an excluded file. +// let editor_for_regular = workspace_a +// .update(cx_a, |workspace, cx| { +// workspace.open_path((worktree_id, "1.txt"), None, true, cx) +// }) +// .await +// .unwrap() +// .downcast::() +// .unwrap(); +// let editor_for_excluded_a = workspace_a +// .update(cx_a, |workspace, cx| { +// workspace.open_path((worktree_id, ".git/COMMIT_EDITMSG"), None, true, cx) +// }) +// .await +// .unwrap() +// .downcast::() +// .unwrap(); + +// // Client A updates their selections in those editors +// editor_for_regular.update(cx_a, |editor, cx| { +// editor.handle_input("a", cx); +// editor.handle_input("b", cx); +// editor.handle_input("c", cx); +// editor.select_left(&Default::default(), cx); +// assert_eq!(editor.selections.ranges(cx), vec![3..2]); +// }); +// editor_for_excluded_a.update(cx_a, |editor, cx| { +// editor.select_all(&Default::default(), cx); +// editor.handle_input("new commit message", cx); +// editor.select_left(&Default::default(), cx); +// assert_eq!(editor.selections.ranges(cx), vec![18..17]); +// }); + +// // When client B starts following client A, currently visible file is replicated +// workspace_b +// .update(cx_b, |workspace, cx| { +// workspace.follow(peer_id_a, cx).unwrap() +// }) +// .await +// .unwrap(); + +// let editor_for_excluded_b = workspace_b.update(cx_b, |workspace, cx| { +// workspace +// .active_item(cx) +// .unwrap() +// .downcast::() +// .unwrap() +// }); +// assert_eq!( +// cx_b.read(|cx| editor_for_excluded_b.project_path(cx)), +// Some((worktree_id, ".git/COMMIT_EDITMSG").into()) +// ); +// assert_eq!( +// editor_for_excluded_b.update(cx_b, |editor, cx| editor.selections.ranges(cx)), +// vec![18..17] +// ); + +// // Changes from B to the excluded file are replicated in A's editor +// editor_for_excluded_b.update(cx_b, |editor, cx| { +// editor.handle_input("\nCo-Authored-By: B ", cx); +// }); +// executor.run_until_parked(); +// editor_for_excluded_a.update(cx_a, |editor, cx| { +// assert_eq!( +// editor.text(cx), +// "new commit messag\nCo-Authored-By: B " +// ); +// }); +// } + // fn visible_push_notifications( // cx: &mut TestAppContext, -// ) -> Vec> { +// ) -> Vec> { // let mut ret = Vec::new(); // for window in cx.windows() { -// window.read_with(cx, |window| { +// window.update(cx, |window| { // if let Some(handle) = window // .root_view() // .clone() @@ -1645,8 +1858,8 @@ // }) // } -// fn pane_summaries(workspace: &ViewHandle, cx: &mut TestAppContext) -> Vec { -// workspace.read_with(cx, |workspace, cx| { +// fn pane_summaries(workspace: &View, cx: &mut WindowContext<'_>) -> Vec { +// workspace.update(cx, |workspace, cx| { // let active_pane = workspace.active_pane(); // workspace // .panes() diff --git a/crates/collab2/src/tests/integration_tests.rs b/crates/collab2/src/tests/integration_tests.rs index 2268a51f2ba5f1671a02707101ecad8f65501d1c..823c8e9045eb02fe67fa605bc1cf2d21fb88a670 100644 --- a/crates/collab2/src/tests/integration_tests.rs +++ b/crates/collab2/src/tests/integration_tests.rs @@ -510,10 +510,9 @@ async fn test_joining_channels_and_calling_multiple_users_simultaneously( // Simultaneously join channel 1 and then channel 2 active_call_a - .update(cx_a, |call, cx| call.join_channel(channel_1, None, cx)) + .update(cx_a, |call, cx| call.join_channel(channel_1, cx)) .detach(); - let join_channel_2 = - active_call_a.update(cx_a, |call, cx| call.join_channel(channel_2, None, cx)); + let join_channel_2 = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_2, cx)); join_channel_2.await.unwrap(); @@ -539,8 +538,7 @@ async fn test_joining_channels_and_calling_multiple_users_simultaneously( call.invite(client_c.user_id().unwrap(), None, cx) }); - let join_channel = - active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, None, cx)); + let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx)); b_invite.await.unwrap(); c_invite.await.unwrap(); @@ -569,8 +567,7 @@ async fn test_joining_channels_and_calling_multiple_users_simultaneously( .unwrap(); // Simultaneously join channel 1 and call user B and user C from client A. - let join_channel = - active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, None, cx)); + let join_channel = active_call_a.update(cx_a, |call, cx| call.join_channel(channel_1, cx)); let b_invite = active_call_a.update(cx_a, |call, cx| { call.invite(client_b.user_id().unwrap(), None, cx) @@ -2784,11 +2781,10 @@ async fn test_fs_operations( let entry = project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "c.txt"), false, cx) - .unwrap() + project.create_entry((worktree_id, "c.txt"), false, cx) }) .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -2815,8 +2811,8 @@ async fn test_fs_operations( .update(cx_b, |project, cx| { project.rename_entry(entry.id, Path::new("d.txt"), cx) }) - .unwrap() .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -2841,11 +2837,10 @@ async fn test_fs_operations( let dir_entry = project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR"), true, cx) - .unwrap() + project.create_entry((worktree_id, "DIR"), true, cx) }) .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -2870,27 +2865,24 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/e.txt"), false, cx) - .unwrap() + project.create_entry((worktree_id, "DIR/e.txt"), false, cx) }) .await + .unwrap() .unwrap(); project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/SUBDIR"), true, cx) - .unwrap() + project.create_entry((worktree_id, "DIR/SUBDIR"), true, cx) }) .await + .unwrap() .unwrap(); project_b .update(cx_b, |project, cx| { - project - .create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) - .unwrap() + project.create_entry((worktree_id, "DIR/SUBDIR/f.txt"), false, cx) }) .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { @@ -2931,11 +2923,10 @@ async fn test_fs_operations( project_b .update(cx_b, |project, cx| { - project - .copy_entry(entry.id, Path::new("f.txt"), cx) - .unwrap() + project.copy_entry(entry.id, Path::new("f.txt"), cx) }) .await + .unwrap() .unwrap(); worktree_a.read_with(cx_a, |worktree, _| { diff --git a/crates/collab2/src/tests/random_project_collaboration_tests.rs b/crates/collab2/src/tests/random_project_collaboration_tests.rs index 47b936a6117df1873702cb1937614548aa03d796..f4194b98e8adbf41742a5aa279d766cf09c2477d 100644 --- a/crates/collab2/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab2/src/tests/random_project_collaboration_tests.rs @@ -665,7 +665,6 @@ impl RandomizedTest for ProjectCollaborationTest { ensure_project_shared(&project, client, cx).await; project .update(cx, |p, cx| p.create_entry(project_path, is_dir, cx)) - .unwrap() .await?; } diff --git a/crates/collab2/src/tests/test_server.rs b/crates/collab2/src/tests/test_server.rs index 5f95f00d6fcd5c74d81d90f9b4b455ab531862d5..6bb57e11ab1d582031930f34b8bfe67b96a2581e 100644 --- a/crates/collab2/src/tests/test_server.rs +++ b/crates/collab2/src/tests/test_server.rs @@ -221,7 +221,6 @@ impl TestServer { fs: fs.clone(), build_window_options: |_, _, _| Default::default(), node_runtime: FakeNodeRuntime::new(), - call_factory: |_| Box::new(workspace::TestCallHandler), }); cx.update(|cx| { diff --git a/crates/collab_ui2/src/collab_panel.rs b/crates/collab_ui2/src/collab_panel.rs index b62056a3be92ba178e62b44d4b1557c0d848f2a8..c55bfa8cf5a7beec1c516598f242fcfa2fefe018 100644 --- a/crates/collab_ui2/src/collab_panel.rs +++ b/crates/collab_ui2/src/collab_panel.rs @@ -1,5 +1,5 @@ #![allow(unused)] -// mod channel_modal; +mod channel_modal; mod contact_finder; // use crate::{ @@ -18,7 +18,7 @@ mod contact_finder; // }; use contact_finder::ContactFinder; use menu::{Cancel, Confirm, SelectNext, SelectPrev}; -use rpc::proto; +use rpc::proto::{self, PeerId}; use theme::{ActiveTheme, ThemeSettings}; // use context_menu::{ContextMenu, ContextMenuItem}; // use db::kvp::KEY_VALUE_STORE; @@ -169,11 +169,12 @@ use editor::Editor; use feature_flags::{ChannelsAlpha, FeatureFlagAppExt, FeatureFlagViewExt}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, div, img, overlay, prelude::*, px, rems, serde_json, Action, AppContext, - AsyncWindowContext, Bounds, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, - Focusable, FocusableView, InteractiveElement, IntoElement, Model, MouseDownEvent, - ParentElement, Pixels, Point, PromptLevel, Render, RenderOnce, ScrollHandle, SharedString, - Stateful, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, + actions, canvas, div, img, overlay, point, prelude::*, px, rems, serde_json, 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, }; use project::{Fs, Project}; use serde_derive::{Deserialize, Serialize}; @@ -192,6 +193,8 @@ use workspace::{ use crate::{face_pile::FacePile, CollaborationPanelSettings}; +use self::channel_modal::ChannelModal; + pub fn init(cx: &mut AppContext) { cx.observe_new_views(|workspace: &mut Workspace, _| { workspace.register_action(|workspace, _: &ToggleFocus, cx| { @@ -345,21 +348,21 @@ enum Section { #[derive(Clone, Debug)] enum ListEntry { Header(Section), - // CallParticipant { - // user: Arc, - // peer_id: Option, - // is_pending: bool, - // }, - // ParticipantProject { - // project_id: u64, - // worktree_root_names: Vec, - // host_user_id: u64, - // is_last: bool, - // }, - // ParticipantScreen { - // peer_id: Option, - // is_last: bool, - // }, + CallParticipant { + user: Arc, + peer_id: Option, + is_pending: bool, + }, + ParticipantProject { + project_id: u64, + worktree_root_names: Vec, + host_user_id: u64, + is_last: bool, + }, + ParticipantScreen { + peer_id: Option, + is_last: bool, + }, IncomingRequest(Arc), OutgoingRequest(Arc), // ChannelInvite(Arc), @@ -368,12 +371,12 @@ enum ListEntry { depth: usize, has_children: bool, }, - // ChannelNotes { - // channel_id: ChannelId, - // }, - // ChannelChat { - // channel_id: ChannelId, - // }, + ChannelNotes { + channel_id: ChannelId, + }, + ChannelChat { + channel_id: ChannelId, + }, ChannelEditor { depth: usize, }, @@ -706,136 +709,136 @@ impl CollabPanel { let prev_selected_entry = self.selection.and_then(|ix| self.entries.get(ix).cloned()); let old_entries = mem::take(&mut self.entries); - let scroll_to_top = false; - - // if let Some(room) = ActiveCall::global(cx).read(cx).room() { - // self.entries.push(ListEntry::Header(Section::ActiveCall)); - // if !old_entries - // .iter() - // .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall))) - // { - // scroll_to_top = true; - // } + let mut scroll_to_top = false; - // if !self.collapsed_sections.contains(&Section::ActiveCall) { - // let room = room.read(cx); + if let Some(room) = ActiveCall::global(cx).read(cx).room() { + self.entries.push(ListEntry::Header(Section::ActiveCall)); + if !old_entries + .iter() + .any(|entry| matches!(entry, ListEntry::Header(Section::ActiveCall))) + { + scroll_to_top = true; + } - // if let Some(channel_id) = room.channel_id() { - // self.entries.push(ListEntry::ChannelNotes { channel_id }); - // self.entries.push(ListEntry::ChannelChat { channel_id }) - // } + if !self.collapsed_sections.contains(&Section::ActiveCall) { + let room = room.read(cx); - // // Populate the active user. - // if let Some(user) = user_store.current_user() { - // self.match_candidates.clear(); - // self.match_candidates.push(StringMatchCandidate { - // id: 0, - // string: user.github_login.clone(), - // char_bag: user.github_login.chars().collect(), - // }); - // let matches = executor.block(match_strings( - // &self.match_candidates, - // &query, - // true, - // usize::MAX, - // &Default::default(), - // executor.clone(), - // )); - // if !matches.is_empty() { - // let user_id = user.id; - // self.entries.push(ListEntry::CallParticipant { - // user, - // peer_id: None, - // is_pending: false, - // }); - // let mut projects = room.local_participant().projects.iter().peekable(); - // while let Some(project) = projects.next() { - // self.entries.push(ListEntry::ParticipantProject { - // project_id: project.id, - // worktree_root_names: project.worktree_root_names.clone(), - // host_user_id: user_id, - // is_last: projects.peek().is_none() && !room.is_screen_sharing(), - // }); - // } - // if room.is_screen_sharing() { - // self.entries.push(ListEntry::ParticipantScreen { - // peer_id: None, - // is_last: true, - // }); - // } - // } - // } + if let Some(channel_id) = room.channel_id() { + self.entries.push(ListEntry::ChannelNotes { channel_id }); + self.entries.push(ListEntry::ChannelChat { channel_id }) + } - // // Populate remote participants. - // self.match_candidates.clear(); - // self.match_candidates - // .extend(room.remote_participants().iter().map(|(_, participant)| { - // StringMatchCandidate { - // id: participant.user.id as usize, - // string: participant.user.github_login.clone(), - // char_bag: participant.user.github_login.chars().collect(), - // } - // })); - // let matches = executor.block(match_strings( - // &self.match_candidates, - // &query, - // true, - // usize::MAX, - // &Default::default(), - // executor.clone(), - // )); - // for mat in matches { - // let user_id = mat.candidate_id as u64; - // let participant = &room.remote_participants()[&user_id]; - // self.entries.push(ListEntry::CallParticipant { - // user: participant.user.clone(), - // peer_id: Some(participant.peer_id), - // is_pending: false, - // }); - // let mut projects = participant.projects.iter().peekable(); - // while let Some(project) = projects.next() { - // self.entries.push(ListEntry::ParticipantProject { - // project_id: project.id, - // worktree_root_names: project.worktree_root_names.clone(), - // host_user_id: participant.user.id, - // is_last: projects.peek().is_none() - // && participant.video_tracks.is_empty(), - // }); - // } - // if !participant.video_tracks.is_empty() { - // self.entries.push(ListEntry::ParticipantScreen { - // peer_id: Some(participant.peer_id), - // is_last: true, - // }); - // } - // } + // Populate the active user. + if let Some(user) = user_store.current_user() { + self.match_candidates.clear(); + self.match_candidates.push(StringMatchCandidate { + id: 0, + string: user.github_login.clone(), + char_bag: user.github_login.chars().collect(), + }); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + if !matches.is_empty() { + let user_id = user.id; + self.entries.push(ListEntry::CallParticipant { + user, + peer_id: None, + is_pending: false, + }); + let mut projects = room.local_participant().projects.iter().peekable(); + while let Some(project) = projects.next() { + self.entries.push(ListEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: user_id, + is_last: projects.peek().is_none() && !room.is_screen_sharing(), + }); + } + if room.is_screen_sharing() { + self.entries.push(ListEntry::ParticipantScreen { + peer_id: None, + is_last: true, + }); + } + } + } - // // Populate pending participants. - // self.match_candidates.clear(); - // self.match_candidates - // .extend(room.pending_participants().iter().enumerate().map( - // |(id, participant)| StringMatchCandidate { - // id, - // string: participant.github_login.clone(), - // char_bag: participant.github_login.chars().collect(), - // }, - // )); - // let matches = executor.block(match_strings( - // &self.match_candidates, - // &query, - // true, - // usize::MAX, - // &Default::default(), - // executor.clone(), - // )); - // self.entries - // .extend(matches.iter().map(|mat| ListEntry::CallParticipant { - // user: room.pending_participants()[mat.candidate_id].clone(), - // peer_id: None, - // is_pending: true, - // })); - // } - // } + // Populate remote participants. + self.match_candidates.clear(); + self.match_candidates + .extend(room.remote_participants().iter().map(|(_, participant)| { + StringMatchCandidate { + id: participant.user.id as usize, + string: participant.user.github_login.clone(), + char_bag: participant.user.github_login.chars().collect(), + } + })); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + for mat in matches { + let user_id = mat.candidate_id as u64; + let participant = &room.remote_participants()[&user_id]; + self.entries.push(ListEntry::CallParticipant { + user: participant.user.clone(), + peer_id: Some(participant.peer_id), + is_pending: false, + }); + let mut projects = participant.projects.iter().peekable(); + while let Some(project) = projects.next() { + self.entries.push(ListEntry::ParticipantProject { + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + host_user_id: participant.user.id, + is_last: projects.peek().is_none() + && participant.video_tracks.is_empty(), + }); + } + if !participant.video_tracks.is_empty() { + self.entries.push(ListEntry::ParticipantScreen { + peer_id: Some(participant.peer_id), + is_last: true, + }); + } + } + + // Populate pending participants. + self.match_candidates.clear(); + self.match_candidates + .extend(room.pending_participants().iter().enumerate().map( + |(id, participant)| StringMatchCandidate { + id, + string: participant.github_login.clone(), + char_bag: participant.github_login.chars().collect(), + }, + )); + let matches = executor.block(match_strings( + &self.match_candidates, + &query, + true, + usize::MAX, + &Default::default(), + executor.clone(), + )); + self.entries + .extend(matches.iter().map(|mat| ListEntry::CallParticipant { + user: room.pending_participants()[mat.candidate_id].clone(), + peer_id: None, + is_pending: true, + })); + } + } let mut request_entries = Vec::new(); @@ -1133,290 +1136,234 @@ impl CollabPanel { cx.notify(); } - // fn render_call_participant( - // user: &User, - // peer_id: Option, - // user_store: ModelHandle, - // is_pending: bool, - // is_selected: bool, - // theme: &theme::Theme, - // cx: &mut ViewContext, - // ) -> AnyElement { - // enum CallParticipant {} - // enum CallParticipantTooltip {} - // enum LeaveCallButton {} - // enum LeaveCallTooltip {} - - // let collab_theme = &theme.collab_panel; - - // let is_current_user = - // user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); - - // let content = MouseEventHandler::new::( - // user.id as usize, - // cx, - // |mouse_state, cx| { - // let style = if is_current_user { - // *collab_theme - // .contact_row - // .in_state(is_selected) - // .style_for(&mut Default::default()) - // } else { - // *collab_theme - // .contact_row - // .in_state(is_selected) - // .style_for(mouse_state) - // }; - - // Flex::row() - // .with_children(user.avatar.clone().map(|avatar| { - // Image::from_data(avatar) - // .with_style(collab_theme.contact_avatar) - // .aligned() - // .left() - // })) - // .with_child( - // Label::new( - // user.github_login.clone(), - // collab_theme.contact_username.text.clone(), - // ) - // .contained() - // .with_style(collab_theme.contact_username.container) - // .aligned() - // .left() - // .flex(1., true), - // ) - // .with_children(if is_pending { - // Some( - // Label::new("Calling", collab_theme.calling_indicator.text.clone()) - // .contained() - // .with_style(collab_theme.calling_indicator.container) - // .aligned() - // .into_any(), - // ) - // } else if is_current_user { - // Some( - // MouseEventHandler::new::(0, cx, |state, _| { - // render_icon_button( - // theme - // .collab_panel - // .leave_call_button - // .style_for(is_selected, state), - // "icons/exit.svg", - // ) - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .on_click(MouseButton::Left, |_, _, cx| { - // Self::leave_call(cx); - // }) - // .with_tooltip::( - // 0, - // "Leave call", - // None, - // theme.tooltip.clone(), - // cx, - // ) - // .into_any(), - // ) - // } else { - // None - // }) - // .constrained() - // .with_height(collab_theme.row_height) - // .contained() - // .with_style(style) - // }, - // ); - - // if is_current_user || is_pending || peer_id.is_none() { - // return content.into_any(); - // } - - // let tooltip = format!("Follow {}", user.github_login); - - // content - // .on_click(MouseButton::Left, move |_, this, cx| { - // if let Some(workspace) = this.workspace.upgrade(cx) { - // workspace - // .update(cx, |workspace, cx| workspace.follow(peer_id.unwrap(), cx)) - // .map(|task| task.detach_and_log_err(cx)); - // } - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .with_tooltip::( - // user.id as usize, - // tooltip, - // Some(Box::new(FollowNextCollaborator)), - // theme.tooltip.clone(), - // cx, - // ) - // .into_any() - // } + fn render_call_participant( + &self, + user: Arc, + peer_id: Option, + is_pending: bool, + cx: &mut ViewContext, + ) -> impl IntoElement { + let is_current_user = + self.user_store.read(cx).current_user().map(|user| user.id) == Some(user.id); + let tooltip = format!("Follow {}", user.github_login); - // fn render_participant_project( - // project_id: u64, - // worktree_root_names: &[String], - // host_user_id: u64, - // is_current: bool, - // is_last: bool, - // is_selected: bool, - // theme: &theme::Theme, - // cx: &mut ViewContext, - // ) -> AnyElement { - // enum JoinProject {} - // enum JoinProjectTooltip {} - - // let collab_theme = &theme.collab_panel; - // let host_avatar_width = collab_theme - // .contact_avatar - // .width - // .or(collab_theme.contact_avatar.height) - // .unwrap_or(0.); - // let tree_branch = collab_theme.tree_branch; - // let project_name = if worktree_root_names.is_empty() { - // "untitled".to_string() - // } else { - // worktree_root_names.join(", ") - // }; - - // let content = - // MouseEventHandler::new::(project_id as usize, cx, |mouse_state, cx| { - // let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); - // let row = if is_current { - // collab_theme - // .project_row - // .in_state(true) - // .style_for(&mut Default::default()) - // } else { - // collab_theme - // .project_row - // .in_state(is_selected) - // .style_for(mouse_state) - // }; - - // Flex::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // is_last, - // vec2f(host_avatar_width, collab_theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/file_icons/folder.svg") - // .with_color(collab_theme.channel_hash.color) - // .constrained() - // .with_width(collab_theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new(project_name.clone(), row.name.text.clone()) - // .aligned() - // .left() - // .contained() - // .with_style(row.name.container) - // .flex(1., false), - // ) - // .constrained() - // .with_height(collab_theme.row_height) - // .contained() - // .with_style(row.container) - // }); - - // if is_current { - // return content.into_any(); - // } - - // content - // .with_cursor_style(CursorStyle::PointingHand) - // .on_click(MouseButton::Left, move |_, this, cx| { - // if let Some(workspace) = this.workspace.upgrade(cx) { - // let app_state = workspace.read(cx).app_state().clone(); - // workspace::join_remote_project(project_id, host_user_id, app_state, cx) - // .detach_and_log_err(cx); - // } - // }) - // .with_tooltip::( - // project_id as usize, - // format!("Open {}", project_name), - // None, - // theme.tooltip.clone(), - // cx, - // ) - // .into_any() - // } + ListItem::new(SharedString::from(user.github_login.clone())) + .left_child(Avatar::data(user.avatar.clone().unwrap())) + .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() + }), + ) + .when_some(peer_id, |this, peer_id| { + this.tooltip(move |cx| Tooltip::text(tooltip.clone(), cx)) + .on_click(cx.listener(move |this, _, cx| { + this.workspace + .update(cx, |workspace, cx| workspace.follow(peer_id, cx)); + })) + }) + } - // fn render_participant_screen( - // peer_id: Option, - // is_last: bool, - // is_selected: bool, - // theme: &theme::CollabPanel, - // cx: &mut ViewContext, - // ) -> AnyElement { - // enum OpenSharedScreen {} - - // let host_avatar_width = theme - // .contact_avatar - // .width - // .or(theme.contact_avatar.height) - // .unwrap_or(0.); - // let tree_branch = theme.tree_branch; - - // let handler = MouseEventHandler::new::( - // peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize, - // cx, - // |mouse_state, cx| { - // let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); - // let row = theme - // .project_row - // .in_state(is_selected) - // .style_for(mouse_state); - - // Flex::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // is_last, - // vec2f(host_avatar_width, theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/desktop.svg") - // .with_color(theme.channel_hash.color) - // .constrained() - // .with_width(theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new("Screen", row.name.text.clone()) - // .aligned() - // .left() - // .contained() - // .with_style(row.name.container) - // .flex(1., false), - // ) - // .constrained() - // .with_height(theme.row_height) - // .contained() - // .with_style(row.container) - // }, - // ); - // if peer_id.is_none() { - // return handler.into_any(); - // } - // handler - // .with_cursor_style(CursorStyle::PointingHand) - // .on_click(MouseButton::Left, move |_, this, cx| { - // if let Some(workspace) = this.workspace.upgrade(cx) { - // workspace.update(cx, |workspace, cx| { - // workspace.open_shared_screen(peer_id.unwrap(), cx) - // }); - // } - // }) - // .into_any() - // } + fn render_participant_project( + &self, + project_id: u64, + worktree_root_names: &[String], + host_user_id: u64, + // is_current: bool, + is_last: bool, + // is_selected: bool, + // theme: &theme::Theme, + cx: &mut ViewContext, + ) -> impl IntoElement { + let project_name: SharedString = if worktree_root_names.is_empty() { + "untitled".to_string() + } else { + worktree_root_names.join(", ") + } + .into(); + + let theme = cx.theme(); + + ListItem::new(project_id as usize) + .on_click(cx.listener(move |this, _, cx| { + this.workspace.update(cx, |workspace, cx| { + let app_state = workspace.app_state().clone(); + workspace::join_remote_project(project_id, host_user_id, app_state, cx) + .detach_and_log_err(cx); + }); + })) + .left_child(IconButton::new(0, Icon::Folder)) + .child( + h_stack() + .w_full() + .justify_between() + .child(render_tree_branch(is_last, cx)) + .child(Label::new(project_name.clone())), + ) + .tooltip(move |cx| Tooltip::text(format!("Open {}", project_name), cx)) + + // enum JoinProject {} + // enum JoinProjectTooltip {} + + // let collab_theme = &theme.collab_panel; + // let host_avatar_width = collab_theme + // .contact_avatar + // .width + // .or(collab_theme.contact_avatar.height) + // .unwrap_or(0.); + // let tree_branch = collab_theme.tree_branch; + + // let content = + // MouseEventHandler::new::(project_id as usize, cx, |mouse_state, cx| { + // let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + // let row = if is_current { + // collab_theme + // .project_row + // .in_state(true) + // .style_for(&mut Default::default()) + // } else { + // collab_theme + // .project_row + // .in_state(is_selected) + // .style_for(mouse_state) + // }; + + // Flex::row() + // .with_child(render_tree_branch( + // tree_branch, + // &row.name.text, + // is_last, + // vec2f(host_avatar_width, collab_theme.row_height), + // cx.font_cache(), + // )) + // .with_child( + // Svg::new("icons/file_icons/folder.svg") + // .with_color(collab_theme.channel_hash.color) + // .constrained() + // .with_width(collab_theme.channel_hash.width) + // .aligned() + // .left(), + // ) + // .with_child( + // Label::new(project_name.clone(), row.name.text.clone()) + // .aligned() + // .left() + // .contained() + // .with_style(row.name.container) + // .flex(1., false), + // ) + // .constrained() + // .with_height(collab_theme.row_height) + // .contained() + // .with_style(row.container) + // }); + + // if is_current { + // return content.into_any(); + // } + + // content + // .with_cursor_style(CursorStyle::PointingHand) + // .on_click(MouseButton::Left, move |_, this, cx| { + // if let Some(workspace) = this.workspace.upgrade(cx) { + // let app_state = workspace.read(cx).app_state().clone(); + // workspace::join_remote_project(project_id, host_user_id, app_state, cx) + // .detach_and_log_err(cx); + // } + // }) + // .with_tooltip::( + // project_id as usize, + // format!("Open {}", project_name), + // None, + // theme.tooltip.clone(), + // cx, + // ) + // .into_any() + } + + fn render_participant_screen( + &self, + peer_id: Option, + is_last: bool, + cx: &mut ViewContext, + ) -> impl IntoElement { + // enum OpenSharedScreen {} + + // let host_avatar_width = theme + // .contact_avatar + // .width + // .or(theme.contact_avatar.height) + // .unwrap_or(0.); + // let tree_branch = theme.tree_branch; + + // let handler = MouseEventHandler::new::( + // peer_id.map(|id| id.as_u64()).unwrap_or(0) as usize, + // cx, + // |mouse_state, cx| { + // let tree_branch = *tree_branch.in_state(is_selected).style_for(mouse_state); + // let row = theme + // .project_row + // .in_state(is_selected) + // .style_for(mouse_state); + + // Flex::row() + // .with_child(render_tree_branch( + // tree_branch, + // &row.name.text, + // is_last, + // vec2f(host_avatar_width, theme.row_height), + // cx.font_cache(), + // )) + // .with_child( + // Svg::new("icons/desktop.svg") + // .with_color(theme.channel_hash.color) + // .constrained() + // .with_width(theme.channel_hash.width) + // .aligned() + // .left(), + // ) + // .with_child( + // Label::new("Screen", row.name.text.clone()) + // .aligned() + // .left() + // .contained() + // .with_style(row.name.container) + // .flex(1., false), + // ) + // .constrained() + // .with_height(theme.row_height) + // .contained() + // .with_style(row.container) + // }, + // ); + // if peer_id.is_none() { + // return handler.into_any(); + // } + // handler + // .with_cursor_style(CursorStyle::PointingHand) + // .on_click(MouseButton::Left, move |_, this, cx| { + // if let Some(workspace) = this.workspace.upgrade(cx) { + // workspace.update(cx, |workspace, cx| { + // workspace.open_shared_screen(peer_id.unwrap(), cx) + // }); + // } + // }) + // .into_any() + + div() + } fn take_editing_state(&mut self, cx: &mut ViewContext) -> bool { if let Some(_) = self.channel_editing_state.take() { @@ -1463,117 +1410,114 @@ impl CollabPanel { // .into_any() // } - // fn render_channel_notes( - // &self, - // channel_id: ChannelId, - // theme: &theme::CollabPanel, - // is_selected: bool, - // ix: usize, - // cx: &mut ViewContext, - // ) -> AnyElement { - // enum ChannelNotes {} - // let host_avatar_width = theme - // .contact_avatar - // .width - // .or(theme.contact_avatar.height) - // .unwrap_or(0.); - - // MouseEventHandler::new::(ix as usize, cx, |state, cx| { - // let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); - // let row = theme.project_row.in_state(is_selected).style_for(state); - - // Flex::::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // false, - // vec2f(host_avatar_width, theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/file.svg") - // .with_color(theme.channel_hash.color) - // .constrained() - // .with_width(theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new("notes", theme.channel_name.text.clone()) - // .contained() - // .with_style(theme.channel_name.container) - // .aligned() - // .left() - // .flex(1., true), - // ) - // .constrained() - // .with_height(theme.row_height) - // .contained() - // .with_style(*theme.channel_row.style_for(is_selected, state)) - // .with_padding_left(theme.channel_row.default_style().padding.left) - // }) - // .on_click(MouseButton::Left, move |_, this, cx| { - // this.open_channel_notes(&OpenChannelNotes { channel_id }, cx); - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .into_any() - // } + fn render_channel_notes( + &self, + channel_id: ChannelId, + cx: &mut ViewContext, + ) -> impl IntoElement { + // enum ChannelNotes {} + // let host_avatar_width = theme + // .contact_avatar + // .width + // .or(theme.contact_avatar.height) + // .unwrap_or(0.); + + // MouseEventHandler::new::(ix as usize, cx, |state, cx| { + // let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); + // let row = theme.project_row.in_state(is_selected).style_for(state); + + // Flex::::row() + // .with_child(render_tree_branch( + // tree_branch, + // &row.name.text, + // false, + // vec2f(host_avatar_width, theme.row_height), + // cx.font_cache(), + // )) + // .with_child( + // Svg::new("icons/file.svg") + // .with_color(theme.channel_hash.color) + // .constrained() + // .with_width(theme.channel_hash.width) + // .aligned() + // .left(), + // ) + // .with_child( + // Label::new("notes", theme.channel_name.text.clone()) + // .contained() + // .with_style(theme.channel_name.container) + // .aligned() + // .left() + // .flex(1., true), + // ) + // .constrained() + // .with_height(theme.row_height) + // .contained() + // .with_style(*theme.channel_row.style_for(is_selected, state)) + // .with_padding_left(theme.channel_row.default_style().padding.left) + // }) + // .on_click(MouseButton::Left, move |_, this, cx| { + // this.open_channel_notes(&OpenChannelNotes { channel_id }, cx); + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .into_any() - // fn render_channel_chat( - // &self, - // channel_id: ChannelId, - // theme: &theme::CollabPanel, - // is_selected: bool, - // ix: usize, - // cx: &mut ViewContext, - // ) -> AnyElement { - // enum ChannelChat {} - // let host_avatar_width = theme - // .contact_avatar - // .width - // .or(theme.contact_avatar.height) - // .unwrap_or(0.); - - // MouseEventHandler::new::(ix as usize, cx, |state, cx| { - // let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); - // let row = theme.project_row.in_state(is_selected).style_for(state); - - // Flex::::row() - // .with_child(render_tree_branch( - // tree_branch, - // &row.name.text, - // true, - // vec2f(host_avatar_width, theme.row_height), - // cx.font_cache(), - // )) - // .with_child( - // Svg::new("icons/conversations.svg") - // .with_color(theme.channel_hash.color) - // .constrained() - // .with_width(theme.channel_hash.width) - // .aligned() - // .left(), - // ) - // .with_child( - // Label::new("chat", theme.channel_name.text.clone()) - // .contained() - // .with_style(theme.channel_name.container) - // .aligned() - // .left() - // .flex(1., true), - // ) - // .constrained() - // .with_height(theme.row_height) - // .contained() - // .with_style(*theme.channel_row.style_for(is_selected, state)) - // .with_padding_left(theme.channel_row.default_style().padding.left) - // }) - // .on_click(MouseButton::Left, move |_, this, cx| { - // this.join_channel_chat(&JoinChannelChat { channel_id }, cx); - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .into_any() - // } + div() + } + + fn render_channel_chat( + &self, + channel_id: ChannelId, + cx: &mut ViewContext, + ) -> impl IntoElement { + // enum ChannelChat {} + // let host_avatar_width = theme + // .contact_avatar + // .width + // .or(theme.contact_avatar.height) + // .unwrap_or(0.); + + // MouseEventHandler::new::(ix as usize, cx, |state, cx| { + // let tree_branch = *theme.tree_branch.in_state(is_selected).style_for(state); + // let row = theme.project_row.in_state(is_selected).style_for(state); + + // Flex::::row() + // .with_child(render_tree_branch( + // tree_branch, + // &row.name.text, + // true, + // vec2f(host_avatar_width, theme.row_height), + // cx.font_cache(), + // )) + // .with_child( + // Svg::new("icons/conversations.svg") + // .with_color(theme.channel_hash.color) + // .constrained() + // .with_width(theme.channel_hash.width) + // .aligned() + // .left(), + // ) + // .with_child( + // Label::new("chat", theme.channel_name.text.clone()) + // .contained() + // .with_style(theme.channel_name.container) + // .aligned() + // .left() + // .flex(1., true), + // ) + // .constrained() + // .with_height(theme.row_height) + // .contained() + // .with_style(*theme.channel_row.style_for(is_selected, state)) + // .with_padding_left(theme.channel_row.default_style().padding.left) + // }) + // .on_click(MouseButton::Left, move |_, this, cx| { + // this.join_channel_chat(&JoinChannelChat { channel_id }, cx); + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .into_any() + div() + } // fn render_channel_invite( // channel: Arc, @@ -2058,13 +2002,11 @@ impl CollabPanel { } fn invite_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { - todo!(); - // self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx); + self.show_channel_modal(channel_id, channel_modal::Mode::InviteMembers, cx); } fn manage_members(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { - todo!(); - // self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx); + self.show_channel_modal(channel_id, channel_modal::Mode::ManageMembers, cx); } fn remove_selected_channel(&mut self, _: &Remove, cx: &mut ViewContext) { @@ -2156,38 +2098,36 @@ impl CollabPanel { }) } - // fn show_channel_modal( - // &mut self, - // channel_id: ChannelId, - // mode: channel_modal::Mode, - // cx: &mut ViewContext, - // ) { - // let workspace = self.workspace.clone(); - // let user_store = self.user_store.clone(); - // let channel_store = self.channel_store.clone(); - // let members = self.channel_store.update(cx, |channel_store, cx| { - // channel_store.get_channel_member_details(channel_id, cx) - // }); - - // cx.spawn(|_, mut cx| async move { - // let members = members.await?; - // workspace.update(&mut cx, |workspace, cx| { - // workspace.toggle_modal(cx, |_, cx| { - // cx.add_view(|cx| { - // ChannelModal::new( - // user_store.clone(), - // channel_store.clone(), - // channel_id, - // mode, - // members, - // cx, - // ) - // }) - // }); - // }) - // }) - // .detach(); - // } + fn show_channel_modal( + &mut self, + channel_id: ChannelId, + mode: channel_modal::Mode, + cx: &mut ViewContext, + ) { + let workspace = self.workspace.clone(); + let user_store = self.user_store.clone(); + let channel_store = self.channel_store.clone(); + let members = self.channel_store.update(cx, |channel_store, cx| { + channel_store.get_channel_member_details(channel_id, cx) + }); + + cx.spawn(|_, mut cx| async move { + let members = members.await?; + workspace.update(&mut cx, |workspace, cx| { + workspace.toggle_modal(cx, |cx| { + ChannelModal::new( + user_store.clone(), + channel_store.clone(), + channel_id, + mode, + members, + cx, + ) + }); + }) + }) + .detach(); + } // fn remove_selected_channel(&mut self, action: &RemoveChannel, cx: &mut ViewContext) { // self.remove_channel(action.channel_id, cx) @@ -2275,20 +2215,19 @@ impl CollabPanel { } fn join_channel(&self, channel_id: u64, cx: &mut ViewContext) { + let Some(workspace) = self.workspace.upgrade() else { + return; + }; let Some(handle) = cx.window_handle().downcast::() else { return; }; - let active_call = ActiveCall::global(cx); - cx.spawn(|_, mut cx| async move { - active_call - .update(&mut cx, |active_call, cx| { - active_call.join_channel(channel_id, Some(handle), cx) - }) - .log_err()? - .await - .notify_async_err(&mut cx) - }) - .detach() + workspace::join_channel( + channel_id, + workspace.read(cx).app_state().clone(), + Some(handle), + cx, + ) + .detach_and_log_err(cx) } fn join_channel_chat(&mut self, channel_id: ChannelId, cx: &mut ViewContext) { @@ -2392,6 +2331,36 @@ impl CollabPanel { ListEntry::ChannelEditor { depth } => { self.render_channel_editor(depth, cx).into_any_element() } + ListEntry::CallParticipant { + user, + peer_id, + is_pending, + } => self + .render_call_participant(user, peer_id, is_pending, cx) + .into_any_element(), + ListEntry::ParticipantProject { + project_id, + worktree_root_names, + host_user_id, + is_last, + } => self + .render_participant_project( + project_id, + &worktree_root_names, + host_user_id, + is_last, + cx, + ) + .into_any_element(), + ListEntry::ParticipantScreen { peer_id, is_last } => self + .render_participant_screen(peer_id, is_last, cx) + .into_any_element(), + ListEntry::ChannelNotes { channel_id } => { + self.render_channel_notes(channel_id, cx).into_any_element() + } + ListEntry::ChannelChat { channel_id } => { + self.render_channel_chat(channel_id, cx).into_any_element() + } } }), ), @@ -2405,37 +2374,36 @@ impl CollabPanel { is_collapsed: bool, cx: &ViewContext, ) -> impl IntoElement { - // let mut channel_link = None; - // let mut channel_tooltip_text = None; - // let mut channel_icon = None; + let mut channel_link = None; + let mut channel_tooltip_text = None; + let mut channel_icon = None; // let mut is_dragged_over = false; let text = match section { Section::ActiveCall => { - // let channel_name = maybe!({ - // let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?; - - // let channel = self.channel_store.read(cx).channel_for_id(channel_id)?; - - // channel_link = Some(channel.link()); - // (channel_icon, channel_tooltip_text) = match channel.visibility { - // proto::ChannelVisibility::Public => { - // (Some("icons/public.svg"), Some("Copy public channel link.")) - // } - // proto::ChannelVisibility::Members => { - // (Some("icons/hash.svg"), Some("Copy private channel link.")) - // } - // }; - - // Some(channel.name.as_str()) - // }); - - // if let Some(name) = channel_name { - // SharedString::from(format!("{}", name)) - // } else { - // SharedString::from("Current Call") - // } - todo!() + let channel_name = maybe!({ + let channel_id = ActiveCall::global(cx).read(cx).channel_id(cx)?; + + let channel = self.channel_store.read(cx).channel_for_id(channel_id)?; + + channel_link = Some(channel.link()); + (channel_icon, channel_tooltip_text) = match channel.visibility { + proto::ChannelVisibility::Public => { + (Some("icons/public.svg"), Some("Copy public channel link.")) + } + proto::ChannelVisibility::Members => { + (Some("icons/hash.svg"), Some("Copy private channel link.")) + } + }; + + Some(channel.name.as_str()) + }); + + if let Some(name) = channel_name { + SharedString::from(format!("{}", name)) + } else { + SharedString::from("Current Call") + } } Section::ContactRequests => SharedString::from("Requests"), Section::Contacts => SharedString::from("Contacts"), @@ -2446,34 +2414,15 @@ impl CollabPanel { }; let button = match section { - Section::ActiveCall => - // channel_link.map(|channel_link| { - // let channel_link_copy = channel_link.clone(); - // MouseEventHandler::new::(0, cx, |state, _| { - // render_icon_button( - // theme - // .collab_panel - // .leave_call_button - // .style_for(is_selected, state), - // "icons/link.svg", - // ) - // }) - // .with_cursor_style(CursorStyle::PointingHand) - // .on_click(MouseButton::Left, move |_, _, cx| { - // let item = ClipboardItem::new(channel_link_copy.clone()); - // cx.write_to_clipboard(item) - // }) - // .with_tooltip::( - // 0, - // channel_tooltip_text.unwrap(), - // None, - // tooltip_style.clone(), - // cx, - // ) - // }), - { - todo!() - } + Section::ActiveCall => channel_link.map(|channel_link| { + let channel_link_copy = channel_link.clone(); + IconButton::new("channel-link", Icon::Check) + .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)) + }), Section::Contacts => Some( IconButton::new("add-contact", Icon::Plus) .on_click(cx.listener(|this, _, cx| this.toggle_contact_finder(cx))) @@ -2511,7 +2460,7 @@ impl CollabPanel { } else { el.child( ListHeader::new(text) - .when_some(button, |el, button| el.right_button(button)) + .when_some(button, |el, button| el.meta(button)) .selected(is_selected), ) } @@ -2546,15 +2495,7 @@ impl CollabPanel { let user_id = contact.user.id; let github_login = SharedString::from(contact.user.github_login.clone()); let mut item = ListItem::new(github_login.clone()) - .on_click(cx.listener(move |this, _, cx| { - this.workspace - .update(cx, |this, cx| { - this.call_state() - .invite(user_id, None, cx) - .detach_and_log_err(cx) - }) - .log_err(); - })) + .on_click(cx.listener(move |this, _, cx| this.call(user_id, cx))) .child( h_stack() .w_full() @@ -3177,50 +3118,49 @@ impl CollabPanel { } } -// fn render_tree_branch( -// branch_style: theme::TreeBranch, -// row_style: &TextStyle, -// is_last: bool, -// size: Vector2F, -// font_cache: &FontCache, -// ) -> gpui::elements::ConstrainedBox { -// let line_height = row_style.line_height(font_cache); -// let cap_height = row_style.cap_height(font_cache); -// let baseline_offset = row_style.baseline_offset(font_cache) + (size.y() - line_height) / 2.; - -// Canvas::new(move |bounds, _, _, cx| { -// cx.paint_layer(None, |cx| { -// let start_x = bounds.min_x() + (bounds.width() / 2.) - (branch_style.width / 2.); -// let end_x = bounds.max_x(); -// let start_y = bounds.min_y(); -// let end_y = bounds.min_y() + baseline_offset - (cap_height / 2.); - -// cx.scene().push_quad(gpui::Quad { -// bounds: RectF::from_points( -// vec2f(start_x, start_y), -// vec2f( -// start_x + branch_style.width, -// if is_last { end_y } else { bounds.max_y() }, -// ), -// ), -// background: Some(branch_style.color), -// border: gpui::Border::default(), -// corner_radii: (0.).into(), -// }); -// cx.scene().push_quad(gpui::Quad { -// bounds: RectF::from_points( -// vec2f(start_x, end_y), -// vec2f(end_x, end_y + branch_style.width), -// ), -// background: Some(branch_style.color), -// border: gpui::Border::default(), -// corner_radii: (0.).into(), -// }); -// }) -// }) -// .constrained() -// .with_width(size.x()) -// } +fn render_tree_branch(is_last: bool, cx: &mut WindowContext) -> impl IntoElement { + let text_style = cx.text_style(); + let rem_size = cx.rem_size(); + let text_system = cx.text_system(); + let font_id = text_system.font_id(&text_style.font()).unwrap(); + let font_size = text_style.font_size.to_pixels(rem_size); + let line_height = text_style.line_height_in_pixels(rem_size); + let cap_height = text_system.cap_height(font_id, font_size); + let baseline_offset = text_system.baseline_offset(font_id, font_size, line_height); + let width = cx.rem_size() * 2.5; + let thickness = px(2.); + let color = cx.theme().colors().text; + + canvas(move |bounds, cx| { + let start_x = bounds.left() + (bounds.size.width / 2.) - (width / 2.); + let end_x = bounds.right(); + let start_y = bounds.top(); + let end_y = bounds.top() + baseline_offset - (cap_height / 2.); + + cx.paint_quad( + Bounds::from_corners( + point(start_x, start_y), + point( + start_x + thickness, + if is_last { end_y } else { bounds.bottom() }, + ), + ), + Default::default(), + color, + Default::default(), + Hsla::transparent_black(), + ); + cx.paint_quad( + Bounds::from_corners(point(start_x, end_y), point(end_x, end_y + thickness)), + Default::default(), + color, + Default::default(), + Hsla::transparent_black(), + ); + }) + .w(width) + .h(line_height) +} impl Render for CollabPanel { type Element = Focusable
; @@ -3427,33 +3367,33 @@ impl PartialEq for ListEntry { return section_1 == section_2; } } - // ListEntry::CallParticipant { user: user_1, .. } => { - // if let ListEntry::CallParticipant { user: user_2, .. } = other { - // return user_1.id == user_2.id; - // } - // } - // ListEntry::ParticipantProject { - // project_id: project_id_1, - // .. - // } => { - // if let ListEntry::ParticipantProject { - // project_id: project_id_2, - // .. - // } = other - // { - // return project_id_1 == project_id_2; - // } - // } - // ListEntry::ParticipantScreen { - // peer_id: peer_id_1, .. - // } => { - // if let ListEntry::ParticipantScreen { - // peer_id: peer_id_2, .. - // } = other - // { - // return peer_id_1 == peer_id_2; - // } - // } + ListEntry::CallParticipant { user: user_1, .. } => { + if let ListEntry::CallParticipant { user: user_2, .. } = other { + return user_1.id == user_2.id; + } + } + ListEntry::ParticipantProject { + project_id: project_id_1, + .. + } => { + if let ListEntry::ParticipantProject { + project_id: project_id_2, + .. + } = other + { + return project_id_1 == project_id_2; + } + } + ListEntry::ParticipantScreen { + peer_id: peer_id_1, .. + } => { + if let ListEntry::ParticipantScreen { + peer_id: peer_id_2, .. + } = other + { + return peer_id_1 == peer_id_2; + } + } ListEntry::Channel { channel: channel_1, .. } => { @@ -3464,22 +3404,22 @@ impl PartialEq for ListEntry { return channel_1.id == channel_2.id; } } - // ListEntry::ChannelNotes { channel_id } => { - // if let ListEntry::ChannelNotes { - // channel_id: other_id, - // } = other - // { - // return channel_id == other_id; - // } - // } - // ListEntry::ChannelChat { channel_id } => { - // if let ListEntry::ChannelChat { - // channel_id: other_id, - // } = other - // { - // return channel_id == other_id; - // } - // } + ListEntry::ChannelNotes { channel_id } => { + if let ListEntry::ChannelNotes { + channel_id: other_id, + } = other + { + return channel_id == other_id; + } + } + ListEntry::ChannelChat { channel_id } => { + if let ListEntry::ChannelChat { + channel_id: other_id, + } = other + { + return channel_id == other_id; + } + } // ListEntry::ChannelInvite(channel_1) => { // if let ListEntry::ChannelInvite(channel_2) = other { // return channel_1.id == channel_2.id; diff --git a/crates/collab_ui2/src/collab_panel/channel_modal.rs b/crates/collab_ui2/src/collab_panel/channel_modal.rs index 0ccf0894b25fc1fc04ed884769c87b78bfaa72fc..fc1a4c5fb7f8f9e3b7c2a63a885207306a7c1ed3 100644 --- a/crates/collab_ui2/src/collab_panel/channel_modal.rs +++ b/crates/collab_ui2/src/collab_panel/channel_modal.rs @@ -3,58 +3,54 @@ use client::{ proto::{self, ChannelRole, ChannelVisibility}, User, UserId, UserStore, }; -use context_menu::{ContextMenu, ContextMenuItem}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, - elements::*, - platform::{CursorStyle, MouseButton}, - AppContext, ClipboardItem, Entity, ModelHandle, MouseState, Task, View, ViewContext, - ViewHandle, + actions, div, AppContext, ClipboardItem, DismissEvent, Div, Entity, EventEmitter, + FocusableView, Model, ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, + WeakView, }; -use picker::{Picker, PickerDelegate, PickerEvent}; +use picker::{Picker, PickerDelegate}; use std::sync::Arc; +use ui::v_stack; use util::TryFutureExt; -use workspace::Modal; actions!( - channel_modal, - [ - SelectNextControl, - ToggleMode, - ToggleMemberAdmin, - RemoveMember - ] + SelectNextControl, + ToggleMode, + ToggleMemberAdmin, + RemoveMember ); -pub fn init(cx: &mut AppContext) { - Picker::::init(cx); - cx.add_action(ChannelModal::toggle_mode); - cx.add_action(ChannelModal::toggle_member_admin); - cx.add_action(ChannelModal::remove_member); - cx.add_action(ChannelModal::dismiss); -} +// pub fn init(cx: &mut AppContext) { +// Picker::::init(cx); +// cx.add_action(ChannelModal::toggle_mode); +// cx.add_action(ChannelModal::toggle_member_admin); +// cx.add_action(ChannelModal::remove_member); +// cx.add_action(ChannelModal::dismiss); +// } pub struct ChannelModal { - picker: ViewHandle>, - channel_store: ModelHandle, + picker: View>, + channel_store: Model, channel_id: ChannelId, has_focus: bool, } impl ChannelModal { pub fn new( - user_store: ModelHandle, - channel_store: ModelHandle, + user_store: Model, + channel_store: Model, channel_id: ChannelId, mode: Mode, members: Vec, cx: &mut ViewContext, ) -> Self { cx.observe(&channel_store, |_, _, cx| cx.notify()).detach(); - let picker = cx.add_view(|cx| { + let channel_modal = cx.view().downgrade(); + let picker = cx.build_view(|cx| { Picker::new( ChannelModalDelegate { + channel_modal, matching_users: Vec::new(), matching_member_indices: Vec::new(), selected_index: 0, @@ -64,20 +60,17 @@ impl ChannelModal { match_candidates: Vec::new(), members, mode, - context_menu: cx.add_view(|cx| { - let mut menu = ContextMenu::new(cx.view_id(), cx); - menu.set_position_mode(OverlayPositionMode::Local); - menu - }), + // context_menu: cx.add_view(|cx| { + // let mut menu = ContextMenu::new(cx.view_id(), cx); + // menu.set_position_mode(OverlayPositionMode::Local); + // menu + // }), }, cx, ) - .with_theme(|theme| theme.collab_panel.tabbed_modal.picker.clone()) }); - cx.subscribe(&picker, |_, _, e, cx| cx.emit(*e)).detach(); - - let has_focus = picker.read(cx).has_focus(); + let has_focus = picker.focus_handle(cx).contains_focused(cx); Self { picker, @@ -88,7 +81,7 @@ impl ChannelModal { } fn toggle_mode(&mut self, _: &ToggleMode, cx: &mut ViewContext) { - let mode = match self.picker.read(cx).delegate().mode { + let mode = match self.picker.read(cx).delegate.mode { Mode::ManageMembers => Mode::InviteMembers, Mode::InviteMembers => Mode::ManageMembers, }; @@ -103,20 +96,20 @@ impl ChannelModal { let mut members = channel_store .update(&mut cx, |channel_store, cx| { channel_store.get_channel_member_details(channel_id, cx) - }) + })? .await?; members.sort_by(|a, b| a.sort_key().cmp(&b.sort_key())); this.update(&mut cx, |this, cx| { this.picker - .update(cx, |picker, _| picker.delegate_mut().members = members); + .update(cx, |picker, _| picker.delegate.members = members); })?; } this.update(&mut cx, |this, cx| { this.picker.update(cx, |picker, cx| { - let delegate = picker.delegate_mut(); + let delegate = &mut picker.delegate; delegate.mode = mode; delegate.selected_index = 0; picker.set_query("", cx); @@ -131,203 +124,194 @@ impl ChannelModal { fn toggle_member_admin(&mut self, _: &ToggleMemberAdmin, cx: &mut ViewContext) { self.picker.update(cx, |picker, cx| { - picker.delegate_mut().toggle_selected_member_admin(cx); + picker.delegate.toggle_selected_member_admin(cx); }) } fn remove_member(&mut self, _: &RemoveMember, cx: &mut ViewContext) { self.picker.update(cx, |picker, cx| { - picker.delegate_mut().remove_selected_member(cx); + picker.delegate.remove_selected_member(cx); }); } fn dismiss(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { - cx.emit(PickerEvent::Dismiss); + cx.emit(DismissEvent); } } -impl Entity for ChannelModal { - type Event = PickerEvent; -} - -impl View for ChannelModal { - fn ui_name() -> &'static str { - "ChannelModal" - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - let theme = &theme::current(cx).collab_panel.tabbed_modal; - - let mode = self.picker.read(cx).delegate().mode; - let Some(channel) = self.channel_store.read(cx).channel_for_id(self.channel_id) else { - return Empty::new().into_any(); - }; - - enum InviteMembers {} - enum ManageMembers {} - - fn render_mode_button( - mode: Mode, - text: &'static str, - current_mode: Mode, - theme: &theme::TabbedModal, - cx: &mut ViewContext, - ) -> AnyElement { - let active = mode == current_mode; - MouseEventHandler::new::(0, cx, move |state, _| { - let contained_text = theme.tab_button.style_for(active, state); - Label::new(text, contained_text.text.clone()) - .contained() - .with_style(contained_text.container.clone()) - }) - .on_click(MouseButton::Left, move |_, this, cx| { - if !active { - this.set_mode(mode, cx); - } - }) - .with_cursor_style(CursorStyle::PointingHand) - .into_any() - } - - fn render_visibility( - channel_id: ChannelId, - visibility: ChannelVisibility, - theme: &theme::TabbedModal, - cx: &mut ViewContext, - ) -> AnyElement { - enum TogglePublic {} - - if visibility == ChannelVisibility::Members { - return Flex::row() - .with_child( - MouseEventHandler::new::(0, cx, move |state, _| { - let style = theme.visibility_toggle.style_for(state); - Label::new(format!("{}", "Public access: OFF"), style.text.clone()) - .contained() - .with_style(style.container.clone()) - }) - .on_click(MouseButton::Left, move |_, this, cx| { - this.channel_store - .update(cx, |channel_store, cx| { - channel_store.set_channel_visibility( - channel_id, - ChannelVisibility::Public, - cx, - ) - }) - .detach_and_log_err(cx); - }) - .with_cursor_style(CursorStyle::PointingHand), - ) - .into_any(); - } - - Flex::row() - .with_child( - MouseEventHandler::new::(0, cx, move |state, _| { - let style = theme.visibility_toggle.style_for(state); - Label::new(format!("{}", "Public access: ON"), style.text.clone()) - .contained() - .with_style(style.container.clone()) - }) - .on_click(MouseButton::Left, move |_, this, cx| { - this.channel_store - .update(cx, |channel_store, cx| { - channel_store.set_channel_visibility( - channel_id, - ChannelVisibility::Members, - cx, - ) - }) - .detach_and_log_err(cx); - }) - .with_cursor_style(CursorStyle::PointingHand), - ) - .with_spacing(14.0) - .with_child( - MouseEventHandler::new::(1, cx, move |state, _| { - let style = theme.channel_link.style_for(state); - Label::new(format!("{}", "copy link"), style.text.clone()) - .contained() - .with_style(style.container.clone()) - }) - .on_click(MouseButton::Left, move |_, this, cx| { - if let Some(channel) = - this.channel_store.read(cx).channel_for_id(channel_id) - { - let item = ClipboardItem::new(channel.link()); - cx.write_to_clipboard(item); - } - }) - .with_cursor_style(CursorStyle::PointingHand), - ) - .into_any() - } - - Flex::column() - .with_child( - Flex::column() - .with_child( - Label::new(format!("#{}", channel.name), theme.title.text.clone()) - .contained() - .with_style(theme.title.container.clone()), - ) - .with_child(render_visibility(channel.id, channel.visibility, theme, cx)) - .with_child(Flex::row().with_children([ - render_mode_button::( - Mode::InviteMembers, - "Invite members", - mode, - theme, - cx, - ), - render_mode_button::( - Mode::ManageMembers, - "Manage members", - mode, - theme, - cx, - ), - ])) - .expanded() - .contained() - .with_style(theme.header), - ) - .with_child( - ChildView::new(&self.picker, cx) - .contained() - .with_style(theme.body), - ) - .constrained() - .with_max_height(theme.max_height) - .with_max_width(theme.max_width) - .contained() - .with_style(theme.modal) - .into_any() - } - - fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { - self.has_focus = true; - if cx.is_self_focused() { - cx.focus(&self.picker) - } - } +impl EventEmitter for ChannelModal {} - fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { - self.has_focus = false; +impl FocusableView for ChannelModal { + fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { + self.picker.focus_handle(cx) } } -impl Modal for ChannelModal { - fn has_focus(&self) -> bool { - self.has_focus +impl Render for ChannelModal { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + v_stack().min_w_96().child(self.picker.clone()) + // let theme = &theme::current(cx).collab_panel.tabbed_modal; + + // let mode = self.picker.read(cx).delegate().mode; + // let Some(channel) = self.channel_store.read(cx).channel_for_id(self.channel_id) else { + // return Empty::new().into_any(); + // }; + + // enum InviteMembers {} + // enum ManageMembers {} + + // fn render_mode_button( + // mode: Mode, + // text: &'static str, + // current_mode: Mode, + // theme: &theme::TabbedModal, + // cx: &mut ViewContext, + // ) -> AnyElement { + // let active = mode == current_mode; + // MouseEventHandler::new::(0, cx, move |state, _| { + // let contained_text = theme.tab_button.style_for(active, state); + // Label::new(text, contained_text.text.clone()) + // .contained() + // .with_style(contained_text.container.clone()) + // }) + // .on_click(MouseButton::Left, move |_, this, cx| { + // if !active { + // this.set_mode(mode, cx); + // } + // }) + // .with_cursor_style(CursorStyle::PointingHand) + // .into_any() + // } + + // fn render_visibility( + // channel_id: ChannelId, + // visibility: ChannelVisibility, + // theme: &theme::TabbedModal, + // cx: &mut ViewContext, + // ) -> AnyElement { + // enum TogglePublic {} + + // if visibility == ChannelVisibility::Members { + // return Flex::row() + // .with_child( + // MouseEventHandler::new::(0, cx, move |state, _| { + // let style = theme.visibility_toggle.style_for(state); + // Label::new(format!("{}", "Public access: OFF"), style.text.clone()) + // .contained() + // .with_style(style.container.clone()) + // }) + // .on_click(MouseButton::Left, move |_, this, cx| { + // this.channel_store + // .update(cx, |channel_store, cx| { + // channel_store.set_channel_visibility( + // channel_id, + // ChannelVisibility::Public, + // cx, + // ) + // }) + // .detach_and_log_err(cx); + // }) + // .with_cursor_style(CursorStyle::PointingHand), + // ) + // .into_any(); + // } + + // Flex::row() + // .with_child( + // MouseEventHandler::new::(0, cx, move |state, _| { + // let style = theme.visibility_toggle.style_for(state); + // Label::new(format!("{}", "Public access: ON"), style.text.clone()) + // .contained() + // .with_style(style.container.clone()) + // }) + // .on_click(MouseButton::Left, move |_, this, cx| { + // this.channel_store + // .update(cx, |channel_store, cx| { + // channel_store.set_channel_visibility( + // channel_id, + // ChannelVisibility::Members, + // cx, + // ) + // }) + // .detach_and_log_err(cx); + // }) + // .with_cursor_style(CursorStyle::PointingHand), + // ) + // .with_spacing(14.0) + // .with_child( + // MouseEventHandler::new::(1, cx, move |state, _| { + // let style = theme.channel_link.style_for(state); + // Label::new(format!("{}", "copy link"), style.text.clone()) + // .contained() + // .with_style(style.container.clone()) + // }) + // .on_click(MouseButton::Left, move |_, this, cx| { + // if let Some(channel) = + // this.channel_store.read(cx).channel_for_id(channel_id) + // { + // let item = ClipboardItem::new(channel.link()); + // cx.write_to_clipboard(item); + // } + // }) + // .with_cursor_style(CursorStyle::PointingHand), + // ) + // .into_any() + // } + + // Flex::column() + // .with_child( + // Flex::column() + // .with_child( + // Label::new(format!("#{}", channel.name), theme.title.text.clone()) + // .contained() + // .with_style(theme.title.container.clone()), + // ) + // .with_child(render_visibility(channel.id, channel.visibility, theme, cx)) + // .with_child(Flex::row().with_children([ + // render_mode_button::( + // Mode::InviteMembers, + // "Invite members", + // mode, + // theme, + // cx, + // ), + // render_mode_button::( + // Mode::ManageMembers, + // "Manage members", + // mode, + // theme, + // cx, + // ), + // ])) + // .expanded() + // .contained() + // .with_style(theme.header), + // ) + // .with_child( + // ChildView::new(&self.picker, cx) + // .contained() + // .with_style(theme.body), + // ) + // .constrained() + // .with_max_height(theme.max_height) + // .with_max_width(theme.max_width) + // .contained() + // .with_style(theme.modal) + // .into_any() } - fn dismiss_on_event(event: &Self::Event) -> bool { - match event { - PickerEvent::Dismiss => true, - } - } + // fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { + // self.has_focus = true; + // if cx.is_self_focused() { + // cx.focus(&self.picker) + // } + // } + + // fn focus_out(&mut self, _: gpui::AnyViewHandle, _: &mut ViewContext) { + // self.has_focus = false; + // } } #[derive(Copy, Clone, PartialEq)] @@ -337,19 +321,22 @@ pub enum Mode { } pub struct ChannelModalDelegate { + channel_modal: WeakView, matching_users: Vec>, matching_member_indices: Vec, - user_store: ModelHandle, - channel_store: ModelHandle, + user_store: Model, + channel_store: Model, channel_id: ChannelId, selected_index: usize, mode: Mode, match_candidates: Vec, members: Vec, - context_menu: ViewHandle, + // context_menu: ViewHandle, } impl PickerDelegate for ChannelModalDelegate { + type ListItem = Div; + fn placeholder_text(&self) -> Arc { "Search collaborator by username...".into() } @@ -382,19 +369,19 @@ impl PickerDelegate for ChannelModalDelegate { } })); - let matches = cx.background().block(match_strings( + let matches = cx.background_executor().block(match_strings( &self.match_candidates, &query, true, usize::MAX, &Default::default(), - cx.background().clone(), + cx.background_executor().clone(), )); cx.spawn(|picker, mut cx| async move { picker .update(&mut cx, |picker, cx| { - let delegate = picker.delegate_mut(); + let delegate = &mut picker.delegate; delegate.matching_member_indices.clear(); delegate .matching_member_indices @@ -412,8 +399,7 @@ impl PickerDelegate for ChannelModalDelegate { async { let users = search_users.await?; picker.update(&mut cx, |picker, cx| { - let delegate = picker.delegate_mut(); - delegate.matching_users = users; + picker.delegate.matching_users = users; cx.notify(); })?; anyhow::Ok(()) @@ -445,138 +431,142 @@ impl PickerDelegate for ChannelModalDelegate { } fn dismissed(&mut self, cx: &mut ViewContext>) { - cx.emit(PickerEvent::Dismiss); + self.channel_modal + .update(cx, |_, cx| { + cx.emit(DismissEvent); + }) + .ok(); } fn render_match( &self, ix: usize, - mouse_state: &mut MouseState, selected: bool, - cx: &gpui::AppContext, - ) -> AnyElement> { - let full_theme = &theme::current(cx); - let theme = &full_theme.collab_panel.channel_modal; - let tabbed_modal = &full_theme.collab_panel.tabbed_modal; - let (user, role) = self.user_at_index(ix).unwrap(); - let request_status = self.member_status(user.id, cx); - - let style = tabbed_modal - .picker - .item - .in_state(selected) - .style_for(mouse_state); - - let in_manage = matches!(self.mode, Mode::ManageMembers); - - let mut result = Flex::row() - .with_children(user.avatar.clone().map(|avatar| { - Image::from_data(avatar) - .with_style(theme.contact_avatar) - .aligned() - .left() - })) - .with_child( - Label::new(user.github_login.clone(), style.label.clone()) - .contained() - .with_style(theme.contact_username) - .aligned() - .left(), - ) - .with_children({ - (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then( - || { - Label::new("Invited", theme.member_tag.text.clone()) - .contained() - .with_style(theme.member_tag.container) - .aligned() - .left() - }, - ) - }) - .with_children(if in_manage && role == Some(ChannelRole::Admin) { - Some( - Label::new("Admin", theme.member_tag.text.clone()) - .contained() - .with_style(theme.member_tag.container) - .aligned() - .left(), - ) - } else if in_manage && role == Some(ChannelRole::Guest) { - Some( - Label::new("Guest", theme.member_tag.text.clone()) - .contained() - .with_style(theme.member_tag.container) - .aligned() - .left(), - ) - } else { - None - }) - .with_children({ - let svg = match self.mode { - Mode::ManageMembers => Some( - Svg::new("icons/ellipsis.svg") - .with_color(theme.member_icon.color) - .constrained() - .with_width(theme.member_icon.icon_width) - .aligned() - .constrained() - .with_width(theme.member_icon.button_width) - .with_height(theme.member_icon.button_width) - .contained() - .with_style(theme.member_icon.container), - ), - Mode::InviteMembers => match request_status { - Some(proto::channel_member::Kind::Member) => Some( - Svg::new("icons/check.svg") - .with_color(theme.member_icon.color) - .constrained() - .with_width(theme.member_icon.icon_width) - .aligned() - .constrained() - .with_width(theme.member_icon.button_width) - .with_height(theme.member_icon.button_width) - .contained() - .with_style(theme.member_icon.container), - ), - Some(proto::channel_member::Kind::Invitee) => Some( - Svg::new("icons/check.svg") - .with_color(theme.invitee_icon.color) - .constrained() - .with_width(theme.invitee_icon.icon_width) - .aligned() - .constrained() - .with_width(theme.invitee_icon.button_width) - .with_height(theme.invitee_icon.button_width) - .contained() - .with_style(theme.invitee_icon.container), - ), - Some(proto::channel_member::Kind::AncestorMember) | None => None, - }, - }; - - svg.map(|svg| svg.aligned().flex_float().into_any()) - }) - .contained() - .with_style(style.container) - .constrained() - .with_height(tabbed_modal.row_height) - .into_any(); - - if selected { - result = Stack::new() - .with_child(result) - .with_child( - ChildView::new(&self.context_menu, cx) - .aligned() - .top() - .right(), - ) - .into_any(); - } - - result + cx: &mut ViewContext>, + ) -> Option { + None + // let full_theme = &theme::current(cx); + // let theme = &full_theme.collab_panel.channel_modal; + // let tabbed_modal = &full_theme.collab_panel.tabbed_modal; + // let (user, role) = self.user_at_index(ix).unwrap(); + // let request_status = self.member_status(user.id, cx); + + // let style = tabbed_modal + // .picker + // .item + // .in_state(selected) + // .style_for(mouse_state); + + // let in_manage = matches!(self.mode, Mode::ManageMembers); + + // let mut result = Flex::row() + // .with_children(user.avatar.clone().map(|avatar| { + // Image::from_data(avatar) + // .with_style(theme.contact_avatar) + // .aligned() + // .left() + // })) + // .with_child( + // Label::new(user.github_login.clone(), style.label.clone()) + // .contained() + // .with_style(theme.contact_username) + // .aligned() + // .left(), + // ) + // .with_children({ + // (in_manage && request_status == Some(proto::channel_member::Kind::Invitee)).then( + // || { + // Label::new("Invited", theme.member_tag.text.clone()) + // .contained() + // .with_style(theme.member_tag.container) + // .aligned() + // .left() + // }, + // ) + // }) + // .with_children(if in_manage && role == Some(ChannelRole::Admin) { + // Some( + // Label::new("Admin", theme.member_tag.text.clone()) + // .contained() + // .with_style(theme.member_tag.container) + // .aligned() + // .left(), + // ) + // } else if in_manage && role == Some(ChannelRole::Guest) { + // Some( + // Label::new("Guest", theme.member_tag.text.clone()) + // .contained() + // .with_style(theme.member_tag.container) + // .aligned() + // .left(), + // ) + // } else { + // None + // }) + // .with_children({ + // let svg = match self.mode { + // Mode::ManageMembers => Some( + // Svg::new("icons/ellipsis.svg") + // .with_color(theme.member_icon.color) + // .constrained() + // .with_width(theme.member_icon.icon_width) + // .aligned() + // .constrained() + // .with_width(theme.member_icon.button_width) + // .with_height(theme.member_icon.button_width) + // .contained() + // .with_style(theme.member_icon.container), + // ), + // Mode::InviteMembers => match request_status { + // Some(proto::channel_member::Kind::Member) => Some( + // Svg::new("icons/check.svg") + // .with_color(theme.member_icon.color) + // .constrained() + // .with_width(theme.member_icon.icon_width) + // .aligned() + // .constrained() + // .with_width(theme.member_icon.button_width) + // .with_height(theme.member_icon.button_width) + // .contained() + // .with_style(theme.member_icon.container), + // ), + // Some(proto::channel_member::Kind::Invitee) => Some( + // Svg::new("icons/check.svg") + // .with_color(theme.invitee_icon.color) + // .constrained() + // .with_width(theme.invitee_icon.icon_width) + // .aligned() + // .constrained() + // .with_width(theme.invitee_icon.button_width) + // .with_height(theme.invitee_icon.button_width) + // .contained() + // .with_style(theme.invitee_icon.container), + // ), + // Some(proto::channel_member::Kind::AncestorMember) | None => None, + // }, + // }; + + // svg.map(|svg| svg.aligned().flex_float().into_any()) + // }) + // .contained() + // .with_style(style.container) + // .constrained() + // .with_height(tabbed_modal.row_height) + // .into_any(); + + // if selected { + // result = Stack::new() + // .with_child(result) + // .with_child( + // ChildView::new(&self.context_menu, cx) + // .aligned() + // .top() + // .right(), + // ) + // .into_any(); + // } + + // result } } @@ -623,7 +613,7 @@ impl ChannelModalDelegate { cx.spawn(|picker, mut cx| async move { update.await?; picker.update(&mut cx, |picker, cx| { - let this = picker.delegate_mut(); + let this = &mut picker.delegate; if let Some(member) = this.members.iter_mut().find(|m| m.user.id == user.id) { member.role = new_role; } @@ -644,7 +634,7 @@ impl ChannelModalDelegate { cx.spawn(|picker, mut cx| async move { update.await?; picker.update(&mut cx, |picker, cx| { - let this = picker.delegate_mut(); + let this = &mut picker.delegate; if let Some(ix) = this.members.iter_mut().position(|m| m.user.id == user_id) { this.members.remove(ix); this.matching_member_indices.retain_mut(|member_ix| { @@ -683,7 +673,7 @@ impl ChannelModalDelegate { kind: proto::channel_member::Kind::Invitee, role: ChannelRole::Member, }; - let members = &mut this.delegate_mut().members; + let members = &mut this.delegate.members; match members.binary_search_by_key(&new_member.sort_key(), |k| k.sort_key()) { Ok(ix) | Err(ix) => members.insert(ix, new_member), } @@ -695,23 +685,23 @@ impl ChannelModalDelegate { } fn show_context_menu(&mut self, role: ChannelRole, cx: &mut ViewContext>) { - self.context_menu.update(cx, |context_menu, cx| { - context_menu.show( - Default::default(), - AnchorCorner::TopRight, - vec![ - ContextMenuItem::action("Remove", RemoveMember), - ContextMenuItem::action( - if role == ChannelRole::Admin { - "Make non-admin" - } else { - "Make admin" - }, - ToggleMemberAdmin, - ), - ], - cx, - ) - }) + // self.context_menu.update(cx, |context_menu, cx| { + // context_menu.show( + // Default::default(), + // AnchorCorner::TopRight, + // vec![ + // ContextMenuItem::action("Remove", RemoveMember), + // ContextMenuItem::action( + // if role == ChannelRole::Admin { + // "Make non-admin" + // } else { + // "Make admin" + // }, + // ToggleMemberAdmin, + // ), + // ], + // cx, + // ) + // }) } } diff --git a/crates/collab_ui2/src/collab_titlebar_item.rs b/crates/collab_ui2/src/collab_titlebar_item.rs index f18e4cb2db4358f810499598ce96a55af4dc1064..7e5354c6015bf9764380d7637377fa2b91482da1 100644 --- a/crates/collab_ui2/src/collab_titlebar_item.rs +++ b/crates/collab_ui2/src/collab_titlebar_item.rs @@ -31,20 +31,31 @@ use std::sync::Arc; use call::ActiveCall; use client::{Client, UserStore}; use gpui::{ - div, px, rems, AppContext, Div, Element, InteractiveElement, IntoElement, Model, MouseButton, - ParentElement, Render, RenderOnce, Stateful, StatefulInteractiveElement, Styled, Subscription, - ViewContext, VisualContext, WeakView, WindowBounds, + actions, div, px, rems, AppContext, Div, Element, InteractiveElement, IntoElement, Model, + MouseButton, ParentElement, Render, RenderOnce, Stateful, StatefulInteractiveElement, Styled, + Subscription, ViewContext, VisualContext, WeakView, WindowBounds, }; -use project::Project; +use project::{Project, RepositoryEntry}; use theme::ActiveTheme; -use ui::{h_stack, prelude::*, Avatar, Button, ButtonStyle, IconButton, KeyBinding, Tooltip}; +use ui::{ + h_stack, popover_menu, prelude::*, Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, + IconButton, IconElement, KeyBinding, Tooltip, +}; use util::ResultExt; use workspace::{notifications::NotifyResultExt, Workspace}; use crate::face_pile::FacePile; -// const MAX_PROJECT_NAME_LENGTH: usize = 40; -// const MAX_BRANCH_NAME_LENGTH: usize = 40; +const MAX_PROJECT_NAME_LENGTH: usize = 40; +const MAX_BRANCH_NAME_LENGTH: usize = 40; + +actions!( + ShareProject, + UnshareProject, + ToggleUserMenu, + ToggleProjectMenu, + SwitchBranch +); // actions!( // collab, @@ -88,36 +99,23 @@ impl Render for CollabTitlebarItem { type Element = Stateful
; fn render(&mut self, cx: &mut ViewContext) -> Self::Element { - let is_in_room = self - .workspace - .update(cx, |this, cx| this.call_state().is_in_room(cx)) - .unwrap_or_default(); + let room = ActiveCall::global(cx).read(cx).room(); + let is_in_room = room.is_some(); let is_shared = is_in_room && self.project.read(cx).is_shared(); let current_user = self.user_store.read(cx).current_user(); let client = self.client.clone(); - let users = self - .workspace - .update(cx, |this, cx| this.call_state().remote_participants(cx)) - .log_err() - .flatten(); - let mic_icon = if self - .workspace - .update(cx, |this, cx| this.call_state().is_muted(cx)) - .log_err() - .flatten() - .unwrap_or_default() - { - ui::Icon::MicMute - } else { - ui::Icon::Mic - }; - let speakers_icon = if self - .workspace - .update(cx, |this, cx| this.call_state().is_deafened(cx)) - .log_err() - .flatten() - .unwrap_or_default() - { + let remote_participants = room.map(|room| { + room.read(cx) + .remote_participants() + .values() + .map(|participant| (participant.user.clone(), participant.peer_id)) + .collect::>() + }); + let is_muted = room.map_or(false, |room| room.read(cx).is_muted(cx)); + let is_deafened = room + .and_then(|room| room.read(cx).is_deafened()) + .unwrap_or(false); + let speakers_icon = if is_deafened { ui::Icon::AudioOff } else { ui::Icon::AudioOn @@ -146,59 +144,14 @@ impl Render for CollabTitlebarItem { .child( h_stack() .gap_1() - // TODO - Add player menu - .child( - div() - .border() - .border_color(gpui::red()) - .id("project_owner_indicator") - .child( - Button::new("player", "player") - .style(ButtonStyle::Subtle) - .color(Some(Color::Player(0))), - ) - .tooltip(move |cx| Tooltip::text("Toggle following", cx)), - ) - // TODO - Add project menu - .child( - div() - .border() - .border_color(gpui::red()) - .id("titlebar_project_menu_button") - .child( - Button::new("project_name", "project_name") - .style(ButtonStyle::Subtle), - ) - .tooltip(move |cx| Tooltip::text("Recent Projects", cx)), - ) - // TODO - Add git menu - .child( - div() - .border() - .border_color(gpui::red()) - .id("titlebar_git_menu_button") - .child( - Button::new("branch_name", "branch_name") - .style(ButtonStyle::Subtle) - .color(Some(Color::Muted)), - ) - .tooltip(move |cx| { - cx.build_view(|_| { - Tooltip::new("Recent Branches") - .key_binding(KeyBinding::new(gpui::KeyBinding::new( - "cmd-b", - // todo!() Replace with real action. - gpui::NoAction, - None, - ))) - .meta("Only local branches shown") - }) - .into() - }), - ), + .when(is_in_room, |this| { + this.children(self.render_project_owner(cx)) + }) + .child(self.render_project_name(cx)) + .children(self.render_project_branch(cx)), ) .when_some( - users.zip(current_user.clone()), + remote_participants.zip(current_user.clone()), |this, (remote_participants, current_user)| { let mut pile = FacePile::default(); pile.extend( @@ -209,25 +162,30 @@ impl Render for CollabTitlebarItem { div().child(Avatar::data(avatar.clone())).into_any_element() }) .into_iter() - .chain(remote_participants.into_iter().flat_map(|(user, peer_id)| { - user.avatar.as_ref().map(|avatar| { - div() - .child( - Avatar::data(avatar.clone()).into_element().into_any(), - ) - .on_mouse_down(MouseButton::Left, { - let workspace = workspace.clone(); - move |_, cx| { - workspace - .update(cx, |this, cx| { - this.open_shared_screen(peer_id, cx); - }) - .log_err(); - } - }) - .into_any_element() - }) - })), + .chain(remote_participants.into_iter().filter_map( + |(user, peer_id)| { + let avatar = user.avatar.as_ref()?; + Some( + div() + .child( + Avatar::data(avatar.clone()) + .into_element() + .into_any(), + ) + .on_mouse_down(MouseButton::Left, { + let workspace = workspace.clone(); + move |_, cx| { + workspace + .update(cx, |this, cx| { + this.open_shared_screen(peer_id, cx); + }) + .log_err(); + } + }) + .into_any_element(), + ) + }, + )), ); this.child(pile.render(cx)) }, @@ -236,62 +194,112 @@ impl Render for CollabTitlebarItem { .when(is_in_room, |this| { this.child( h_stack() + .gap_1() .child( h_stack() - .child(Button::new( - "toggle_sharing", - if is_shared { "Unshare" } else { "Share" }, - )) - .child(IconButton::new("leave-call", ui::Icon::Exit).on_click({ - let workspace = workspace.clone(); - move |_, cx| { - workspace - .update(cx, |this, cx| { - this.call_state().hang_up(cx).detach(); - }) - .log_err(); - } - })), + .gap_1() + .child( + Button::new( + "toggle_sharing", + if is_shared { "Unshare" } else { "Share" }, + ) + .style(ButtonStyle::Subtle) + .on_click(cx.listener( + move |this, _, cx| { + if is_shared { + this.unshare_project(&Default::default(), cx); + } else { + this.share_project(&Default::default(), cx); + } + }, + )), + ) + .child( + IconButton::new("leave-call", ui::Icon::Exit) + .style(ButtonStyle::Subtle) + .on_click(move |_, cx| { + ActiveCall::global(cx) + .update(cx, |call, cx| call.hang_up(cx)) + .detach_and_log_err(cx); + }), + ), ) .child( h_stack() - .child(IconButton::new("mute-microphone", mic_icon).on_click({ - let workspace = workspace.clone(); - move |_, cx| { - workspace - .update(cx, |this, cx| { - this.call_state().toggle_mute(cx); - }) - .log_err(); - } - })) - .child(IconButton::new("mute-sound", speakers_icon).on_click({ - let workspace = workspace.clone(); - move |_, cx| { - workspace - .update(cx, |this, cx| { - this.call_state().toggle_deafen(cx); - }) - .log_err(); - } - })) - .child(IconButton::new("screen-share", ui::Icon::Screen).on_click( - move |_, cx| { - workspace - .update(cx, |this, cx| { - this.call_state().toggle_screen_share(cx); - }) - .log_err(); - }, - )) + .gap_1() + .child( + IconButton::new( + "mute-microphone", + if is_muted { + ui::Icon::MicMute + } else { + ui::Icon::Mic + }, + ) + .style(ButtonStyle::Subtle) + .selected(is_muted) + .on_click(move |_, cx| { + crate::toggle_mute(&Default::default(), cx) + }), + ) + .child( + IconButton::new("mute-sound", speakers_icon) + .style(ButtonStyle::Subtle) + .selected(is_deafened.clone()) + .tooltip(move |cx| { + Tooltip::with_meta( + "Deafen Audio", + None, + "Mic will be muted", + cx, + ) + }) + .on_click(move |_, cx| { + crate::toggle_mute(&Default::default(), cx) + }), + ) + .child( + IconButton::new("screen-share", ui::Icon::Screen) + .style(ButtonStyle::Subtle) + .on_click(move |_, cx| { + crate::toggle_screen_sharing(&Default::default(), cx) + }), + ) .pl_2(), ), ) }) - .map(|this| { + .child(h_stack().px_1p5().map(|this| { if let Some(user) = current_user { this.when_some(user.avatar.clone(), |this, avatar| { - this.child(ui::Avatar::data(avatar)) + // TODO: Finish implementing user menu popover + // + this.child( + popover_menu("user-menu") + .menu(|cx| ContextMenu::build(cx, |menu, _| menu.header("ADADA"))) + .trigger( + ButtonLike::new("user-menu") + .child( + h_stack().gap_0p5().child(Avatar::data(avatar)).child( + IconElement::new(Icon::ChevronDown) + .color(Color::Muted), + ), + ) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), + ) + .anchor(gpui::AnchorCorner::TopRight), + ) + // this.child( + // ButtonLike::new("user-menu") + // .child( + // h_stack().gap_0p5().child(Avatar::data(avatar)).child( + // IconElement::new(Icon::ChevronDown).color(Color::Muted), + // ), + // ) + // .style(ButtonStyle::Subtle) + // .tooltip(move |cx| Tooltip::text("Toggle User Menu", cx)), + // ) }) } else { this.child(Button::new("sign_in", "Sign in").on_click(move |_, cx| { @@ -305,7 +313,7 @@ impl Render for CollabTitlebarItem { .detach(); })) } - }) + })) } } @@ -424,6 +432,83 @@ impl CollabTitlebarItem { } } + // resolve if you are in a room -> render_project_owner + // render_project_owner -> resolve if you are in a room -> Option + + pub fn render_project_owner(&self, cx: &mut ViewContext) -> Option { + let host = self.project.read(cx).host()?; + let host = self.user_store.read(cx).get_cached_user(host.user_id)?; + let participant_index = self + .user_store + .read(cx) + .participant_indices() + .get(&host.id)?; + Some( + div().border().border_color(gpui::red()).child( + Button::new("project_owner_trigger", host.github_login.clone()) + .color(Color::Player(participant_index.0)) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| Tooltip::text("Toggle following", cx)), + ), + ) + } + + pub fn render_project_name(&self, cx: &mut ViewContext) -> impl Element { + let name = { + let mut names = self.project.read(cx).visible_worktrees(cx).map(|worktree| { + let worktree = worktree.read(cx); + worktree.root_name() + }); + + names.next().unwrap_or("") + }; + + let name = util::truncate_and_trailoff(name, MAX_PROJECT_NAME_LENGTH); + + div().border().border_color(gpui::red()).child( + Button::new("project_name_trigger", name) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| Tooltip::text("Recent Projects", cx)), + ) + } + + pub fn render_project_branch(&self, cx: &mut ViewContext) -> Option { + let entry = { + let mut names_and_branches = + self.project.read(cx).visible_worktrees(cx).map(|worktree| { + let worktree = worktree.read(cx); + worktree.root_git_entry() + }); + + names_and_branches.next().flatten() + }; + + let branch_name = entry + .as_ref() + .and_then(RepositoryEntry::branch) + .map(|branch| util::truncate_and_trailoff(&branch, MAX_BRANCH_NAME_LENGTH))?; + + Some( + div().border().border_color(gpui::red()).child( + Button::new("project_branch_trigger", branch_name) + .style(ButtonStyle::Subtle) + .tooltip(move |cx| { + cx.build_view(|_| { + Tooltip::new("Recent Branches") + .key_binding(KeyBinding::new(gpui::KeyBinding::new( + "cmd-b", + // todo!() Replace with real action. + gpui::NoAction, + None, + ))) + .meta("Local branches only") + }) + .into() + }), + ), + ) + } + // fn collect_title_root_names( // &self, // theme: Arc, @@ -603,21 +688,21 @@ impl CollabTitlebarItem { cx.notify(); } - // fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext) { - // let active_call = ActiveCall::global(cx); - // let project = self.project.clone(); - // active_call - // .update(cx, |call, cx| call.share_project(project, cx)) - // .detach_and_log_err(cx); - // } + fn share_project(&mut self, _: &ShareProject, cx: &mut ViewContext) { + let active_call = ActiveCall::global(cx); + let project = self.project.clone(); + active_call + .update(cx, |call, cx| call.share_project(project, cx)) + .detach_and_log_err(cx); + } - // fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext) { - // let active_call = ActiveCall::global(cx); - // let project = self.project.clone(); - // active_call - // .update(cx, |call, cx| call.unshare_project(project, cx)) - // .log_err(); - // } + fn unshare_project(&mut self, _: &UnshareProject, cx: &mut ViewContext) { + let active_call = ActiveCall::global(cx); + let project = self.project.clone(); + active_call + .update(cx, |call, cx| call.unshare_project(project, cx)) + .log_err(); + } // pub fn toggle_user_menu(&mut self, _: &ToggleUserMenu, cx: &mut ViewContext) { // self.user_menu.update(cx, |user_menu, cx| { diff --git a/crates/collab_ui2/src/collab_ui.rs b/crates/collab_ui2/src/collab_ui.rs index 57a33c6790868bcd97a597da5a68a2608d0a684a..efd3ff869225aced36002a3bdb4f1f5905579c5a 100644 --- a/crates/collab_ui2/src/collab_ui.rs +++ b/crates/collab_ui2/src/collab_ui.rs @@ -9,22 +9,21 @@ mod panel_settings; use std::{rc::Rc, sync::Arc}; +use call::{report_call_event_for_room, ActiveCall, Room}; pub use collab_panel::CollabPanel; pub use collab_titlebar_item::CollabTitlebarItem; use gpui::{ - point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, WindowBounds, WindowKind, - WindowOptions, + actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds, + WindowKind, WindowOptions, }; pub use panel_settings::{ ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings, }; use settings::Settings; +use util::ResultExt; use workspace::AppState; -// actions!( -// collab, -// [ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall] -// ); +actions!(ToggleScreenSharing, ToggleMute, ToggleDeafen, LeaveCall); pub fn init(app_state: &Arc, cx: &mut AppContext) { CollaborationPanelSettings::register(cx); @@ -42,61 +41,61 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { // cx.add_global_action(toggle_deafen); } -// pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { -// let call = ActiveCall::global(cx).read(cx); -// if let Some(room) = call.room().cloned() { -// let client = call.client(); -// let toggle_screen_sharing = room.update(cx, |room, cx| { -// if room.is_screen_sharing() { -// report_call_event_for_room( -// "disable screen share", -// room.id(), -// room.channel_id(), -// &client, -// cx, -// ); -// Task::ready(room.unshare_screen(cx)) -// } else { -// report_call_event_for_room( -// "enable screen share", -// room.id(), -// room.channel_id(), -// &client, -// cx, -// ); -// room.share_screen(cx) -// } -// }); -// toggle_screen_sharing.detach_and_log_err(cx); -// } -// } +pub fn toggle_screen_sharing(_: &ToggleScreenSharing, cx: &mut AppContext) { + let call = ActiveCall::global(cx).read(cx); + if let Some(room) = call.room().cloned() { + let client = call.client(); + let toggle_screen_sharing = room.update(cx, |room, cx| { + if room.is_screen_sharing() { + report_call_event_for_room( + "disable screen share", + room.id(), + room.channel_id(), + &client, + cx, + ); + Task::ready(room.unshare_screen(cx)) + } else { + report_call_event_for_room( + "enable screen share", + room.id(), + room.channel_id(), + &client, + cx, + ); + room.share_screen(cx) + } + }); + toggle_screen_sharing.detach_and_log_err(cx); + } +} -// pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { -// let call = ActiveCall::global(cx).read(cx); -// if let Some(room) = call.room().cloned() { -// let client = call.client(); -// room.update(cx, |room, cx| { -// let operation = if room.is_muted(cx) { -// "enable microphone" -// } else { -// "disable microphone" -// }; -// report_call_event_for_room(operation, room.id(), room.channel_id(), &client, cx); +pub fn toggle_mute(_: &ToggleMute, cx: &mut AppContext) { + let call = ActiveCall::global(cx).read(cx); + if let Some(room) = call.room().cloned() { + let client = call.client(); + room.update(cx, |room, cx| { + let operation = if room.is_muted(cx) { + "enable microphone" + } else { + "disable microphone" + }; + report_call_event_for_room(operation, room.id(), room.channel_id(), &client, cx); -// room.toggle_mute(cx) -// }) -// .map(|task| task.detach_and_log_err(cx)) -// .log_err(); -// } -// } + room.toggle_mute(cx) + }) + .map(|task| task.detach_and_log_err(cx)) + .log_err(); + } +} -// pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { -// if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { -// room.update(cx, Room::toggle_deafen) -// .map(|task| task.detach_and_log_err(cx)) -// .log_err(); -// } -// } +pub fn toggle_deafen(_: &ToggleDeafen, cx: &mut AppContext) { + if let Some(room) = ActiveCall::global(cx).read(cx).room().cloned() { + room.update(cx, Room::toggle_deafen) + .map(|task| task.detach_and_log_err(cx)) + .log_err(); + } +} fn notification_window_options( screen: Rc, diff --git a/crates/command_palette2/src/command_palette.rs b/crates/command_palette2/src/command_palette.rs index 04688b05492c8c298c33d32423cdb5e7ce1fe393..a2abadd5fdea652e7d3d8fda933dac549fde8ef2 100644 --- a/crates/command_palette2/src/command_palette.rs +++ b/crates/command_palette2/src/command_palette.rs @@ -311,7 +311,11 @@ impl PickerDelegate for CommandPaletteDelegate { command.name.clone(), r#match.positions.clone(), )) - .children(KeyBinding::for_action(&*command.action, cx)), + .children(KeyBinding::for_action_in( + &*command.action, + &self.previous_focus_handle, + cx, + )), ), ) } diff --git a/crates/copilot2/Cargo.toml b/crates/copilot2/Cargo.toml index 68b56a6c018b5108c841c37e83f68c0877ee77f5..9a9243b32eecb766451a3f7f89940227fe6059fa 100644 --- a/crates/copilot2/Cargo.toml +++ b/crates/copilot2/Cargo.toml @@ -45,6 +45,6 @@ fs = { path = "../fs", 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"] } -rpc = { path = "../rpc", features = ["test-support"] } +rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] } settings = { package = "settings2", path = "../settings2", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } diff --git a/crates/copilot2/src/copilot2.rs b/crates/copilot2/src/copilot2.rs index 53d802dd037f51c3df4183ba57bda44a1ea18e7f..b2454728644b212c8736b49876b213123cf039e8 100644 --- a/crates/copilot2/src/copilot2.rs +++ b/crates/copilot2/src/copilot2.rs @@ -1002,229 +1002,231 @@ async fn get_copilot_lsp(http: Arc) -> anyhow::Result { } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use gpui::{executor::Deterministic, TestAppContext}; - -// #[gpui::test(iterations = 10)] -// async fn test_buffer_management(deterministic: Arc, cx: &mut TestAppContext) { -// deterministic.forbid_parking(); -// let (copilot, mut lsp) = Copilot::fake(cx); - -// let buffer_1 = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "Hello")); -// let buffer_1_uri: lsp::Url = format!("buffer://{}", buffer_1.id()).parse().unwrap(); -// copilot.update(cx, |copilot, cx| copilot.register_buffer(&buffer_1, cx)); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidOpenTextDocumentParams { -// text_document: lsp::TextDocumentItem::new( -// buffer_1_uri.clone(), -// "plaintext".into(), -// 0, -// "Hello".into() -// ), -// } -// ); - -// let buffer_2 = cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "Goodbye")); -// let buffer_2_uri: lsp::Url = format!("buffer://{}", buffer_2.id()).parse().unwrap(); -// copilot.update(cx, |copilot, cx| copilot.register_buffer(&buffer_2, cx)); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidOpenTextDocumentParams { -// text_document: lsp::TextDocumentItem::new( -// buffer_2_uri.clone(), -// "plaintext".into(), -// 0, -// "Goodbye".into() -// ), -// } -// ); - -// buffer_1.update(cx, |buffer, cx| buffer.edit([(5..5, " world")], None, cx)); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidChangeTextDocumentParams { -// text_document: lsp::VersionedTextDocumentIdentifier::new(buffer_1_uri.clone(), 1), -// content_changes: vec![lsp::TextDocumentContentChangeEvent { -// range: Some(lsp::Range::new( -// lsp::Position::new(0, 5), -// lsp::Position::new(0, 5) -// )), -// range_length: None, -// text: " world".into(), -// }], -// } -// ); - -// // Ensure updates to the file are reflected in the LSP. -// buffer_1 -// .update(cx, |buffer, cx| { -// buffer.file_updated( -// Arc::new(File { -// abs_path: "/root/child/buffer-1".into(), -// path: Path::new("child/buffer-1").into(), -// }), -// cx, -// ) -// }) -// .await; -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidCloseTextDocumentParams { -// text_document: lsp::TextDocumentIdentifier::new(buffer_1_uri), -// } -// ); -// let buffer_1_uri = lsp::Url::from_file_path("/root/child/buffer-1").unwrap(); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidOpenTextDocumentParams { -// text_document: lsp::TextDocumentItem::new( -// buffer_1_uri.clone(), -// "plaintext".into(), -// 1, -// "Hello world".into() -// ), -// } -// ); - -// // Ensure all previously-registered buffers are closed when signing out. -// lsp.handle_request::(|_, _| async { -// Ok(request::SignOutResult {}) -// }); -// copilot -// .update(cx, |copilot, cx| copilot.sign_out(cx)) -// .await -// .unwrap(); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidCloseTextDocumentParams { -// text_document: lsp::TextDocumentIdentifier::new(buffer_2_uri.clone()), -// } -// ); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidCloseTextDocumentParams { -// text_document: lsp::TextDocumentIdentifier::new(buffer_1_uri.clone()), -// } -// ); - -// // Ensure all previously-registered buffers are re-opened when signing in. -// lsp.handle_request::(|_, _| async { -// Ok(request::SignInInitiateResult::AlreadySignedIn { -// user: "user-1".into(), -// }) -// }); -// copilot -// .update(cx, |copilot, cx| copilot.sign_in(cx)) -// .await -// .unwrap(); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidOpenTextDocumentParams { -// text_document: lsp::TextDocumentItem::new( -// buffer_2_uri.clone(), -// "plaintext".into(), -// 0, -// "Goodbye".into() -// ), -// } -// ); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidOpenTextDocumentParams { -// text_document: lsp::TextDocumentItem::new( -// buffer_1_uri.clone(), -// "plaintext".into(), -// 0, -// "Hello world".into() -// ), -// } -// ); - -// // Dropping a buffer causes it to be closed on the LSP side as well. -// cx.update(|_| drop(buffer_2)); -// assert_eq!( -// lsp.receive_notification::() -// .await, -// lsp::DidCloseTextDocumentParams { -// text_document: lsp::TextDocumentIdentifier::new(buffer_2_uri), -// } -// ); -// } - -// struct File { -// abs_path: PathBuf, -// path: Arc, -// } - -// impl language2::File for File { -// fn as_local(&self) -> Option<&dyn language2::LocalFile> { -// Some(self) -// } - -// fn mtime(&self) -> std::time::SystemTime { -// unimplemented!() -// } - -// fn path(&self) -> &Arc { -// &self.path -// } - -// fn full_path(&self, _: &AppContext) -> PathBuf { -// unimplemented!() -// } - -// fn file_name<'a>(&'a self, _: &'a AppContext) -> &'a std::ffi::OsStr { -// unimplemented!() -// } - -// fn is_deleted(&self) -> bool { -// unimplemented!() -// } - -// fn as_any(&self) -> &dyn std::any::Any { -// unimplemented!() -// } - -// fn to_proto(&self) -> rpc::proto::File { -// unimplemented!() -// } - -// fn worktree_id(&self) -> usize { -// 0 -// } -// } - -// impl language::LocalFile for File { -// fn abs_path(&self, _: &AppContext) -> PathBuf { -// self.abs_path.clone() -// } - -// fn load(&self, _: &AppContext) -> Task> { -// unimplemented!() -// } - -// fn buffer_reloaded( -// &self, -// _: u64, -// _: &clock::Global, -// _: language::RopeFingerprint, -// _: language::LineEnding, -// _: std::time::SystemTime, -// _: &mut AppContext, -// ) { -// unimplemented!() -// } -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use gpui::TestAppContext; + + #[gpui::test(iterations = 10)] + async fn test_buffer_management(cx: &mut TestAppContext) { + let (copilot, mut lsp) = Copilot::fake(cx); + + let buffer_1 = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "Hello")); + let buffer_1_uri: lsp::Url = format!("buffer://{}", buffer_1.entity_id().as_u64()) + .parse() + .unwrap(); + copilot.update(cx, |copilot, cx| copilot.register_buffer(&buffer_1, cx)); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidOpenTextDocumentParams { + text_document: lsp::TextDocumentItem::new( + buffer_1_uri.clone(), + "plaintext".into(), + 0, + "Hello".into() + ), + } + ); + + let buffer_2 = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "Goodbye")); + let buffer_2_uri: lsp::Url = format!("buffer://{}", buffer_2.entity_id().as_u64()) + .parse() + .unwrap(); + copilot.update(cx, |copilot, cx| copilot.register_buffer(&buffer_2, cx)); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidOpenTextDocumentParams { + text_document: lsp::TextDocumentItem::new( + buffer_2_uri.clone(), + "plaintext".into(), + 0, + "Goodbye".into() + ), + } + ); + + buffer_1.update(cx, |buffer, cx| buffer.edit([(5..5, " world")], None, cx)); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidChangeTextDocumentParams { + text_document: lsp::VersionedTextDocumentIdentifier::new(buffer_1_uri.clone(), 1), + content_changes: vec![lsp::TextDocumentContentChangeEvent { + range: Some(lsp::Range::new( + lsp::Position::new(0, 5), + lsp::Position::new(0, 5) + )), + range_length: None, + text: " world".into(), + }], + } + ); + + // Ensure updates to the file are reflected in the LSP. + buffer_1.update(cx, |buffer, cx| { + buffer.file_updated( + Arc::new(File { + abs_path: "/root/child/buffer-1".into(), + path: Path::new("child/buffer-1").into(), + }), + cx, + ) + }); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new(buffer_1_uri), + } + ); + let buffer_1_uri = lsp::Url::from_file_path("/root/child/buffer-1").unwrap(); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidOpenTextDocumentParams { + text_document: lsp::TextDocumentItem::new( + buffer_1_uri.clone(), + "plaintext".into(), + 1, + "Hello world".into() + ), + } + ); + + // Ensure all previously-registered buffers are closed when signing out. + lsp.handle_request::(|_, _| async { + Ok(request::SignOutResult {}) + }); + copilot + .update(cx, |copilot, cx| copilot.sign_out(cx)) + .await + .unwrap(); + // todo!() po: these notifications now happen in reverse order? + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new(buffer_1_uri.clone()), + } + ); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new(buffer_2_uri.clone()), + } + ); + + // Ensure all previously-registered buffers are re-opened when signing in. + lsp.handle_request::(|_, _| async { + Ok(request::SignInInitiateResult::AlreadySignedIn { + user: "user-1".into(), + }) + }); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .await + .unwrap(); + + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidOpenTextDocumentParams { + text_document: lsp::TextDocumentItem::new( + buffer_1_uri.clone(), + "plaintext".into(), + 0, + "Hello world".into() + ), + } + ); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidOpenTextDocumentParams { + text_document: lsp::TextDocumentItem::new( + buffer_2_uri.clone(), + "plaintext".into(), + 0, + "Goodbye".into() + ), + } + ); + // Dropping a buffer causes it to be closed on the LSP side as well. + cx.update(|_| drop(buffer_2)); + assert_eq!( + lsp.receive_notification::() + .await, + lsp::DidCloseTextDocumentParams { + text_document: lsp::TextDocumentIdentifier::new(buffer_2_uri), + } + ); + } + + struct File { + abs_path: PathBuf, + path: Arc, + } + + impl language::File for File { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + Some(self) + } + + fn mtime(&self) -> std::time::SystemTime { + unimplemented!() + } + + fn path(&self) -> &Arc { + &self.path + } + + fn full_path(&self, _: &AppContext) -> PathBuf { + unimplemented!() + } + + fn file_name<'a>(&'a self, _: &'a AppContext) -> &'a std::ffi::OsStr { + unimplemented!() + } + + fn is_deleted(&self) -> bool { + unimplemented!() + } + + fn as_any(&self) -> &dyn std::any::Any { + unimplemented!() + } + + fn to_proto(&self) -> rpc::proto::File { + unimplemented!() + } + + fn worktree_id(&self) -> usize { + 0 + } + } + + impl language::LocalFile for File { + fn abs_path(&self, _: &AppContext) -> PathBuf { + self.abs_path.clone() + } + + fn load(&self, _: &AppContext) -> Task> { + unimplemented!() + } + + fn buffer_reloaded( + &self, + _: u64, + _: &clock::Global, + _: language::RopeFingerprint, + _: language::LineEnding, + _: std::time::SystemTime, + _: &mut AppContext, + ) { + unimplemented!() + } + } +} diff --git a/crates/copilot_button2/Cargo.toml b/crates/copilot_button2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..9793ecfb1508060c68f142b3baaafb881a1c8c12 --- /dev/null +++ b/crates/copilot_button2/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "copilot_button2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/copilot_button.rs" +doctest = false + +[dependencies] +copilot = { package = "copilot2", path = "../copilot2" } +editor = { package = "editor2", path = "../editor2" } +fs = { package = "fs2", path = "../fs2" } +zed-actions = { package="zed_actions2", path = "../zed_actions2"} +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +settings = { package = "settings2", path = "../settings2" } +theme = { package = "theme2", path = "../theme2" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } +anyhow.workspace = true +smol.workspace = true +futures.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/copilot_button2/src/copilot_button.rs b/crates/copilot_button2/src/copilot_button.rs new file mode 100644 index 0000000000000000000000000000000000000000..dc6f8085339de18f40bd1c4262bc3b9e0d8c9a67 --- /dev/null +++ b/crates/copilot_button2/src/copilot_button.rs @@ -0,0 +1,370 @@ +#![allow(unused)] +use anyhow::Result; +use copilot::{Copilot, SignOut, Status}; +use editor::{scroll::autoscroll::Autoscroll, Editor}; +use fs::Fs; +use gpui::{ + div, Action, AnchorCorner, AppContext, AsyncAppContext, AsyncWindowContext, Div, Entity, + ParentElement, Render, Subscription, View, ViewContext, WeakView, WindowContext, +}; +use language::{ + language_settings::{self, all_language_settings, AllLanguageSettings}, + File, Language, +}; +use settings::{update_settings_file, Settings, SettingsStore}; +use std::{path::Path, sync::Arc}; +use util::{paths, ResultExt}; +use workspace::{ + create_and_open_local_file, + item::ItemHandle, + ui::{ + popover_menu, ButtonCommon, Clickable, ContextMenu, Icon, IconButton, PopoverMenu, Tooltip, + }, + StatusItemView, Toast, Workspace, +}; +use zed_actions::OpenBrowser; + +const COPILOT_SETTINGS_URL: &str = "https://github.com/settings/copilot"; +const COPILOT_STARTING_TOAST_ID: usize = 1337; +const COPILOT_ERROR_TOAST_ID: usize = 1338; + +pub struct CopilotButton { + editor_subscription: Option<(Subscription, usize)>, + editor_enabled: Option, + language: Option>, + file: Option>, + fs: Arc, +} + +impl Render for CopilotButton { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let all_language_settings = all_language_settings(None, cx); + if !all_language_settings.copilot.feature_enabled { + return div(); + } + + let Some(copilot) = Copilot::global(cx) else { + return div(); + }; + let status = copilot.read(cx).status(); + + let enabled = self + .editor_enabled + .unwrap_or_else(|| all_language_settings.copilot_enabled(None, None)); + + let icon = match status { + Status::Error(_) => Icon::CopilotError, + Status::Authorized => { + if enabled { + Icon::Copilot + } else { + Icon::CopilotDisabled + } + } + _ => Icon::CopilotInit, + }; + + if let Status::Error(e) = status { + return div().child( + IconButton::new("copilot-error", icon) + .on_click(cx.listener(move |this, _, cx| { + if let Some(workspace) = cx.window_handle().downcast::() { + workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new( + COPILOT_ERROR_TOAST_ID, + format!("Copilot can't be started: {}", e), + ) + .on_click( + "Reinstall Copilot", + |cx| { + if let Some(copilot) = Copilot::global(cx) { + copilot + .update(cx, |copilot, cx| copilot.reinstall(cx)) + .detach(); + } + }, + ), + cx, + ); + }); + } + })) + .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)), + ); + } + let this = cx.view().clone(); + + div().child( + popover_menu("copilot") + .menu(move |cx| match status { + Status::Authorized => this.update(cx, |this, cx| this.build_copilot_menu(cx)), + _ => this.update(cx, |this, cx| this.build_copilot_start_menu(cx)), + }) + .anchor(AnchorCorner::BottomRight) + .trigger( + IconButton::new("copilot-icon", icon) + .tooltip(|cx| Tooltip::text("GitHub Copilot", cx)), + ), + ) + } +} + +impl CopilotButton { + pub fn new(fs: Arc, cx: &mut ViewContext) -> Self { + Copilot::global(cx).map(|copilot| cx.observe(&copilot, |_, _, cx| cx.notify()).detach()); + + cx.observe_global::(move |_, cx| cx.notify()) + .detach(); + + Self { + editor_subscription: None, + editor_enabled: None, + language: None, + file: None, + fs, + } + } + + pub fn build_copilot_start_menu(&mut self, cx: &mut ViewContext) -> View { + let fs = self.fs.clone(); + ContextMenu::build(cx, |menu, cx| { + menu.entry("Sign In", initiate_sign_in) + .entry("Disable Copilot", move |cx| hide_copilot(fs.clone(), cx)) + }) + } + + pub fn build_copilot_menu(&mut self, cx: &mut ViewContext) -> View { + let fs = self.fs.clone(); + + return ContextMenu::build(cx, move |mut menu, cx| { + if let Some(language) = self.language.clone() { + let fs = fs.clone(); + let language_enabled = + language_settings::language_settings(Some(&language), None, cx) + .show_copilot_suggestions; + + menu = menu.entry( + format!( + "{} Suggestions for {}", + if language_enabled { "Hide" } else { "Show" }, + language.name() + ), + move |cx| toggle_copilot_for_language(language.clone(), fs.clone(), cx), + ); + } + + let settings = AllLanguageSettings::get_global(cx); + + if let Some(file) = &self.file { + let path = file.path().clone(); + let path_enabled = settings.copilot_enabled_for_path(&path); + + menu = menu.entry( + format!( + "{} Suggestions for This Path", + if path_enabled { "Hide" } else { "Show" } + ), + move |cx| { + if let Some(workspace) = cx.window_handle().downcast::() { + if let Ok(workspace) = workspace.root_view(cx) { + let workspace = workspace.downgrade(); + cx.spawn(|cx| { + configure_disabled_globs( + workspace, + path_enabled.then_some(path.clone()), + cx, + ) + }) + .detach_and_log_err(cx); + } + } + }, + ); + } + + let globally_enabled = settings.copilot_enabled(None, None); + menu.entry( + if globally_enabled { + "Hide Suggestions for All Files" + } else { + "Show Suggestions for All Files" + }, + move |cx| toggle_copilot_globally(fs.clone(), cx), + ) + .separator() + .link( + "Copilot Settings", + OpenBrowser { + url: COPILOT_SETTINGS_URL.to_string(), + } + .boxed_clone(), + ) + .action("Sign Out", SignOut.boxed_clone()) + }); + } + + pub fn update_enabled(&mut self, editor: View, cx: &mut ViewContext) { + let editor = editor.read(cx); + let snapshot = editor.buffer().read(cx).snapshot(cx); + let suggestion_anchor = editor.selections.newest_anchor().start; + let language = snapshot.language_at(suggestion_anchor); + let file = snapshot.file_at(suggestion_anchor).cloned(); + + self.editor_enabled = Some( + all_language_settings(self.file.as_ref(), cx) + .copilot_enabled(language, file.as_ref().map(|file| file.path().as_ref())), + ); + self.language = language.cloned(); + self.file = file; + + cx.notify() + } +} + +impl StatusItemView for CopilotButton { + fn set_active_pane_item(&mut self, item: Option<&dyn ItemHandle>, cx: &mut ViewContext) { + if let Some(editor) = item.map(|item| item.act_as::(cx)).flatten() { + self.editor_subscription = Some(( + cx.observe(&editor, Self::update_enabled), + editor.entity_id().as_u64() as usize, + )); + self.update_enabled(editor, cx); + } else { + self.language = None; + self.editor_subscription = None; + self.editor_enabled = None; + } + cx.notify(); + } +} + +async fn configure_disabled_globs( + workspace: WeakView, + path_to_disable: Option>, + mut cx: AsyncWindowContext, +) -> Result<()> { + let settings_editor = workspace + .update(&mut cx, |_, cx| { + create_and_open_local_file(&paths::SETTINGS, cx, || { + settings::initial_user_settings_content().as_ref().into() + }) + })? + .await? + .downcast::() + .unwrap(); + + settings_editor.downgrade().update(&mut cx, |item, cx| { + let text = item.buffer().read(cx).snapshot(cx).text(); + + let settings = cx.global::(); + let edits = settings.edits_for_update::(&text, |file| { + let copilot = file.copilot.get_or_insert_with(Default::default); + let globs = copilot.disabled_globs.get_or_insert_with(|| { + settings + .get::(None) + .copilot + .disabled_globs + .iter() + .map(|glob| glob.glob().to_string()) + .collect() + }); + + if let Some(path_to_disable) = &path_to_disable { + globs.push(path_to_disable.to_string_lossy().into_owned()); + } else { + globs.clear(); + } + }); + + if !edits.is_empty() { + item.change_selections(Some(Autoscroll::newest()), cx, |selections| { + selections.select_ranges(edits.iter().map(|e| e.0.clone())); + }); + + // When *enabling* a path, don't actually perform an edit, just select the range. + if path_to_disable.is_some() { + item.edit(edits.iter().cloned(), cx); + } + } + })?; + + anyhow::Ok(()) +} + +fn toggle_copilot_globally(fs: Arc, cx: &mut AppContext) { + let show_copilot_suggestions = all_language_settings(None, cx).copilot_enabled(None, None); + update_settings_file::(fs, cx, move |file| { + file.defaults.show_copilot_suggestions = Some((!show_copilot_suggestions).into()) + }); +} + +fn toggle_copilot_for_language(language: Arc, fs: Arc, cx: &mut AppContext) { + let show_copilot_suggestions = + all_language_settings(None, cx).copilot_enabled(Some(&language), None); + update_settings_file::(fs, cx, move |file| { + file.languages + .entry(language.name()) + .or_default() + .show_copilot_suggestions = Some(!show_copilot_suggestions); + }); +} + +fn hide_copilot(fs: Arc, cx: &mut AppContext) { + update_settings_file::(fs, cx, move |file| { + file.features.get_or_insert(Default::default()).copilot = Some(false); + }); +} + +fn initiate_sign_in(cx: &mut WindowContext) { + let Some(copilot) = Copilot::global(cx) else { + return; + }; + let status = copilot.read(cx).status(); + + match status { + Status::Starting { task } => { + let Some(workspace) = cx.window_handle().downcast::() else { + return; + }; + + let Ok(workspace) = workspace.update(cx, |workspace, cx| { + workspace.show_toast( + Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot is starting..."), + cx, + ); + workspace.weak_handle() + }) else { + return; + }; + + cx.spawn(|mut cx| async move { + task.await; + if let Some(copilot) = cx.update(|_, cx| Copilot::global(cx)).ok().flatten() { + workspace + .update(&mut cx, |workspace, cx| match copilot.read(cx).status() { + Status::Authorized => workspace.show_toast( + Toast::new(COPILOT_STARTING_TOAST_ID, "Copilot has started!"), + cx, + ), + _ => { + workspace.dismiss_toast(COPILOT_STARTING_TOAST_ID, cx); + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + } + }) + .log_err(); + } + }) + .detach(); + } + _ => { + copilot + .update(cx, |copilot, cx| copilot.sign_in(cx)) + .detach_and_log_err(cx); + } + } +} diff --git a/crates/diagnostics2/src/diagnostics.rs b/crates/diagnostics2/src/diagnostics.rs index 0a0f4da8932a50b1ddac155e27d3fb8ff22cd53a..dd01f90b9f0623b3658673304464229411e3b801 100644 --- a/crates/diagnostics2/src/diagnostics.rs +++ b/crates/diagnostics2/src/diagnostics.rs @@ -774,24 +774,39 @@ fn diagnostic_header_renderer(diagnostic: Diagnostic) -> RenderBlock { Arc::new(move |_| { h_stack() .id("diagnostic header") - .gap_3() - .bg(gpui::red()) - .map(|stack| { - let icon = if diagnostic.severity == DiagnosticSeverity::ERROR { - IconElement::new(Icon::XCircle).color(Color::Error) - } else { - IconElement::new(Icon::ExclamationTriangle).color(Color::Warning) - }; - - stack.child(div().pl_8().child(icon)) - }) - .when_some(diagnostic.source.as_ref(), |stack, source| { - stack.child(Label::new(format!("{source}:")).color(Color::Accent)) - }) - .child(HighlightedLabel::new(message.clone(), highlights.clone())) - .when_some(diagnostic.code.as_ref(), |stack, code| { - stack.child(Label::new(code.clone())) - }) + .py_2() + .pl_10() + .pr_5() + .w_full() + .justify_between() + .gap_2() + .child( + h_stack() + .gap_3() + .map(|stack| { + let icon = if diagnostic.severity == DiagnosticSeverity::ERROR { + IconElement::new(Icon::XCircle).color(Color::Error) + } else { + IconElement::new(Icon::ExclamationTriangle).color(Color::Warning) + }; + stack.child(icon) + }) + .child( + h_stack() + .gap_1() + .child(HighlightedLabel::new(message.clone(), highlights.clone())) + .when_some(diagnostic.code.as_ref(), |stack, code| { + stack.child(Label::new(format!("({code})")).color(Color::Muted)) + }), + ), + ) + .child( + h_stack() + .gap_1() + .when_some(diagnostic.source.as_ref(), |stack, source| { + stack.child(Label::new(format!("{source}")).color(Color::Muted)) + }), + ) .into_any_element() }) } @@ -802,11 +817,22 @@ pub(crate) fn render_summary(summary: &DiagnosticSummary) -> AnyElement { label.into_any_element() } else { h_stack() - .bg(gpui::red()) - .child(IconElement::new(Icon::XCircle)) - .child(Label::new(summary.error_count.to_string())) - .child(IconElement::new(Icon::ExclamationTriangle)) - .child(Label::new(summary.warning_count.to_string())) + .gap_1() + .when(summary.error_count > 0, |then| { + then.child( + h_stack() + .gap_1() + .child(IconElement::new(Icon::XCircle).color(Color::Error)) + .child(Label::new(summary.error_count.to_string())), + ) + }) + .when(summary.warning_count > 0, |then| { + then.child( + h_stack() + .child(IconElement::new(Icon::ExclamationTriangle).color(Color::Warning)) + .child(Label::new(summary.warning_count.to_string())), + ) + }) .into_any_element() } } diff --git a/crates/editor2/src/display_map.rs b/crates/editor2/src/display_map.rs index 533abcd871b6165bc3f400dbceda69122b71b361..1aee04dd0ae02b8d4ea98025be177a82e3801ef7 100644 --- a/crates/editor2/src/display_map.rs +++ b/crates/editor2/src/display_map.rs @@ -990,905 +990,869 @@ pub fn next_rows(display_row: u32, display_map: &DisplaySnapshot) -> impl Iterat }) } -// #[cfg(test)] -// pub mod tests { -// use super::*; -// use crate::{ -// movement, -// test::{editor_test_context::EditorTestContext, marked_display_snapshot}, -// }; -// use gpui::{AppContext, Hsla}; -// use language::{ -// language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, -// Buffer, Language, LanguageConfig, SelectionGoal, -// }; -// use project::Project; -// use rand::{prelude::*, Rng}; -// use settings::SettingsStore; -// use smol::stream::StreamExt; -// use std::{env, sync::Arc}; -// use theme::SyntaxTheme; -// use util::test::{marked_text_ranges, sample_text}; -// use Bias::*; - -// #[gpui::test(iterations = 100)] -// async fn test_random_display_map(cx: &mut gpui::TestAppContext, mut rng: StdRng) { -// cx.foreground().set_block_on_ticks(0..=50); -// cx.foreground().forbid_parking(); -// let operations = env::var("OPERATIONS") -// .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) -// .unwrap_or(10); - -// let font_cache = cx.font_cache().clone(); -// let mut tab_size = rng.gen_range(1..=4); -// let buffer_start_excerpt_header_height = rng.gen_range(1..=5); -// let excerpt_header_height = rng.gen_range(1..=5); -// let family_id = font_cache -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; -// let max_wrap_width = 300.0; -// let mut wrap_width = if rng.gen_bool(0.1) { -// None -// } else { -// Some(rng.gen_range(0.0..=max_wrap_width)) -// }; - -// log::info!("tab size: {}", tab_size); -// log::info!("wrap width: {:?}", wrap_width); - -// cx.update(|cx| { -// init_test(cx, |s| s.defaults.tab_size = NonZeroU32::new(tab_size)); -// }); - -// let buffer = cx.update(|cx| { -// if rng.gen() { -// let len = rng.gen_range(0..10); -// let text = util::RandomCharIter::new(&mut rng) -// .take(len) -// .collect::(); -// MultiBuffer::build_simple(&text, cx) -// } else { -// MultiBuffer::build_random(&mut rng, cx) -// } -// }); - -// let map = cx.add_model(|cx| { -// DisplayMap::new( -// buffer.clone(), -// font_id, -// font_size, -// wrap_width, -// buffer_start_excerpt_header_height, -// excerpt_header_height, -// cx, -// ) -// }); -// let mut notifications = observe(&map, cx); -// let mut fold_count = 0; -// let mut blocks = Vec::new(); - -// let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); -// log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text()); -// log::info!("fold text: {:?}", snapshot.fold_snapshot.text()); -// log::info!("tab text: {:?}", snapshot.tab_snapshot.text()); -// log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text()); -// log::info!("block text: {:?}", snapshot.block_snapshot.text()); -// log::info!("display text: {:?}", snapshot.text()); - -// for _i in 0..operations { -// match rng.gen_range(0..100) { -// 0..=19 => { -// wrap_width = if rng.gen_bool(0.2) { -// None -// } else { -// Some(rng.gen_range(0.0..=max_wrap_width)) -// }; -// log::info!("setting wrap width to {:?}", wrap_width); -// map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); -// } -// 20..=29 => { -// let mut tab_sizes = vec![1, 2, 3, 4]; -// tab_sizes.remove((tab_size - 1) as usize); -// tab_size = *tab_sizes.choose(&mut rng).unwrap(); -// log::info!("setting tab size to {:?}", tab_size); -// cx.update(|cx| { -// cx.update_global::(|store, cx| { -// store.update_user_settings::(cx, |s| { -// s.defaults.tab_size = NonZeroU32::new(tab_size); -// }); -// }); -// }); -// } -// 30..=44 => { -// map.update(cx, |map, cx| { -// if rng.gen() || blocks.is_empty() { -// let buffer = map.snapshot(cx).buffer_snapshot; -// let block_properties = (0..rng.gen_range(1..=1)) -// .map(|_| { -// let position = -// buffer.anchor_after(buffer.clip_offset( -// rng.gen_range(0..=buffer.len()), -// Bias::Left, -// )); - -// let disposition = if rng.gen() { -// BlockDisposition::Above -// } else { -// BlockDisposition::Below -// }; -// let height = rng.gen_range(1..5); -// log::info!( -// "inserting block {:?} {:?} with height {}", -// disposition, -// position.to_point(&buffer), -// height -// ); -// BlockProperties { -// style: BlockStyle::Fixed, -// position, -// height, -// disposition, -// render: Arc::new(|_| Empty::new().into_any()), -// } -// }) -// .collect::>(); -// blocks.extend(map.insert_blocks(block_properties, cx)); -// } else { -// blocks.shuffle(&mut rng); -// let remove_count = rng.gen_range(1..=4.min(blocks.len())); -// let block_ids_to_remove = (0..remove_count) -// .map(|_| blocks.remove(rng.gen_range(0..blocks.len()))) -// .collect(); -// log::info!("removing block ids {:?}", block_ids_to_remove); -// map.remove_blocks(block_ids_to_remove, cx); -// } -// }); -// } -// 45..=79 => { -// let mut ranges = Vec::new(); -// for _ in 0..rng.gen_range(1..=3) { -// buffer.read_with(cx, |buffer, cx| { -// let buffer = buffer.read(cx); -// let end = buffer.clip_offset(rng.gen_range(0..=buffer.len()), Right); -// let start = buffer.clip_offset(rng.gen_range(0..=end), Left); -// ranges.push(start..end); -// }); -// } - -// if rng.gen() && fold_count > 0 { -// log::info!("unfolding ranges: {:?}", ranges); -// map.update(cx, |map, cx| { -// map.unfold(ranges, true, cx); -// }); -// } else { -// log::info!("folding ranges: {:?}", ranges); -// map.update(cx, |map, cx| { -// map.fold(ranges, cx); -// }); -// } -// } -// _ => { -// buffer.update(cx, |buffer, cx| buffer.randomly_mutate(&mut rng, 5, cx)); -// } -// } - -// if map.read_with(cx, |map, cx| map.is_rewrapping(cx)) { -// notifications.next().await.unwrap(); -// } - -// let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); -// fold_count = snapshot.fold_count(); -// log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text()); -// log::info!("fold text: {:?}", snapshot.fold_snapshot.text()); -// log::info!("tab text: {:?}", snapshot.tab_snapshot.text()); -// log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text()); -// log::info!("block text: {:?}", snapshot.block_snapshot.text()); -// log::info!("display text: {:?}", snapshot.text()); - -// // Line boundaries -// let buffer = &snapshot.buffer_snapshot; -// for _ in 0..5 { -// let row = rng.gen_range(0..=buffer.max_point().row); -// let column = rng.gen_range(0..=buffer.line_len(row)); -// let point = buffer.clip_point(Point::new(row, column), Left); - -// let (prev_buffer_bound, prev_display_bound) = snapshot.prev_line_boundary(point); -// let (next_buffer_bound, next_display_bound) = snapshot.next_line_boundary(point); - -// assert!(prev_buffer_bound <= point); -// assert!(next_buffer_bound >= point); -// assert_eq!(prev_buffer_bound.column, 0); -// assert_eq!(prev_display_bound.column(), 0); -// if next_buffer_bound < buffer.max_point() { -// assert_eq!(buffer.chars_at(next_buffer_bound).next(), Some('\n')); -// } - -// assert_eq!( -// prev_display_bound, -// prev_buffer_bound.to_display_point(&snapshot), -// "row boundary before {:?}. reported buffer row boundary: {:?}", -// point, -// prev_buffer_bound -// ); -// assert_eq!( -// next_display_bound, -// next_buffer_bound.to_display_point(&snapshot), -// "display row boundary after {:?}. reported buffer row boundary: {:?}", -// point, -// next_buffer_bound -// ); -// assert_eq!( -// prev_buffer_bound, -// prev_display_bound.to_point(&snapshot), -// "row boundary before {:?}. reported display row boundary: {:?}", -// point, -// prev_display_bound -// ); -// assert_eq!( -// next_buffer_bound, -// next_display_bound.to_point(&snapshot), -// "row boundary after {:?}. reported display row boundary: {:?}", -// point, -// next_display_bound -// ); -// } - -// // Movement -// let min_point = snapshot.clip_point(DisplayPoint::new(0, 0), Left); -// let max_point = snapshot.clip_point(snapshot.max_point(), Right); -// for _ in 0..5 { -// let row = rng.gen_range(0..=snapshot.max_point().row()); -// let column = rng.gen_range(0..=snapshot.line_len(row)); -// let point = snapshot.clip_point(DisplayPoint::new(row, column), Left); - -// log::info!("Moving from point {:?}", point); - -// let moved_right = movement::right(&snapshot, point); -// log::info!("Right {:?}", moved_right); -// if point < max_point { -// assert!(moved_right > point); -// if point.column() == snapshot.line_len(point.row()) -// || snapshot.soft_wrap_indent(point.row()).is_some() -// && point.column() == snapshot.line_len(point.row()) - 1 -// { -// assert!(moved_right.row() > point.row()); -// } -// } else { -// assert_eq!(moved_right, point); -// } - -// let moved_left = movement::left(&snapshot, point); -// log::info!("Left {:?}", moved_left); -// if point > min_point { -// assert!(moved_left < point); -// if point.column() == 0 { -// assert!(moved_left.row() < point.row()); -// } -// } else { -// assert_eq!(moved_left, point); -// } -// } -// } -// } - -// #[gpui::test(retries = 5)] -// async fn test_soft_wraps(cx: &mut gpui::TestAppContext) { -// cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX); -// cx.update(|cx| { -// init_test(cx, |_| {}); -// }); - -// let mut cx = EditorTestContext::new(cx).await; -// let editor = cx.editor.clone(); -// let window = cx.window.clone(); - -// cx.update_window(window, |cx| { -// let text_layout_details = -// editor.read_with(cx, |editor, cx| editor.text_layout_details(cx)); - -// let font_cache = cx.font_cache().clone(); - -// let family_id = font_cache -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 12.0; -// let wrap_width = Some(64.); - -// let text = "one two three four five\nsix seven eight"; -// let buffer = MultiBuffer::build_simple(text, cx); -// let map = cx.add_model(|cx| { -// DisplayMap::new(buffer.clone(), font_id, font_size, wrap_width, 1, 1, cx) -// }); - -// let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); -// assert_eq!( -// snapshot.text_chunks(0).collect::(), -// "one two \nthree four \nfive\nsix seven \neight" -// ); -// assert_eq!( -// snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left), -// DisplayPoint::new(0, 7) -// ); -// assert_eq!( -// snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right), -// DisplayPoint::new(1, 0) -// ); -// assert_eq!( -// movement::right(&snapshot, DisplayPoint::new(0, 7)), -// DisplayPoint::new(1, 0) -// ); -// assert_eq!( -// movement::left(&snapshot, DisplayPoint::new(1, 0)), -// DisplayPoint::new(0, 7) -// ); - -// let x = snapshot.x_for_point(DisplayPoint::new(1, 10), &text_layout_details); -// assert_eq!( -// movement::up( -// &snapshot, -// DisplayPoint::new(1, 10), -// SelectionGoal::None, -// false, -// &text_layout_details, -// ), -// ( -// DisplayPoint::new(0, 7), -// SelectionGoal::HorizontalPosition(x) -// ) -// ); -// assert_eq!( -// movement::down( -// &snapshot, -// DisplayPoint::new(0, 7), -// SelectionGoal::HorizontalPosition(x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(1, 10), -// SelectionGoal::HorizontalPosition(x) -// ) -// ); -// assert_eq!( -// movement::down( -// &snapshot, -// DisplayPoint::new(1, 10), -// SelectionGoal::HorizontalPosition(x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(2, 4), -// SelectionGoal::HorizontalPosition(x) -// ) -// ); - -// let ix = snapshot.buffer_snapshot.text().find("seven").unwrap(); -// buffer.update(cx, |buffer, cx| { -// buffer.edit([(ix..ix, "and ")], None, cx); -// }); - -// let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); -// assert_eq!( -// snapshot.text_chunks(1).collect::(), -// "three four \nfive\nsix and \nseven eight" -// ); - -// // Re-wrap on font size changes -// map.update(cx, |map, cx| map.set_font_with_size(font_id, font_size + 3., cx)); - -// let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); -// assert_eq!( -// snapshot.text_chunks(1).collect::(), -// "three \nfour five\nsix and \nseven \neight" -// ) -// }); -// } - -// #[gpui::test] -// fn test_text_chunks(cx: &mut gpui::AppContext) { -// init_test(cx, |_| {}); - -// let text = sample_text(6, 6, 'a'); -// let buffer = MultiBuffer::build_simple(&text, cx); -// let family_id = cx -// .font_cache() -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = cx -// .font_cache() -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; -// let map = -// cx.add_model(|cx| DisplayMap::new(buffer.clone(), font_id, font_size, None, 1, 1, cx)); - -// buffer.update(cx, |buffer, cx| { -// buffer.edit( -// vec![ -// (Point::new(1, 0)..Point::new(1, 0), "\t"), -// (Point::new(1, 1)..Point::new(1, 1), "\t"), -// (Point::new(2, 1)..Point::new(2, 1), "\t"), -// ], -// None, -// cx, -// ) -// }); - -// assert_eq!( -// map.update(cx, |map, cx| map.snapshot(cx)) -// .text_chunks(1) -// .collect::() -// .lines() -// .next(), -// Some(" b bbbbb") -// ); -// assert_eq!( -// map.update(cx, |map, cx| map.snapshot(cx)) -// .text_chunks(2) -// .collect::() -// .lines() -// .next(), -// Some("c ccccc") -// ); -// } - -// #[gpui::test] -// async fn test_chunks(cx: &mut gpui::TestAppContext) { -// use unindent::Unindent as _; - -// let text = r#" -// fn outer() {} - -// mod module { -// fn inner() {} -// }"# -// .unindent(); - -// let theme = SyntaxTheme::new(vec![ -// ("mod.body".to_string(), Hsla::red().into()), -// ("fn.name".to_string(), Hsla::blue().into()), -// ]); -// let language = Arc::new( -// Language::new( -// LanguageConfig { -// name: "Test".into(), -// path_suffixes: vec![".test".to_string()], -// ..Default::default() -// }, -// Some(tree_sitter_rust::language()), -// ) -// .with_highlights_query( -// r#" -// (mod_item name: (identifier) body: _ @mod.body) -// (function_item name: (identifier) @fn.name) -// "#, -// ) -// .unwrap(), -// ); -// language.set_theme(&theme); - -// cx.update(|cx| init_test(cx, |s| s.defaults.tab_size = Some(2.try_into().unwrap()))); - -// let buffer = cx -// .add_model(|cx| Buffer::new(0, cx.model_id() as u64, text).with_language(language, cx)); -// buffer.condition(cx, |buf, _| !buf.is_parsing()).await; -// let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - -// let font_cache = cx.font_cache(); -// let family_id = font_cache -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; - -// let map = cx.add_model(|cx| DisplayMap::new(buffer, font_id, font_size, None, 1, 1, cx)); -// assert_eq!( -// cx.update(|cx| syntax_chunks(0..5, &map, &theme, cx)), -// vec![ -// ("fn ".to_string(), None), -// ("outer".to_string(), Some(Hsla::blue())), -// ("() {}\n\nmod module ".to_string(), None), -// ("{\n fn ".to_string(), Some(Hsla::red())), -// ("inner".to_string(), Some(Hsla::blue())), -// ("() {}\n}".to_string(), Some(Hsla::red())), -// ] -// ); -// assert_eq!( -// cx.update(|cx| syntax_chunks(3..5, &map, &theme, cx)), -// vec![ -// (" fn ".to_string(), Some(Hsla::red())), -// ("inner".to_string(), Some(Hsla::blue())), -// ("() {}\n}".to_string(), Some(Hsla::red())), -// ] -// ); - -// map.update(cx, |map, cx| { -// map.fold(vec![Point::new(0, 6)..Point::new(3, 2)], cx) -// }); -// assert_eq!( -// cx.update(|cx| syntax_chunks(0..2, &map, &theme, cx)), -// vec![ -// ("fn ".to_string(), None), -// ("out".to_string(), Some(Hsla::blue())), -// ("⋯".to_string(), None), -// (" fn ".to_string(), Some(Hsla::red())), -// ("inner".to_string(), Some(Hsla::blue())), -// ("() {}\n}".to_string(), Some(Hsla::red())), -// ] -// ); -// } - -// #[gpui::test] -// async fn test_chunks_with_soft_wrapping(cx: &mut gpui::TestAppContext) { -// use unindent::Unindent as _; - -// cx.foreground().set_block_on_ticks(usize::MAX..=usize::MAX); - -// let text = r#" -// fn outer() {} - -// mod module { -// fn inner() {} -// }"# -// .unindent(); - -// let theme = SyntaxTheme::new(vec![ -// ("mod.body".to_string(), Hsla::red().into()), -// ("fn.name".to_string(), Hsla::blue().into()), -// ]); -// let language = Arc::new( -// Language::new( -// LanguageConfig { -// name: "Test".into(), -// path_suffixes: vec![".test".to_string()], -// ..Default::default() -// }, -// Some(tree_sitter_rust::language()), -// ) -// .with_highlights_query( -// r#" -// (mod_item name: (identifier) body: _ @mod.body) -// (function_item name: (identifier) @fn.name) -// "#, -// ) -// .unwrap(), -// ); -// language.set_theme(&theme); - -// cx.update(|cx| init_test(cx, |_| {})); - -// let buffer = cx -// .add_model(|cx| Buffer::new(0, cx.model_id() as u64, text).with_language(language, cx)); -// buffer.condition(cx, |buf, _| !buf.is_parsing()).await; -// let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); - -// let font_cache = cx.font_cache(); - -// let family_id = font_cache -// .load_family(&["Courier"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 16.0; - -// let map = -// cx.add_model(|cx| DisplayMap::new(buffer, font_id, font_size, Some(40.0), 1, 1, cx)); -// assert_eq!( -// cx.update(|cx| syntax_chunks(0..5, &map, &theme, cx)), -// [ -// ("fn \n".to_string(), None), -// ("oute\nr".to_string(), Some(Hsla::blue())), -// ("() \n{}\n\n".to_string(), None), -// ] -// ); -// assert_eq!( -// cx.update(|cx| syntax_chunks(3..5, &map, &theme, cx)), -// [("{}\n\n".to_string(), None)] -// ); - -// map.update(cx, |map, cx| { -// map.fold(vec![Point::new(0, 6)..Point::new(3, 2)], cx) -// }); -// assert_eq!( -// cx.update(|cx| syntax_chunks(1..4, &map, &theme, cx)), -// [ -// ("out".to_string(), Some(Hsla::blue())), -// ("⋯\n".to_string(), None), -// (" \nfn ".to_string(), Some(Hsla::red())), -// ("i\n".to_string(), Some(Hsla::blue())) -// ] -// ); -// } - -// #[gpui::test] -// async fn test_chunks_with_text_highlights(cx: &mut gpui::TestAppContext) { -// cx.update(|cx| init_test(cx, |_| {})); - -// let theme = SyntaxTheme::new(vec![ -// ("operator".to_string(), Hsla::red().into()), -// ("string".to_string(), Hsla::green().into()), -// ]); -// let language = Arc::new( -// Language::new( -// LanguageConfig { -// name: "Test".into(), -// path_suffixes: vec![".test".to_string()], -// ..Default::default() -// }, -// Some(tree_sitter_rust::language()), -// ) -// .with_highlights_query( -// r#" -// ":" @operator -// (string_literal) @string -// "#, -// ) -// .unwrap(), -// ); -// language.set_theme(&theme); - -// let (text, highlighted_ranges) = marked_text_ranges(r#"constˇ «a»: B = "c «d»""#, false); - -// let buffer = cx -// .add_model(|cx| Buffer::new(0, cx.model_id() as u64, text).with_language(language, cx)); -// buffer.condition(cx, |buf, _| !buf.is_parsing()).await; - -// let buffer = cx.add_model(|cx| MultiBuffer::singleton(buffer, cx)); -// let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); - -// let font_cache = cx.font_cache(); -// let family_id = font_cache -// .load_family(&["Courier"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 16.0; -// let map = cx.add_model(|cx| DisplayMap::new(buffer, font_id, font_size, None, 1, 1, cx)); - -// enum MyType {} - -// let style = HighlightStyle { -// color: Some(Hsla::blue()), -// ..Default::default() -// }; - -// map.update(cx, |map, _cx| { -// map.highlight_text( -// TypeId::of::(), -// highlighted_ranges -// .into_iter() -// .map(|range| { -// buffer_snapshot.anchor_before(range.start) -// ..buffer_snapshot.anchor_before(range.end) -// }) -// .collect(), -// style, -// ); -// }); - -// assert_eq!( -// cx.update(|cx| chunks(0..10, &map, &theme, cx)), -// [ -// ("const ".to_string(), None, None), -// ("a".to_string(), None, Some(Hsla::blue())), -// (":".to_string(), Some(Hsla::red()), None), -// (" B = ".to_string(), None, None), -// ("\"c ".to_string(), Some(Hsla::green()), None), -// ("d".to_string(), Some(Hsla::green()), Some(Hsla::blue())), -// ("\"".to_string(), Some(Hsla::green()), None), -// ] -// ); -// } - -// #[gpui::test] -// fn test_clip_point(cx: &mut gpui::AppContext) { -// init_test(cx, |_| {}); - -// fn assert(text: &str, shift_right: bool, bias: Bias, cx: &mut gpui::AppContext) { -// let (unmarked_snapshot, mut markers) = marked_display_snapshot(text, cx); - -// match bias { -// Bias::Left => { -// if shift_right { -// *markers[1].column_mut() += 1; -// } - -// assert_eq!(unmarked_snapshot.clip_point(markers[1], bias), markers[0]) -// } -// Bias::Right => { -// if shift_right { -// *markers[0].column_mut() += 1; -// } - -// assert_eq!(unmarked_snapshot.clip_point(markers[0], bias), markers[1]) -// } -// }; -// } - -// use Bias::{Left, Right}; -// assert("ˇˇα", false, Left, cx); -// assert("ˇˇα", true, Left, cx); -// assert("ˇˇα", false, Right, cx); -// assert("ˇαˇ", true, Right, cx); -// assert("ˇˇ✋", false, Left, cx); -// assert("ˇˇ✋", true, Left, cx); -// assert("ˇˇ✋", false, Right, cx); -// assert("ˇ✋ˇ", true, Right, cx); -// assert("ˇˇ🍐", false, Left, cx); -// assert("ˇˇ🍐", true, Left, cx); -// assert("ˇˇ🍐", false, Right, cx); -// assert("ˇ🍐ˇ", true, Right, cx); -// assert("ˇˇ\t", false, Left, cx); -// assert("ˇˇ\t", true, Left, cx); -// assert("ˇˇ\t", false, Right, cx); -// assert("ˇ\tˇ", true, Right, cx); -// assert(" ˇˇ\t", false, Left, cx); -// assert(" ˇˇ\t", true, Left, cx); -// assert(" ˇˇ\t", false, Right, cx); -// assert(" ˇ\tˇ", true, Right, cx); -// assert(" ˇˇ\t", false, Left, cx); -// assert(" ˇˇ\t", false, Right, cx); -// } - -// #[gpui::test] -// fn test_clip_at_line_ends(cx: &mut gpui::AppContext) { -// init_test(cx, |_| {}); - -// fn assert(text: &str, cx: &mut gpui::AppContext) { -// let (mut unmarked_snapshot, markers) = marked_display_snapshot(text, cx); -// unmarked_snapshot.clip_at_line_ends = true; -// assert_eq!( -// unmarked_snapshot.clip_point(markers[1], Bias::Left), -// markers[0] -// ); -// } - -// assert("ˇˇ", cx); -// assert("ˇaˇ", cx); -// assert("aˇbˇ", cx); -// assert("aˇαˇ", cx); -// } - -// #[gpui::test] -// fn test_tabs_with_multibyte_chars(cx: &mut gpui::AppContext) { -// init_test(cx, |_| {}); - -// let text = "✅\t\tα\nβ\t\n🏀β\t\tγ"; -// let buffer = MultiBuffer::build_simple(text, cx); -// let font_cache = cx.font_cache(); -// let family_id = font_cache -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; - -// let map = -// cx.add_model(|cx| DisplayMap::new(buffer.clone(), font_id, font_size, None, 1, 1, cx)); -// let map = map.update(cx, |map, cx| map.snapshot(cx)); -// assert_eq!(map.text(), "✅ α\nβ \n🏀β γ"); -// assert_eq!( -// map.text_chunks(0).collect::(), -// "✅ α\nβ \n🏀β γ" -// ); -// assert_eq!(map.text_chunks(1).collect::(), "β \n🏀β γ"); -// assert_eq!(map.text_chunks(2).collect::(), "🏀β γ"); - -// let point = Point::new(0, "✅\t\t".len() as u32); -// let display_point = DisplayPoint::new(0, "✅ ".len() as u32); -// assert_eq!(point.to_display_point(&map), display_point); -// assert_eq!(display_point.to_point(&map), point); - -// let point = Point::new(1, "β\t".len() as u32); -// let display_point = DisplayPoint::new(1, "β ".len() as u32); -// assert_eq!(point.to_display_point(&map), display_point); -// assert_eq!(display_point.to_point(&map), point,); - -// let point = Point::new(2, "🏀β\t\t".len() as u32); -// let display_point = DisplayPoint::new(2, "🏀β ".len() as u32); -// assert_eq!(point.to_display_point(&map), display_point); -// assert_eq!(display_point.to_point(&map), point,); - -// // Display points inside of expanded tabs -// assert_eq!( -// DisplayPoint::new(0, "✅ ".len() as u32).to_point(&map), -// Point::new(0, "✅\t".len() as u32), -// ); -// assert_eq!( -// DisplayPoint::new(0, "✅ ".len() as u32).to_point(&map), -// Point::new(0, "✅".len() as u32), -// ); - -// // Clipping display points inside of multi-byte characters -// assert_eq!( -// map.clip_point(DisplayPoint::new(0, "✅".len() as u32 - 1), Left), -// DisplayPoint::new(0, 0) -// ); -// assert_eq!( -// map.clip_point(DisplayPoint::new(0, "✅".len() as u32 - 1), Bias::Right), -// DisplayPoint::new(0, "✅".len() as u32) -// ); -// } - -// #[gpui::test] -// fn test_max_point(cx: &mut gpui::AppContext) { -// init_test(cx, |_| {}); - -// let buffer = MultiBuffer::build_simple("aaa\n\t\tbbb", cx); -// let font_cache = cx.font_cache(); -// let family_id = font_cache -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; -// let map = -// cx.add_model(|cx| DisplayMap::new(buffer.clone(), font_id, font_size, None, 1, 1, cx)); -// assert_eq!( -// map.update(cx, |map, cx| map.snapshot(cx)).max_point(), -// DisplayPoint::new(1, 11) -// ) -// } - -// fn syntax_chunks<'a>( -// rows: Range, -// map: &Model, -// theme: &'a SyntaxTheme, -// cx: &mut AppContext, -// ) -> Vec<(String, Option)> { -// chunks(rows, map, theme, cx) -// .into_iter() -// .map(|(text, color, _)| (text, color)) -// .collect() -// } - -// fn chunks<'a>( -// rows: Range, -// map: &Model, -// theme: &'a SyntaxTheme, -// cx: &mut AppContext, -// ) -> Vec<(String, Option, Option)> { -// let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); -// let mut chunks: Vec<(String, Option, Option)> = Vec::new(); -// for chunk in snapshot.chunks(rows, true, None, None) { -// let syntax_color = chunk -// .syntax_highlight_id -// .and_then(|id| id.style(theme)?.color); -// let highlight_color = chunk.highlight_style.and_then(|style| style.color); -// if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() { -// if syntax_color == *last_syntax_color && highlight_color == *last_highlight_color { -// last_chunk.push_str(chunk.text); -// continue; -// } -// } -// chunks.push((chunk.text.to_string(), syntax_color, highlight_color)); -// } -// chunks -// } - -// fn init_test(cx: &mut AppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { -// cx.foreground().forbid_parking(); -// cx.set_global(SettingsStore::test(cx)); -// language::init(cx); -// crate::init(cx); -// Project::init_settings(cx); -// theme::init((), cx); -// cx.update_global::(|store, cx| { -// store.update_user_settings::(cx, f); -// }); -// } -// } +#[cfg(test)] +pub mod tests { + use super::*; + use crate::{ + movement, + test::{editor_test_context::EditorTestContext, marked_display_snapshot}, + }; + use gpui::{div, font, observe, px, AppContext, Context, Element, Hsla}; + use language::{ + language_settings::{AllLanguageSettings, AllLanguageSettingsContent}, + Buffer, Language, LanguageConfig, SelectionGoal, + }; + use project::Project; + use rand::{prelude::*, Rng}; + use settings::SettingsStore; + use smol::stream::StreamExt; + use std::{env, sync::Arc}; + use theme::{LoadThemes, SyntaxTheme}; + use util::test::{marked_text_ranges, sample_text}; + use Bias::*; + + #[gpui::test(iterations = 100)] + async fn test_random_display_map(cx: &mut gpui::TestAppContext, mut rng: StdRng) { + cx.background_executor.set_block_on_ticks(0..=50); + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let test_platform = &cx.test_platform; + let mut tab_size = rng.gen_range(1..=4); + let buffer_start_excerpt_header_height = rng.gen_range(1..=5); + let excerpt_header_height = rng.gen_range(1..=5); + let font_size = px(14.0); + let max_wrap_width = 300.0; + let mut wrap_width = if rng.gen_bool(0.1) { + None + } else { + Some(px(rng.gen_range(0.0..=max_wrap_width))) + }; + + log::info!("tab size: {}", tab_size); + log::info!("wrap width: {:?}", wrap_width); + + cx.update(|cx| { + init_test(cx, |s| s.defaults.tab_size = NonZeroU32::new(tab_size)); + }); + + let buffer = cx.update(|cx| { + if rng.gen() { + let len = rng.gen_range(0..10); + let text = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } else { + MultiBuffer::build_random(&mut rng, cx) + } + }); + + let map = cx.build_model(|cx| { + DisplayMap::new( + buffer.clone(), + font("Helvetica"), + font_size, + wrap_width, + buffer_start_excerpt_header_height, + excerpt_header_height, + cx, + ) + }); + let mut notifications = observe(&map, cx); + let mut fold_count = 0; + let mut blocks = Vec::new(); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text()); + log::info!("fold text: {:?}", snapshot.fold_snapshot.text()); + log::info!("tab text: {:?}", snapshot.tab_snapshot.text()); + log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text()); + log::info!("block text: {:?}", snapshot.block_snapshot.text()); + log::info!("display text: {:?}", snapshot.text()); + + for _i in 0..operations { + match rng.gen_range(0..100) { + 0..=19 => { + wrap_width = if rng.gen_bool(0.2) { + None + } else { + Some(px(rng.gen_range(0.0..=max_wrap_width))) + }; + log::info!("setting wrap width to {:?}", wrap_width); + map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); + } + 20..=29 => { + let mut tab_sizes = vec![1, 2, 3, 4]; + tab_sizes.remove((tab_size - 1) as usize); + tab_size = *tab_sizes.choose(&mut rng).unwrap(); + log::info!("setting tab size to {:?}", tab_size); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, |s| { + s.defaults.tab_size = NonZeroU32::new(tab_size); + }); + }); + }); + } + 30..=44 => { + map.update(cx, |map, cx| { + if rng.gen() || blocks.is_empty() { + let buffer = map.snapshot(cx).buffer_snapshot; + let block_properties = (0..rng.gen_range(1..=1)) + .map(|_| { + let position = + buffer.anchor_after(buffer.clip_offset( + rng.gen_range(0..=buffer.len()), + Bias::Left, + )); + + let disposition = if rng.gen() { + BlockDisposition::Above + } else { + BlockDisposition::Below + }; + let height = rng.gen_range(1..5); + log::info!( + "inserting block {:?} {:?} with height {}", + disposition, + position.to_point(&buffer), + height + ); + BlockProperties { + style: BlockStyle::Fixed, + position, + height, + disposition, + render: Arc::new(|_| div().into_any()), + } + }) + .collect::>(); + blocks.extend(map.insert_blocks(block_properties, cx)); + } else { + blocks.shuffle(&mut rng); + let remove_count = rng.gen_range(1..=4.min(blocks.len())); + let block_ids_to_remove = (0..remove_count) + .map(|_| blocks.remove(rng.gen_range(0..blocks.len()))) + .collect(); + log::info!("removing block ids {:?}", block_ids_to_remove); + map.remove_blocks(block_ids_to_remove, cx); + } + }); + } + 45..=79 => { + let mut ranges = Vec::new(); + for _ in 0..rng.gen_range(1..=3) { + buffer.read_with(cx, |buffer, cx| { + let buffer = buffer.read(cx); + let end = buffer.clip_offset(rng.gen_range(0..=buffer.len()), Right); + let start = buffer.clip_offset(rng.gen_range(0..=end), Left); + ranges.push(start..end); + }); + } + + if rng.gen() && fold_count > 0 { + log::info!("unfolding ranges: {:?}", ranges); + map.update(cx, |map, cx| { + map.unfold(ranges, true, cx); + }); + } else { + log::info!("folding ranges: {:?}", ranges); + map.update(cx, |map, cx| { + map.fold(ranges, cx); + }); + } + } + _ => { + buffer.update(cx, |buffer, cx| buffer.randomly_mutate(&mut rng, 5, cx)); + } + } + + if map.read_with(cx, |map, cx| map.is_rewrapping(cx)) { + notifications.next().await.unwrap(); + } + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + fold_count = snapshot.fold_count(); + log::info!("buffer text: {:?}", snapshot.buffer_snapshot.text()); + log::info!("fold text: {:?}", snapshot.fold_snapshot.text()); + log::info!("tab text: {:?}", snapshot.tab_snapshot.text()); + log::info!("wrap text: {:?}", snapshot.wrap_snapshot.text()); + log::info!("block text: {:?}", snapshot.block_snapshot.text()); + log::info!("display text: {:?}", snapshot.text()); + + // Line boundaries + let buffer = &snapshot.buffer_snapshot; + for _ in 0..5 { + let row = rng.gen_range(0..=buffer.max_point().row); + let column = rng.gen_range(0..=buffer.line_len(row)); + let point = buffer.clip_point(Point::new(row, column), Left); + + let (prev_buffer_bound, prev_display_bound) = snapshot.prev_line_boundary(point); + let (next_buffer_bound, next_display_bound) = snapshot.next_line_boundary(point); + + assert!(prev_buffer_bound <= point); + assert!(next_buffer_bound >= point); + assert_eq!(prev_buffer_bound.column, 0); + assert_eq!(prev_display_bound.column(), 0); + if next_buffer_bound < buffer.max_point() { + assert_eq!(buffer.chars_at(next_buffer_bound).next(), Some('\n')); + } + + assert_eq!( + prev_display_bound, + prev_buffer_bound.to_display_point(&snapshot), + "row boundary before {:?}. reported buffer row boundary: {:?}", + point, + prev_buffer_bound + ); + assert_eq!( + next_display_bound, + next_buffer_bound.to_display_point(&snapshot), + "display row boundary after {:?}. reported buffer row boundary: {:?}", + point, + next_buffer_bound + ); + assert_eq!( + prev_buffer_bound, + prev_display_bound.to_point(&snapshot), + "row boundary before {:?}. reported display row boundary: {:?}", + point, + prev_display_bound + ); + assert_eq!( + next_buffer_bound, + next_display_bound.to_point(&snapshot), + "row boundary after {:?}. reported display row boundary: {:?}", + point, + next_display_bound + ); + } + + // Movement + let min_point = snapshot.clip_point(DisplayPoint::new(0, 0), Left); + let max_point = snapshot.clip_point(snapshot.max_point(), Right); + for _ in 0..5 { + let row = rng.gen_range(0..=snapshot.max_point().row()); + let column = rng.gen_range(0..=snapshot.line_len(row)); + let point = snapshot.clip_point(DisplayPoint::new(row, column), Left); + + log::info!("Moving from point {:?}", point); + + let moved_right = movement::right(&snapshot, point); + log::info!("Right {:?}", moved_right); + if point < max_point { + assert!(moved_right > point); + if point.column() == snapshot.line_len(point.row()) + || snapshot.soft_wrap_indent(point.row()).is_some() + && point.column() == snapshot.line_len(point.row()) - 1 + { + assert!(moved_right.row() > point.row()); + } + } else { + assert_eq!(moved_right, point); + } + + let moved_left = movement::left(&snapshot, point); + log::info!("Left {:?}", moved_left); + if point > min_point { + assert!(moved_left < point); + if point.column() == 0 { + assert!(moved_left.row() < point.row()); + } + } else { + assert_eq!(moved_left, point); + } + } + } + } + + #[gpui::test(retries = 5)] + async fn test_soft_wraps(cx: &mut gpui::TestAppContext) { + cx.background_executor + .set_block_on_ticks(usize::MAX..=usize::MAX); + cx.update(|cx| { + init_test(cx, |_| {}); + }); + + let mut cx = EditorTestContext::new(cx).await; + let editor = cx.editor.clone(); + let window = cx.window.clone(); + + cx.update_window(window, |_, cx| { + let text_layout_details = + editor.update(cx, |editor, cx| editor.text_layout_details(cx)); + + let font_size = px(12.0); + let wrap_width = Some(px(64.)); + + let text = "one two three four five\nsix seven eight"; + let buffer = MultiBuffer::build_simple(text, cx); + let map = cx.build_model(|cx| { + DisplayMap::new( + buffer.clone(), + font("Helvetica"), + font_size, + wrap_width, + 1, + 1, + cx, + ) + }); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.text_chunks(0).collect::(), + "one two \nthree four \nfive\nsix seven \neight" + ); + assert_eq!( + snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Left), + DisplayPoint::new(0, 7) + ); + assert_eq!( + snapshot.clip_point(DisplayPoint::new(0, 8), Bias::Right), + DisplayPoint::new(1, 0) + ); + assert_eq!( + movement::right(&snapshot, DisplayPoint::new(0, 7)), + DisplayPoint::new(1, 0) + ); + assert_eq!( + movement::left(&snapshot, DisplayPoint::new(1, 0)), + DisplayPoint::new(0, 7) + ); + + let x = snapshot.x_for_display_point(DisplayPoint::new(1, 10), &text_layout_details); + assert_eq!( + movement::up( + &snapshot, + DisplayPoint::new(1, 10), + SelectionGoal::None, + false, + &text_layout_details, + ), + ( + DisplayPoint::new(0, 7), + SelectionGoal::HorizontalPosition(x.0) + ) + ); + assert_eq!( + movement::down( + &snapshot, + DisplayPoint::new(0, 7), + SelectionGoal::HorizontalPosition(x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(1, 10), + SelectionGoal::HorizontalPosition(x.0) + ) + ); + assert_eq!( + movement::down( + &snapshot, + DisplayPoint::new(1, 10), + SelectionGoal::HorizontalPosition(x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 4), + SelectionGoal::HorizontalPosition(x.0) + ) + ); + + let ix = snapshot.buffer_snapshot.text().find("seven").unwrap(); + buffer.update(cx, |buffer, cx| { + buffer.edit([(ix..ix, "and ")], None, cx); + }); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.text_chunks(1).collect::(), + "three four \nfive\nsix and \nseven eight" + ); + + // Re-wrap on font size changes + map.update(cx, |map, cx| { + map.set_font(font("Helvetica"), px(font_size.0 + 3.), cx) + }); + + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!( + snapshot.text_chunks(1).collect::(), + "three \nfour five\nsix and \nseven \neight" + ) + }); + } + + #[gpui::test] + fn test_text_chunks(cx: &mut gpui::AppContext) { + init_test(cx, |_| {}); + + let text = sample_text(6, 6, 'a'); + let buffer = MultiBuffer::build_simple(&text, cx); + + let font_size = px(14.0); + let map = cx.build_model(|cx| { + DisplayMap::new(buffer.clone(), font("Helvetica"), font_size, None, 1, 1, cx) + }); + + buffer.update(cx, |buffer, cx| { + buffer.edit( + vec![ + (Point::new(1, 0)..Point::new(1, 0), "\t"), + (Point::new(1, 1)..Point::new(1, 1), "\t"), + (Point::new(2, 1)..Point::new(2, 1), "\t"), + ], + None, + cx, + ) + }); + + assert_eq!( + map.update(cx, |map, cx| map.snapshot(cx)) + .text_chunks(1) + .collect::() + .lines() + .next(), + Some(" b bbbbb") + ); + assert_eq!( + map.update(cx, |map, cx| map.snapshot(cx)) + .text_chunks(2) + .collect::() + .lines() + .next(), + Some("c ccccc") + ); + } + + #[gpui::test] + async fn test_chunks(cx: &mut gpui::TestAppContext) { + use unindent::Unindent as _; + + let text = r#" + fn outer() {} + + mod module { + fn inner() {} + }"# + .unindent(); + + let theme = SyntaxTheme::new_test(vec![ + ("mod.body", Hsla::red().into()), + ("fn.name", Hsla::blue().into()), + ]); + let language = Arc::new( + Language::new( + LanguageConfig { + name: "Test".into(), + path_suffixes: vec![".test".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_highlights_query( + r#" + (mod_item name: (identifier) body: _ @mod.body) + (function_item name: (identifier) @fn.name) + "#, + ) + .unwrap(), + ); + language.set_theme(&theme); + + cx.update(|cx| init_test(cx, |s| s.defaults.tab_size = Some(2.try_into().unwrap()))); + + let buffer = cx.build_model(|cx| { + Buffer::new(0, cx.entity_id().as_u64(), text).with_language(language, cx) + }); + cx.condition(&buffer, |buf, _| !buf.is_parsing()).await; + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + + let font_size = px(14.0); + + let map = cx.build_model(|cx| { + DisplayMap::new(buffer, font("Helvetica"), font_size, None, 1, 1, cx) + }); + assert_eq!( + cx.update(|cx| syntax_chunks(0..5, &map, &theme, cx)), + vec![ + ("fn ".to_string(), None), + ("outer".to_string(), Some(Hsla::blue())), + ("() {}\n\nmod module ".to_string(), None), + ("{\n fn ".to_string(), Some(Hsla::red())), + ("inner".to_string(), Some(Hsla::blue())), + ("() {}\n}".to_string(), Some(Hsla::red())), + ] + ); + assert_eq!( + cx.update(|cx| syntax_chunks(3..5, &map, &theme, cx)), + vec![ + (" fn ".to_string(), Some(Hsla::red())), + ("inner".to_string(), Some(Hsla::blue())), + ("() {}\n}".to_string(), Some(Hsla::red())), + ] + ); + + map.update(cx, |map, cx| { + map.fold(vec![Point::new(0, 6)..Point::new(3, 2)], cx) + }); + assert_eq!( + cx.update(|cx| syntax_chunks(0..2, &map, &theme, cx)), + vec![ + ("fn ".to_string(), None), + ("out".to_string(), Some(Hsla::blue())), + ("⋯".to_string(), None), + (" fn ".to_string(), Some(Hsla::red())), + ("inner".to_string(), Some(Hsla::blue())), + ("() {}\n}".to_string(), Some(Hsla::red())), + ] + ); + } + + #[gpui::test] + async fn test_chunks_with_soft_wrapping(cx: &mut gpui::TestAppContext) { + use unindent::Unindent as _; + + cx.background_executor + .set_block_on_ticks(usize::MAX..=usize::MAX); + + let text = r#" + fn outer() {} + + mod module { + fn inner() {} + }"# + .unindent(); + + let theme = SyntaxTheme::new_test(vec![ + ("mod.body", Hsla::red().into()), + ("fn.name", Hsla::blue().into()), + ]); + let language = Arc::new( + Language::new( + LanguageConfig { + name: "Test".into(), + path_suffixes: vec![".test".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_highlights_query( + r#" + (mod_item name: (identifier) body: _ @mod.body) + (function_item name: (identifier) @fn.name) + "#, + ) + .unwrap(), + ); + language.set_theme(&theme); + + cx.update(|cx| init_test(cx, |_| {})); + + let buffer = cx.build_model(|cx| { + Buffer::new(0, cx.entity_id().as_u64(), text).with_language(language, cx) + }); + cx.condition(&buffer, |buf, _| !buf.is_parsing()).await; + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + + let font_size = px(16.0); + + let map = cx.build_model(|cx| { + DisplayMap::new(buffer, font("Courier"), font_size, Some(px(40.0)), 1, 1, cx) + }); + assert_eq!( + cx.update(|cx| syntax_chunks(0..5, &map, &theme, cx)), + [ + ("fn \n".to_string(), None), + ("oute\nr".to_string(), Some(Hsla::blue())), + ("() \n{}\n\n".to_string(), None), + ] + ); + assert_eq!( + cx.update(|cx| syntax_chunks(3..5, &map, &theme, cx)), + [("{}\n\n".to_string(), None)] + ); + + map.update(cx, |map, cx| { + map.fold(vec![Point::new(0, 6)..Point::new(3, 2)], cx) + }); + assert_eq!( + cx.update(|cx| syntax_chunks(1..4, &map, &theme, cx)), + [ + ("out".to_string(), Some(Hsla::blue())), + ("⋯\n".to_string(), None), + (" \nfn ".to_string(), Some(Hsla::red())), + ("i\n".to_string(), Some(Hsla::blue())) + ] + ); + } + + #[gpui::test] + async fn test_chunks_with_text_highlights(cx: &mut gpui::TestAppContext) { + cx.update(|cx| init_test(cx, |_| {})); + + let theme = SyntaxTheme::new_test(vec![ + ("operator", Hsla::red().into()), + ("string", Hsla::green().into()), + ]); + let language = Arc::new( + Language::new( + LanguageConfig { + name: "Test".into(), + path_suffixes: vec![".test".to_string()], + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_highlights_query( + r#" + ":" @operator + (string_literal) @string + "#, + ) + .unwrap(), + ); + language.set_theme(&theme); + + let (text, highlighted_ranges) = marked_text_ranges(r#"constˇ «a»: B = "c «d»""#, false); + + let buffer = cx.build_model(|cx| { + Buffer::new(0, cx.entity_id().as_u64(), text).with_language(language, cx) + }); + cx.condition(&buffer, |buf, _| !buf.is_parsing()).await; + + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + let buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + + let font_size = px(16.0); + let map = cx + .build_model(|cx| DisplayMap::new(buffer, font("Courier"), font_size, None, 1, 1, cx)); + + enum MyType {} + + let style = HighlightStyle { + color: Some(Hsla::blue()), + ..Default::default() + }; + + map.update(cx, |map, _cx| { + map.highlight_text( + TypeId::of::(), + highlighted_ranges + .into_iter() + .map(|range| { + buffer_snapshot.anchor_before(range.start) + ..buffer_snapshot.anchor_before(range.end) + }) + .collect(), + style, + ); + }); + + assert_eq!( + cx.update(|cx| chunks(0..10, &map, &theme, cx)), + [ + ("const ".to_string(), None, None), + ("a".to_string(), None, Some(Hsla::blue())), + (":".to_string(), Some(Hsla::red()), None), + (" B = ".to_string(), None, None), + ("\"c ".to_string(), Some(Hsla::green()), None), + ("d".to_string(), Some(Hsla::green()), Some(Hsla::blue())), + ("\"".to_string(), Some(Hsla::green()), None), + ] + ); + } + + #[gpui::test] + fn test_clip_point(cx: &mut gpui::AppContext) { + init_test(cx, |_| {}); + + fn assert(text: &str, shift_right: bool, bias: Bias, cx: &mut gpui::AppContext) { + let (unmarked_snapshot, mut markers) = marked_display_snapshot(text, cx); + + match bias { + Bias::Left => { + if shift_right { + *markers[1].column_mut() += 1; + } + + assert_eq!(unmarked_snapshot.clip_point(markers[1], bias), markers[0]) + } + Bias::Right => { + if shift_right { + *markers[0].column_mut() += 1; + } + + assert_eq!(unmarked_snapshot.clip_point(markers[0], bias), markers[1]) + } + }; + } + + use Bias::{Left, Right}; + assert("ˇˇα", false, Left, cx); + assert("ˇˇα", true, Left, cx); + assert("ˇˇα", false, Right, cx); + assert("ˇαˇ", true, Right, cx); + assert("ˇˇ✋", false, Left, cx); + assert("ˇˇ✋", true, Left, cx); + assert("ˇˇ✋", false, Right, cx); + assert("ˇ✋ˇ", true, Right, cx); + assert("ˇˇ🍐", false, Left, cx); + assert("ˇˇ🍐", true, Left, cx); + assert("ˇˇ🍐", false, Right, cx); + assert("ˇ🍐ˇ", true, Right, cx); + assert("ˇˇ\t", false, Left, cx); + assert("ˇˇ\t", true, Left, cx); + assert("ˇˇ\t", false, Right, cx); + assert("ˇ\tˇ", true, Right, cx); + assert(" ˇˇ\t", false, Left, cx); + assert(" ˇˇ\t", true, Left, cx); + assert(" ˇˇ\t", false, Right, cx); + assert(" ˇ\tˇ", true, Right, cx); + assert(" ˇˇ\t", false, Left, cx); + assert(" ˇˇ\t", false, Right, cx); + } + + #[gpui::test] + fn test_clip_at_line_ends(cx: &mut gpui::AppContext) { + init_test(cx, |_| {}); + + fn assert(text: &str, cx: &mut gpui::AppContext) { + let (mut unmarked_snapshot, markers) = marked_display_snapshot(text, cx); + unmarked_snapshot.clip_at_line_ends = true; + assert_eq!( + unmarked_snapshot.clip_point(markers[1], Bias::Left), + markers[0] + ); + } + + assert("ˇˇ", cx); + assert("ˇaˇ", cx); + assert("aˇbˇ", cx); + assert("aˇαˇ", cx); + } + + #[gpui::test] + fn test_tabs_with_multibyte_chars(cx: &mut gpui::AppContext) { + init_test(cx, |_| {}); + + let text = "✅\t\tα\nβ\t\n🏀β\t\tγ"; + let buffer = MultiBuffer::build_simple(text, cx); + let font_size = px(14.0); + + let map = cx.build_model(|cx| { + DisplayMap::new(buffer.clone(), font("Helvetica"), font_size, None, 1, 1, cx) + }); + let map = map.update(cx, |map, cx| map.snapshot(cx)); + assert_eq!(map.text(), "✅ α\nβ \n🏀β γ"); + assert_eq!( + map.text_chunks(0).collect::(), + "✅ α\nβ \n🏀β γ" + ); + assert_eq!(map.text_chunks(1).collect::(), "β \n🏀β γ"); + assert_eq!(map.text_chunks(2).collect::(), "🏀β γ"); + + let point = Point::new(0, "✅\t\t".len() as u32); + let display_point = DisplayPoint::new(0, "✅ ".len() as u32); + assert_eq!(point.to_display_point(&map), display_point); + assert_eq!(display_point.to_point(&map), point); + + let point = Point::new(1, "β\t".len() as u32); + let display_point = DisplayPoint::new(1, "β ".len() as u32); + assert_eq!(point.to_display_point(&map), display_point); + assert_eq!(display_point.to_point(&map), point,); + + let point = Point::new(2, "🏀β\t\t".len() as u32); + let display_point = DisplayPoint::new(2, "🏀β ".len() as u32); + assert_eq!(point.to_display_point(&map), display_point); + assert_eq!(display_point.to_point(&map), point,); + + // Display points inside of expanded tabs + assert_eq!( + DisplayPoint::new(0, "✅ ".len() as u32).to_point(&map), + Point::new(0, "✅\t".len() as u32), + ); + assert_eq!( + DisplayPoint::new(0, "✅ ".len() as u32).to_point(&map), + Point::new(0, "✅".len() as u32), + ); + + // Clipping display points inside of multi-byte characters + assert_eq!( + map.clip_point(DisplayPoint::new(0, "✅".len() as u32 - 1), Left), + DisplayPoint::new(0, 0) + ); + assert_eq!( + map.clip_point(DisplayPoint::new(0, "✅".len() as u32 - 1), Bias::Right), + DisplayPoint::new(0, "✅".len() as u32) + ); + } + + #[gpui::test] + fn test_max_point(cx: &mut gpui::AppContext) { + init_test(cx, |_| {}); + + let buffer = MultiBuffer::build_simple("aaa\n\t\tbbb", cx); + let font_size = px(14.0); + let map = cx.build_model(|cx| { + DisplayMap::new(buffer.clone(), font("Helvetica"), font_size, None, 1, 1, cx) + }); + assert_eq!( + map.update(cx, |map, cx| map.snapshot(cx)).max_point(), + DisplayPoint::new(1, 11) + ) + } + + fn syntax_chunks<'a>( + rows: Range, + map: &Model, + theme: &'a SyntaxTheme, + cx: &mut AppContext, + ) -> Vec<(String, Option)> { + chunks(rows, map, theme, cx) + .into_iter() + .map(|(text, color, _)| (text, color)) + .collect() + } + + fn chunks<'a>( + rows: Range, + map: &Model, + theme: &'a SyntaxTheme, + cx: &mut AppContext, + ) -> Vec<(String, Option, Option)> { + let snapshot = map.update(cx, |map, cx| map.snapshot(cx)); + let mut chunks: Vec<(String, Option, Option)> = Vec::new(); + for chunk in snapshot.chunks(rows, true, None, None) { + let syntax_color = chunk + .syntax_highlight_id + .and_then(|id| id.style(theme)?.color); + let highlight_color = chunk.highlight_style.and_then(|style| style.color); + if let Some((last_chunk, last_syntax_color, last_highlight_color)) = chunks.last_mut() { + if syntax_color == *last_syntax_color && highlight_color == *last_highlight_color { + last_chunk.push_str(chunk.text); + continue; + } + } + chunks.push((chunk.text.to_string(), syntax_color, highlight_color)); + } + chunks + } + + fn init_test(cx: &mut AppContext, f: impl Fn(&mut AllLanguageSettingsContent)) { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + language::init(cx); + crate::init(cx); + Project::init_settings(cx); + theme::init(LoadThemes::JustBase, cx); + cx.update_global::(|store, cx| { + store.update_user_settings::(cx, f); + }); + } +} diff --git a/crates/editor2/src/display_map/block_map.rs b/crates/editor2/src/display_map/block_map.rs index 00778c2eddc8eec3cccf3a3a2a9fe89355d26ded..64e46549fd6c7b9ae2576ca68d7c0f2af52b750e 100644 --- a/crates/editor2/src/display_map/block_map.rs +++ b/crates/editor2/src/display_map/block_map.rs @@ -988,680 +988,664 @@ fn offset_for_row(s: &str, target: u32) -> (u32, usize) { (row, offset) } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::display_map::inlay_map::InlayMap; -// use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap}; -// use gpui::Element; -// use multi_buffer::MultiBuffer; -// use rand::prelude::*; -// use settings::SettingsStore; -// use std::env; -// use util::RandomCharIter; - -// #[gpui::test] -// fn test_offset_for_row() { -// assert_eq!(offset_for_row("", 0), (0, 0)); -// assert_eq!(offset_for_row("", 1), (0, 0)); -// assert_eq!(offset_for_row("abcd", 0), (0, 0)); -// assert_eq!(offset_for_row("abcd", 1), (0, 4)); -// assert_eq!(offset_for_row("\n", 0), (0, 0)); -// assert_eq!(offset_for_row("\n", 1), (1, 1)); -// assert_eq!(offset_for_row("abc\ndef\nghi", 0), (0, 0)); -// assert_eq!(offset_for_row("abc\ndef\nghi", 1), (1, 4)); -// assert_eq!(offset_for_row("abc\ndef\nghi", 2), (2, 8)); -// assert_eq!(offset_for_row("abc\ndef\nghi", 3), (2, 11)); -// } - -// #[gpui::test] -// fn test_basic_blocks(cx: &mut gpui::AppContext) { -// init_test(cx); - -// let family_id = cx -// .font_cache() -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = cx -// .font_cache() -// .select_font(family_id, &Default::default()) -// .unwrap(); - -// let text = "aaa\nbbb\nccc\nddd"; - -// let buffer = MultiBuffer::build_simple(text, cx); -// let buffer_snapshot = buffer.read(cx).snapshot(cx); -// let subscription = buffer.update(cx, |buffer, _| buffer.subscribe()); -// let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); -// let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); -// let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 1.try_into().unwrap()); -// let (wrap_map, wraps_snapshot) = WrapMap::new(tab_snapshot, font_id, 14.0, None, cx); -// let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); - -// let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); -// let block_ids = writer.insert(vec![ -// BlockProperties { -// style: BlockStyle::Fixed, -// position: buffer_snapshot.anchor_after(Point::new(1, 0)), -// height: 1, -// disposition: BlockDisposition::Above, -// render: Arc::new(|_| Empty::new().into_any_named("block 1")), -// }, -// BlockProperties { -// style: BlockStyle::Fixed, -// position: buffer_snapshot.anchor_after(Point::new(1, 2)), -// height: 2, -// disposition: BlockDisposition::Above, -// render: Arc::new(|_| Empty::new().into_any_named("block 2")), -// }, -// BlockProperties { -// style: BlockStyle::Fixed, -// position: buffer_snapshot.anchor_after(Point::new(3, 3)), -// height: 3, -// disposition: BlockDisposition::Below, -// render: Arc::new(|_| Empty::new().into_any_named("block 3")), -// }, -// ]); - -// let snapshot = block_map.read(wraps_snapshot, Default::default()); -// assert_eq!(snapshot.text(), "aaa\n\n\n\nbbb\nccc\nddd\n\n\n"); - -// let blocks = snapshot -// .blocks_in_range(0..8) -// .map(|(start_row, block)| { -// let block = block.as_custom().unwrap(); -// (start_row..start_row + block.height as u32, block.id) -// }) -// .collect::>(); - -// // When multiple blocks are on the same line, the newer blocks appear first. -// assert_eq!( -// blocks, -// &[ -// (1..2, block_ids[0]), -// (2..4, block_ids[1]), -// (7..10, block_ids[2]), -// ] -// ); - -// assert_eq!( -// snapshot.to_block_point(WrapPoint::new(0, 3)), -// BlockPoint::new(0, 3) -// ); -// assert_eq!( -// snapshot.to_block_point(WrapPoint::new(1, 0)), -// BlockPoint::new(4, 0) -// ); -// assert_eq!( -// snapshot.to_block_point(WrapPoint::new(3, 3)), -// BlockPoint::new(6, 3) -// ); - -// assert_eq!( -// snapshot.to_wrap_point(BlockPoint::new(0, 3)), -// WrapPoint::new(0, 3) -// ); -// assert_eq!( -// snapshot.to_wrap_point(BlockPoint::new(1, 0)), -// WrapPoint::new(1, 0) -// ); -// assert_eq!( -// snapshot.to_wrap_point(BlockPoint::new(3, 0)), -// WrapPoint::new(1, 0) -// ); -// assert_eq!( -// snapshot.to_wrap_point(BlockPoint::new(7, 0)), -// WrapPoint::new(3, 3) -// ); - -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(1, 0), Bias::Left), -// BlockPoint::new(0, 3) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(1, 0), Bias::Right), -// BlockPoint::new(4, 0) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(1, 1), Bias::Left), -// BlockPoint::new(0, 3) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(1, 1), Bias::Right), -// BlockPoint::new(4, 0) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(4, 0), Bias::Left), -// BlockPoint::new(4, 0) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(4, 0), Bias::Right), -// BlockPoint::new(4, 0) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(6, 3), Bias::Left), -// BlockPoint::new(6, 3) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(6, 3), Bias::Right), -// BlockPoint::new(6, 3) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(7, 0), Bias::Left), -// BlockPoint::new(6, 3) -// ); -// assert_eq!( -// snapshot.clip_point(BlockPoint::new(7, 0), Bias::Right), -// BlockPoint::new(6, 3) -// ); - -// assert_eq!( -// snapshot.buffer_rows(0).collect::>(), -// &[ -// Some(0), -// None, -// None, -// None, -// Some(1), -// Some(2), -// Some(3), -// None, -// None, -// None -// ] -// ); - -// // Insert a line break, separating two block decorations into separate lines. -// let buffer_snapshot = buffer.update(cx, |buffer, cx| { -// buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "!!!\n")], None, cx); -// buffer.snapshot(cx) -// }); - -// let (inlay_snapshot, inlay_edits) = -// inlay_map.sync(buffer_snapshot, subscription.consume().into_inner()); -// let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); -// let (tab_snapshot, tab_edits) = -// tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap()); -// let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { -// wrap_map.sync(tab_snapshot, tab_edits, cx) -// }); -// let snapshot = block_map.read(wraps_snapshot, wrap_edits); -// assert_eq!(snapshot.text(), "aaa\n\nb!!!\n\n\nbb\nccc\nddd\n\n\n"); -// } - -// #[gpui::test] -// fn test_blocks_on_wrapped_lines(cx: &mut gpui::AppContext) { -// init_test(cx); - -// let family_id = cx -// .font_cache() -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = cx -// .font_cache() -// .select_font(family_id, &Default::default()) -// .unwrap(); - -// let text = "one two three\nfour five six\nseven eight"; - -// let buffer = MultiBuffer::build_simple(text, cx); -// let buffer_snapshot = buffer.read(cx).snapshot(cx); -// let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); -// let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); -// let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); -// let (_, wraps_snapshot) = WrapMap::new(tab_snapshot, font_id, 14.0, Some(60.), cx); -// let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); - -// let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); -// writer.insert(vec![ -// BlockProperties { -// style: BlockStyle::Fixed, -// position: buffer_snapshot.anchor_after(Point::new(1, 12)), -// disposition: BlockDisposition::Above, -// render: Arc::new(|_| Empty::new().into_any_named("block 1")), -// height: 1, -// }, -// BlockProperties { -// style: BlockStyle::Fixed, -// position: buffer_snapshot.anchor_after(Point::new(1, 1)), -// disposition: BlockDisposition::Below, -// render: Arc::new(|_| Empty::new().into_any_named("block 2")), -// height: 1, -// }, -// ]); - -// // Blocks with an 'above' disposition go above their corresponding buffer line. -// // Blocks with a 'below' disposition go below their corresponding buffer line. -// let snapshot = block_map.read(wraps_snapshot, Default::default()); -// assert_eq!( -// snapshot.text(), -// "one two \nthree\n\nfour five \nsix\n\nseven \neight" -// ); -// } - -// #[gpui::test(iterations = 100)] -// fn test_random_blocks(cx: &mut gpui::AppContext, mut rng: StdRng) { -// init_test(cx); - -// let operations = env::var("OPERATIONS") -// .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) -// .unwrap_or(10); - -// let wrap_width = if rng.gen_bool(0.2) { -// None -// } else { -// Some(rng.gen_range(0.0..=100.0)) -// }; -// let tab_size = 1.try_into().unwrap(); -// let family_id = cx -// .font_cache() -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = cx -// .font_cache() -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; -// let buffer_start_header_height = rng.gen_range(1..=5); -// let excerpt_header_height = rng.gen_range(1..=5); - -// log::info!("Wrap width: {:?}", wrap_width); -// log::info!("Excerpt Header Height: {:?}", excerpt_header_height); - -// let buffer = if rng.gen() { -// let len = rng.gen_range(0..10); -// let text = RandomCharIter::new(&mut rng).take(len).collect::(); -// log::info!("initial buffer text: {:?}", text); -// MultiBuffer::build_simple(&text, cx) -// } else { -// MultiBuffer::build_random(&mut rng, cx) -// }; - -// let mut buffer_snapshot = buffer.read(cx).snapshot(cx); -// let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); -// let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); -// let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); -// let (wrap_map, wraps_snapshot) = -// WrapMap::new(tab_snapshot, font_id, font_size, wrap_width, cx); -// let mut block_map = BlockMap::new( -// wraps_snapshot, -// buffer_start_header_height, -// excerpt_header_height, -// ); -// let mut custom_blocks = Vec::new(); - -// for _ in 0..operations { -// let mut buffer_edits = Vec::new(); -// match rng.gen_range(0..=100) { -// 0..=19 => { -// let wrap_width = if rng.gen_bool(0.2) { -// None -// } else { -// Some(rng.gen_range(0.0..=100.0)) -// }; -// log::info!("Setting wrap width to {:?}", wrap_width); -// wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); -// } -// 20..=39 => { -// let block_count = rng.gen_range(1..=5); -// let block_properties = (0..block_count) -// .map(|_| { -// let buffer = buffer.read(cx).read(cx); -// let position = buffer.anchor_after( -// buffer.clip_offset(rng.gen_range(0..=buffer.len()), Bias::Left), -// ); - -// let disposition = if rng.gen() { -// BlockDisposition::Above -// } else { -// BlockDisposition::Below -// }; -// let height = rng.gen_range(1..5); -// log::info!( -// "inserting block {:?} {:?} with height {}", -// disposition, -// position.to_point(&buffer), -// height -// ); -// BlockProperties { -// style: BlockStyle::Fixed, -// position, -// height, -// disposition, -// render: Arc::new(|_| Empty::new().into_any()), -// } -// }) -// .collect::>(); - -// let (inlay_snapshot, inlay_edits) = -// inlay_map.sync(buffer_snapshot.clone(), vec![]); -// let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); -// let (tab_snapshot, tab_edits) = -// tab_map.sync(fold_snapshot, fold_edits, tab_size); -// let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { -// wrap_map.sync(tab_snapshot, tab_edits, cx) -// }); -// let mut block_map = block_map.write(wraps_snapshot, wrap_edits); -// let block_ids = block_map.insert(block_properties.clone()); -// for (block_id, props) in block_ids.into_iter().zip(block_properties) { -// custom_blocks.push((block_id, props)); -// } -// } -// 40..=59 if !custom_blocks.is_empty() => { -// let block_count = rng.gen_range(1..=4.min(custom_blocks.len())); -// let block_ids_to_remove = (0..block_count) -// .map(|_| { -// custom_blocks -// .remove(rng.gen_range(0..custom_blocks.len())) -// .0 -// }) -// .collect(); - -// let (inlay_snapshot, inlay_edits) = -// inlay_map.sync(buffer_snapshot.clone(), vec![]); -// let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); -// let (tab_snapshot, tab_edits) = -// tab_map.sync(fold_snapshot, fold_edits, tab_size); -// let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { -// wrap_map.sync(tab_snapshot, tab_edits, cx) -// }); -// let mut block_map = block_map.write(wraps_snapshot, wrap_edits); -// block_map.remove(block_ids_to_remove); -// } -// _ => { -// buffer.update(cx, |buffer, cx| { -// let mutation_count = rng.gen_range(1..=5); -// let subscription = buffer.subscribe(); -// buffer.randomly_mutate(&mut rng, mutation_count, cx); -// buffer_snapshot = buffer.snapshot(cx); -// buffer_edits.extend(subscription.consume()); -// log::info!("buffer text: {:?}", buffer_snapshot.text()); -// }); -// } -// } - -// let (inlay_snapshot, inlay_edits) = -// inlay_map.sync(buffer_snapshot.clone(), buffer_edits); -// let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); -// let (tab_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size); -// let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { -// wrap_map.sync(tab_snapshot, tab_edits, cx) -// }); -// let blocks_snapshot = block_map.read(wraps_snapshot.clone(), wrap_edits); -// assert_eq!( -// blocks_snapshot.transforms.summary().input_rows, -// wraps_snapshot.max_point().row() + 1 -// ); -// log::info!("blocks text: {:?}", blocks_snapshot.text()); - -// let mut expected_blocks = Vec::new(); -// expected_blocks.extend(custom_blocks.iter().map(|(id, block)| { -// let mut position = block.position.to_point(&buffer_snapshot); -// match block.disposition { -// BlockDisposition::Above => { -// position.column = 0; -// } -// BlockDisposition::Below => { -// position.column = buffer_snapshot.line_len(position.row); -// } -// }; -// let row = wraps_snapshot.make_wrap_point(position, Bias::Left).row(); -// ( -// row, -// ExpectedBlock::Custom { -// disposition: block.disposition, -// id: *id, -// height: block.height, -// }, -// ) -// })); -// expected_blocks.extend(buffer_snapshot.excerpt_boundaries_in_range(0..).map( -// |boundary| { -// let position = -// wraps_snapshot.make_wrap_point(Point::new(boundary.row, 0), Bias::Left); -// ( -// position.row(), -// ExpectedBlock::ExcerptHeader { -// height: if boundary.starts_new_buffer { -// buffer_start_header_height -// } else { -// excerpt_header_height -// }, -// starts_new_buffer: boundary.starts_new_buffer, -// }, -// ) -// }, -// )); -// expected_blocks.sort_unstable(); -// let mut sorted_blocks_iter = expected_blocks.into_iter().peekable(); - -// let input_buffer_rows = buffer_snapshot.buffer_rows(0).collect::>(); -// let mut expected_buffer_rows = Vec::new(); -// let mut expected_text = String::new(); -// let mut expected_block_positions = Vec::new(); -// let input_text = wraps_snapshot.text(); -// for (row, input_line) in input_text.split('\n').enumerate() { -// let row = row as u32; -// if row > 0 { -// expected_text.push('\n'); -// } - -// let buffer_row = input_buffer_rows[wraps_snapshot -// .to_point(WrapPoint::new(row, 0), Bias::Left) -// .row as usize]; - -// while let Some((block_row, block)) = sorted_blocks_iter.peek() { -// if *block_row == row && block.disposition() == BlockDisposition::Above { -// let (_, block) = sorted_blocks_iter.next().unwrap(); -// let height = block.height() as usize; -// expected_block_positions -// .push((expected_text.matches('\n').count() as u32, block)); -// let text = "\n".repeat(height); -// expected_text.push_str(&text); -// for _ in 0..height { -// expected_buffer_rows.push(None); -// } -// } else { -// break; -// } -// } - -// let soft_wrapped = wraps_snapshot.to_tab_point(WrapPoint::new(row, 0)).column() > 0; -// expected_buffer_rows.push(if soft_wrapped { None } else { buffer_row }); -// expected_text.push_str(input_line); - -// while let Some((block_row, block)) = sorted_blocks_iter.peek() { -// if *block_row == row && block.disposition() == BlockDisposition::Below { -// let (_, block) = sorted_blocks_iter.next().unwrap(); -// let height = block.height() as usize; -// expected_block_positions -// .push((expected_text.matches('\n').count() as u32 + 1, block)); -// let text = "\n".repeat(height); -// expected_text.push_str(&text); -// for _ in 0..height { -// expected_buffer_rows.push(None); -// } -// } else { -// break; -// } -// } -// } - -// let expected_lines = expected_text.split('\n').collect::>(); -// let expected_row_count = expected_lines.len(); -// for start_row in 0..expected_row_count { -// let expected_text = expected_lines[start_row..].join("\n"); -// let actual_text = blocks_snapshot -// .chunks( -// start_row as u32..blocks_snapshot.max_point().row + 1, -// false, -// Highlights::default(), -// ) -// .map(|chunk| chunk.text) -// .collect::(); -// assert_eq!( -// actual_text, expected_text, -// "incorrect text starting from row {}", -// start_row -// ); -// assert_eq!( -// blocks_snapshot -// .buffer_rows(start_row as u32) -// .collect::>(), -// &expected_buffer_rows[start_row..] -// ); -// } - -// assert_eq!( -// blocks_snapshot -// .blocks_in_range(0..(expected_row_count as u32)) -// .map(|(row, block)| (row, block.clone().into())) -// .collect::>(), -// expected_block_positions -// ); - -// let mut expected_longest_rows = Vec::new(); -// let mut longest_line_len = -1_isize; -// for (row, line) in expected_lines.iter().enumerate() { -// let row = row as u32; - -// assert_eq!( -// blocks_snapshot.line_len(row), -// line.len() as u32, -// "invalid line len for row {}", -// row -// ); - -// let line_char_count = line.chars().count() as isize; -// match line_char_count.cmp(&longest_line_len) { -// Ordering::Less => {} -// Ordering::Equal => expected_longest_rows.push(row), -// Ordering::Greater => { -// longest_line_len = line_char_count; -// expected_longest_rows.clear(); -// expected_longest_rows.push(row); -// } -// } -// } - -// let longest_row = blocks_snapshot.longest_row(); -// assert!( -// expected_longest_rows.contains(&longest_row), -// "incorrect longest row {}. expected {:?} with length {}", -// longest_row, -// expected_longest_rows, -// longest_line_len, -// ); - -// for row in 0..=blocks_snapshot.wrap_snapshot.max_point().row() { -// let wrap_point = WrapPoint::new(row, 0); -// let block_point = blocks_snapshot.to_block_point(wrap_point); -// assert_eq!(blocks_snapshot.to_wrap_point(block_point), wrap_point); -// } - -// let mut block_point = BlockPoint::new(0, 0); -// for c in expected_text.chars() { -// let left_point = blocks_snapshot.clip_point(block_point, Bias::Left); -// let left_buffer_point = blocks_snapshot.to_point(left_point, Bias::Left); -// assert_eq!( -// blocks_snapshot.to_block_point(blocks_snapshot.to_wrap_point(left_point)), -// left_point -// ); -// assert_eq!( -// left_buffer_point, -// buffer_snapshot.clip_point(left_buffer_point, Bias::Right), -// "{:?} is not valid in buffer coordinates", -// left_point -// ); - -// let right_point = blocks_snapshot.clip_point(block_point, Bias::Right); -// let right_buffer_point = blocks_snapshot.to_point(right_point, Bias::Right); -// assert_eq!( -// blocks_snapshot.to_block_point(blocks_snapshot.to_wrap_point(right_point)), -// right_point -// ); -// assert_eq!( -// right_buffer_point, -// buffer_snapshot.clip_point(right_buffer_point, Bias::Left), -// "{:?} is not valid in buffer coordinates", -// right_point -// ); - -// if c == '\n' { -// block_point.0 += Point::new(1, 0); -// } else { -// block_point.column += c.len_utf8() as u32; -// } -// } -// } - -// #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] -// enum ExpectedBlock { -// ExcerptHeader { -// height: u8, -// starts_new_buffer: bool, -// }, -// Custom { -// disposition: BlockDisposition, -// id: BlockId, -// height: u8, -// }, -// } - -// impl ExpectedBlock { -// fn height(&self) -> u8 { -// match self { -// ExpectedBlock::ExcerptHeader { height, .. } => *height, -// ExpectedBlock::Custom { height, .. } => *height, -// } -// } - -// fn disposition(&self) -> BlockDisposition { -// match self { -// ExpectedBlock::ExcerptHeader { .. } => BlockDisposition::Above, -// ExpectedBlock::Custom { disposition, .. } => *disposition, -// } -// } -// } - -// impl From for ExpectedBlock { -// fn from(block: TransformBlock) -> Self { -// match block { -// TransformBlock::Custom(block) => ExpectedBlock::Custom { -// id: block.id, -// disposition: block.disposition, -// height: block.height, -// }, -// TransformBlock::ExcerptHeader { -// height, -// starts_new_buffer, -// .. -// } => ExpectedBlock::ExcerptHeader { -// height, -// starts_new_buffer, -// }, -// } -// } -// } -// } - -// fn init_test(cx: &mut gpui::AppContext) { -// cx.set_global(SettingsStore::test(cx)); -// theme::init(cx); -// } - -// impl TransformBlock { -// fn as_custom(&self) -> Option<&Block> { -// match self { -// TransformBlock::Custom(block) => Some(block), -// TransformBlock::ExcerptHeader { .. } => None, -// } -// } -// } - -// impl BlockSnapshot { -// fn to_point(&self, point: BlockPoint, bias: Bias) -> Point { -// self.wrap_snapshot.to_point(self.to_wrap_point(point), bias) -// } -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use crate::display_map::inlay_map::InlayMap; + use crate::display_map::{fold_map::FoldMap, tab_map::TabMap, wrap_map::WrapMap}; + use gpui::{div, font, px, Element, Platform as _}; + use multi_buffer::MultiBuffer; + use rand::prelude::*; + use settings::SettingsStore; + use std::env; + use util::RandomCharIter; + + #[gpui::test] + fn test_offset_for_row() { + assert_eq!(offset_for_row("", 0), (0, 0)); + assert_eq!(offset_for_row("", 1), (0, 0)); + assert_eq!(offset_for_row("abcd", 0), (0, 0)); + assert_eq!(offset_for_row("abcd", 1), (0, 4)); + assert_eq!(offset_for_row("\n", 0), (0, 0)); + assert_eq!(offset_for_row("\n", 1), (1, 1)); + assert_eq!(offset_for_row("abc\ndef\nghi", 0), (0, 0)); + assert_eq!(offset_for_row("abc\ndef\nghi", 1), (1, 4)); + assert_eq!(offset_for_row("abc\ndef\nghi", 2), (2, 8)); + assert_eq!(offset_for_row("abc\ndef\nghi", 3), (2, 11)); + } + + #[gpui::test] + fn test_basic_blocks(cx: &mut gpui::TestAppContext) { + cx.update(|cx| init_test(cx)); + + let text = "aaa\nbbb\nccc\nddd"; + + let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx)); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let subscription = buffer.update(cx, |buffer, _| buffer.subscribe()); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 1.try_into().unwrap()); + let (wrap_map, wraps_snapshot) = + cx.update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), None, cx)); + let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); + + let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); + let block_ids = writer.insert(vec![ + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(1, 0)), + height: 1, + disposition: BlockDisposition::Above, + render: Arc::new(|_| div().into_any()), + }, + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(1, 2)), + height: 2, + disposition: BlockDisposition::Above, + render: Arc::new(|_| div().into_any()), + }, + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(3, 3)), + height: 3, + disposition: BlockDisposition::Below, + render: Arc::new(|_| div().into_any()), + }, + ]); + + let snapshot = block_map.read(wraps_snapshot, Default::default()); + assert_eq!(snapshot.text(), "aaa\n\n\n\nbbb\nccc\nddd\n\n\n"); + + let blocks = snapshot + .blocks_in_range(0..8) + .map(|(start_row, block)| { + let block = block.as_custom().unwrap(); + (start_row..start_row + block.height as u32, block.id) + }) + .collect::>(); + + // When multiple blocks are on the same line, the newer blocks appear first. + assert_eq!( + blocks, + &[ + (1..2, block_ids[0]), + (2..4, block_ids[1]), + (7..10, block_ids[2]), + ] + ); + + assert_eq!( + snapshot.to_block_point(WrapPoint::new(0, 3)), + BlockPoint::new(0, 3) + ); + assert_eq!( + snapshot.to_block_point(WrapPoint::new(1, 0)), + BlockPoint::new(4, 0) + ); + assert_eq!( + snapshot.to_block_point(WrapPoint::new(3, 3)), + BlockPoint::new(6, 3) + ); + + assert_eq!( + snapshot.to_wrap_point(BlockPoint::new(0, 3)), + WrapPoint::new(0, 3) + ); + assert_eq!( + snapshot.to_wrap_point(BlockPoint::new(1, 0)), + WrapPoint::new(1, 0) + ); + assert_eq!( + snapshot.to_wrap_point(BlockPoint::new(3, 0)), + WrapPoint::new(1, 0) + ); + assert_eq!( + snapshot.to_wrap_point(BlockPoint::new(7, 0)), + WrapPoint::new(3, 3) + ); + + assert_eq!( + snapshot.clip_point(BlockPoint::new(1, 0), Bias::Left), + BlockPoint::new(0, 3) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(1, 0), Bias::Right), + BlockPoint::new(4, 0) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(1, 1), Bias::Left), + BlockPoint::new(0, 3) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(1, 1), Bias::Right), + BlockPoint::new(4, 0) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(4, 0), Bias::Left), + BlockPoint::new(4, 0) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(4, 0), Bias::Right), + BlockPoint::new(4, 0) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(6, 3), Bias::Left), + BlockPoint::new(6, 3) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(6, 3), Bias::Right), + BlockPoint::new(6, 3) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(7, 0), Bias::Left), + BlockPoint::new(6, 3) + ); + assert_eq!( + snapshot.clip_point(BlockPoint::new(7, 0), Bias::Right), + BlockPoint::new(6, 3) + ); + + assert_eq!( + snapshot.buffer_rows(0).collect::>(), + &[ + Some(0), + None, + None, + None, + Some(1), + Some(2), + Some(3), + None, + None, + None + ] + ); + + // Insert a line break, separating two block decorations into separate lines. + let buffer_snapshot = buffer.update(cx, |buffer, cx| { + buffer.edit([(Point::new(1, 1)..Point::new(1, 1), "!!!\n")], None, cx); + buffer.snapshot(cx) + }); + + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot, subscription.consume().into_inner()); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let (tab_snapshot, tab_edits) = + tab_map.sync(fold_snapshot, fold_edits, 4.try_into().unwrap()); + let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { + wrap_map.sync(tab_snapshot, tab_edits, cx) + }); + let snapshot = block_map.read(wraps_snapshot, wrap_edits); + assert_eq!(snapshot.text(), "aaa\n\nb!!!\n\n\nbb\nccc\nddd\n\n\n"); + } + + #[gpui::test] + fn test_blocks_on_wrapped_lines(cx: &mut gpui::TestAppContext) { + cx.update(|cx| init_test(cx)); + + let font_id = cx + .test_platform + .text_system() + .font_id(&font("Helvetica")) + .unwrap(); + + let text = "one two three\nfour five six\nseven eight"; + + let buffer = cx.update(|cx| MultiBuffer::build_simple(text, cx)); + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let (_, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (_, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (_, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (_, wraps_snapshot) = cx.update(|cx| { + WrapMap::new(tab_snapshot, font("Helvetica"), px(14.0), Some(px(60.)), cx) + }); + let mut block_map = BlockMap::new(wraps_snapshot.clone(), 1, 1); + + let mut writer = block_map.write(wraps_snapshot.clone(), Default::default()); + writer.insert(vec![ + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(1, 12)), + disposition: BlockDisposition::Above, + render: Arc::new(|_| div().into_any()), + height: 1, + }, + BlockProperties { + style: BlockStyle::Fixed, + position: buffer_snapshot.anchor_after(Point::new(1, 1)), + disposition: BlockDisposition::Below, + render: Arc::new(|_| div().into_any()), + height: 1, + }, + ]); + + // Blocks with an 'above' disposition go above their corresponding buffer line. + // Blocks with a 'below' disposition go below their corresponding buffer line. + let snapshot = block_map.read(wraps_snapshot, Default::default()); + assert_eq!( + snapshot.text(), + "one two \nthree\n\nfour five \nsix\n\nseven \neight" + ); + } + + #[gpui::test(iterations = 100)] + fn test_random_blocks(cx: &mut gpui::TestAppContext, mut rng: StdRng) { + cx.update(|cx| init_test(cx)); + + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let wrap_width = if rng.gen_bool(0.2) { + None + } else { + Some(px(rng.gen_range(0.0..=100.0))) + }; + let tab_size = 1.try_into().unwrap(); + let font_size = px(14.0); + let buffer_start_header_height = rng.gen_range(1..=5); + let excerpt_header_height = rng.gen_range(1..=5); + + log::info!("Wrap width: {:?}", wrap_width); + log::info!("Excerpt Header Height: {:?}", excerpt_header_height); + + let buffer = if rng.gen() { + let len = rng.gen_range(0..10); + let text = RandomCharIter::new(&mut rng).take(len).collect::(); + log::info!("initial buffer text: {:?}", text); + cx.update(|cx| MultiBuffer::build_simple(&text, cx)) + } else { + cx.update(|cx| MultiBuffer::build_random(&mut rng, cx)) + }; + + let mut buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot(cx)); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot); + let (mut tab_map, tab_snapshot) = TabMap::new(fold_snapshot, 4.try_into().unwrap()); + let (wrap_map, wraps_snapshot) = cx + .update(|cx| WrapMap::new(tab_snapshot, font("Helvetica"), font_size, wrap_width, cx)); + let mut block_map = BlockMap::new( + wraps_snapshot, + buffer_start_header_height, + excerpt_header_height, + ); + let mut custom_blocks = Vec::new(); + + for _ in 0..operations { + let mut buffer_edits = Vec::new(); + match rng.gen_range(0..=100) { + 0..=19 => { + let wrap_width = if rng.gen_bool(0.2) { + None + } else { + Some(px(rng.gen_range(0.0..=100.0))) + }; + log::info!("Setting wrap width to {:?}", wrap_width); + wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); + } + 20..=39 => { + let block_count = rng.gen_range(1..=5); + let block_properties = (0..block_count) + .map(|_| { + let buffer = cx.update(|cx| buffer.read(cx).read(cx).clone()); + let position = buffer.anchor_after( + buffer.clip_offset(rng.gen_range(0..=buffer.len()), Bias::Left), + ); + + let disposition = if rng.gen() { + BlockDisposition::Above + } else { + BlockDisposition::Below + }; + let height = rng.gen_range(1..5); + log::info!( + "inserting block {:?} {:?} with height {}", + disposition, + position.to_point(&buffer), + height + ); + BlockProperties { + style: BlockStyle::Fixed, + position, + height, + disposition, + render: Arc::new(|_| div().into_any()), + } + }) + .collect::>(); + + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot.clone(), vec![]); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let (tab_snapshot, tab_edits) = + tab_map.sync(fold_snapshot, fold_edits, tab_size); + let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { + wrap_map.sync(tab_snapshot, tab_edits, cx) + }); + let mut block_map = block_map.write(wraps_snapshot, wrap_edits); + let block_ids = block_map.insert(block_properties.clone()); + for (block_id, props) in block_ids.into_iter().zip(block_properties) { + custom_blocks.push((block_id, props)); + } + } + 40..=59 if !custom_blocks.is_empty() => { + let block_count = rng.gen_range(1..=4.min(custom_blocks.len())); + let block_ids_to_remove = (0..block_count) + .map(|_| { + custom_blocks + .remove(rng.gen_range(0..custom_blocks.len())) + .0 + }) + .collect(); + + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot.clone(), vec![]); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let (tab_snapshot, tab_edits) = + tab_map.sync(fold_snapshot, fold_edits, tab_size); + let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { + wrap_map.sync(tab_snapshot, tab_edits, cx) + }); + let mut block_map = block_map.write(wraps_snapshot, wrap_edits); + block_map.remove(block_ids_to_remove); + } + _ => { + buffer.update(cx, |buffer, cx| { + let mutation_count = rng.gen_range(1..=5); + let subscription = buffer.subscribe(); + buffer.randomly_mutate(&mut rng, mutation_count, cx); + buffer_snapshot = buffer.snapshot(cx); + buffer_edits.extend(subscription.consume()); + log::info!("buffer text: {:?}", buffer_snapshot.text()); + }); + } + } + + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot.clone(), buffer_edits); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let (tab_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size); + let (wraps_snapshot, wrap_edits) = wrap_map.update(cx, |wrap_map, cx| { + wrap_map.sync(tab_snapshot, tab_edits, cx) + }); + let blocks_snapshot = block_map.read(wraps_snapshot.clone(), wrap_edits); + assert_eq!( + blocks_snapshot.transforms.summary().input_rows, + wraps_snapshot.max_point().row() + 1 + ); + log::info!("blocks text: {:?}", blocks_snapshot.text()); + + let mut expected_blocks = Vec::new(); + expected_blocks.extend(custom_blocks.iter().map(|(id, block)| { + let mut position = block.position.to_point(&buffer_snapshot); + match block.disposition { + BlockDisposition::Above => { + position.column = 0; + } + BlockDisposition::Below => { + position.column = buffer_snapshot.line_len(position.row); + } + }; + let row = wraps_snapshot.make_wrap_point(position, Bias::Left).row(); + ( + row, + ExpectedBlock::Custom { + disposition: block.disposition, + id: *id, + height: block.height, + }, + ) + })); + expected_blocks.extend(buffer_snapshot.excerpt_boundaries_in_range(0..).map( + |boundary| { + let position = + wraps_snapshot.make_wrap_point(Point::new(boundary.row, 0), Bias::Left); + ( + position.row(), + ExpectedBlock::ExcerptHeader { + height: if boundary.starts_new_buffer { + buffer_start_header_height + } else { + excerpt_header_height + }, + starts_new_buffer: boundary.starts_new_buffer, + }, + ) + }, + )); + expected_blocks.sort_unstable(); + let mut sorted_blocks_iter = expected_blocks.into_iter().peekable(); + + let input_buffer_rows = buffer_snapshot.buffer_rows(0).collect::>(); + let mut expected_buffer_rows = Vec::new(); + let mut expected_text = String::new(); + let mut expected_block_positions = Vec::new(); + let input_text = wraps_snapshot.text(); + for (row, input_line) in input_text.split('\n').enumerate() { + let row = row as u32; + if row > 0 { + expected_text.push('\n'); + } + + let buffer_row = input_buffer_rows[wraps_snapshot + .to_point(WrapPoint::new(row, 0), Bias::Left) + .row as usize]; + + while let Some((block_row, block)) = sorted_blocks_iter.peek() { + if *block_row == row && block.disposition() == BlockDisposition::Above { + let (_, block) = sorted_blocks_iter.next().unwrap(); + let height = block.height() as usize; + expected_block_positions + .push((expected_text.matches('\n').count() as u32, block)); + let text = "\n".repeat(height); + expected_text.push_str(&text); + for _ in 0..height { + expected_buffer_rows.push(None); + } + } else { + break; + } + } + + let soft_wrapped = wraps_snapshot.to_tab_point(WrapPoint::new(row, 0)).column() > 0; + expected_buffer_rows.push(if soft_wrapped { None } else { buffer_row }); + expected_text.push_str(input_line); + + while let Some((block_row, block)) = sorted_blocks_iter.peek() { + if *block_row == row && block.disposition() == BlockDisposition::Below { + let (_, block) = sorted_blocks_iter.next().unwrap(); + let height = block.height() as usize; + expected_block_positions + .push((expected_text.matches('\n').count() as u32 + 1, block)); + let text = "\n".repeat(height); + expected_text.push_str(&text); + for _ in 0..height { + expected_buffer_rows.push(None); + } + } else { + break; + } + } + } + + let expected_lines = expected_text.split('\n').collect::>(); + let expected_row_count = expected_lines.len(); + for start_row in 0..expected_row_count { + let expected_text = expected_lines[start_row..].join("\n"); + let actual_text = blocks_snapshot + .chunks( + start_row as u32..blocks_snapshot.max_point().row + 1, + false, + Highlights::default(), + ) + .map(|chunk| chunk.text) + .collect::(); + assert_eq!( + actual_text, expected_text, + "incorrect text starting from row {}", + start_row + ); + assert_eq!( + blocks_snapshot + .buffer_rows(start_row as u32) + .collect::>(), + &expected_buffer_rows[start_row..] + ); + } + + assert_eq!( + blocks_snapshot + .blocks_in_range(0..(expected_row_count as u32)) + .map(|(row, block)| (row, block.clone().into())) + .collect::>(), + expected_block_positions + ); + + let mut expected_longest_rows = Vec::new(); + let mut longest_line_len = -1_isize; + for (row, line) in expected_lines.iter().enumerate() { + let row = row as u32; + + assert_eq!( + blocks_snapshot.line_len(row), + line.len() as u32, + "invalid line len for row {}", + row + ); + + let line_char_count = line.chars().count() as isize; + match line_char_count.cmp(&longest_line_len) { + Ordering::Less => {} + Ordering::Equal => expected_longest_rows.push(row), + Ordering::Greater => { + longest_line_len = line_char_count; + expected_longest_rows.clear(); + expected_longest_rows.push(row); + } + } + } + + let longest_row = blocks_snapshot.longest_row(); + assert!( + expected_longest_rows.contains(&longest_row), + "incorrect longest row {}. expected {:?} with length {}", + longest_row, + expected_longest_rows, + longest_line_len, + ); + + for row in 0..=blocks_snapshot.wrap_snapshot.max_point().row() { + let wrap_point = WrapPoint::new(row, 0); + let block_point = blocks_snapshot.to_block_point(wrap_point); + assert_eq!(blocks_snapshot.to_wrap_point(block_point), wrap_point); + } + + let mut block_point = BlockPoint::new(0, 0); + for c in expected_text.chars() { + let left_point = blocks_snapshot.clip_point(block_point, Bias::Left); + let left_buffer_point = blocks_snapshot.to_point(left_point, Bias::Left); + assert_eq!( + blocks_snapshot.to_block_point(blocks_snapshot.to_wrap_point(left_point)), + left_point + ); + assert_eq!( + left_buffer_point, + buffer_snapshot.clip_point(left_buffer_point, Bias::Right), + "{:?} is not valid in buffer coordinates", + left_point + ); + + let right_point = blocks_snapshot.clip_point(block_point, Bias::Right); + let right_buffer_point = blocks_snapshot.to_point(right_point, Bias::Right); + assert_eq!( + blocks_snapshot.to_block_point(blocks_snapshot.to_wrap_point(right_point)), + right_point + ); + assert_eq!( + right_buffer_point, + buffer_snapshot.clip_point(right_buffer_point, Bias::Left), + "{:?} is not valid in buffer coordinates", + right_point + ); + + if c == '\n' { + block_point.0 += Point::new(1, 0); + } else { + block_point.column += c.len_utf8() as u32; + } + } + } + + #[derive(Debug, Eq, PartialEq, Ord, PartialOrd)] + enum ExpectedBlock { + ExcerptHeader { + height: u8, + starts_new_buffer: bool, + }, + Custom { + disposition: BlockDisposition, + id: BlockId, + height: u8, + }, + } + + impl ExpectedBlock { + fn height(&self) -> u8 { + match self { + ExpectedBlock::ExcerptHeader { height, .. } => *height, + ExpectedBlock::Custom { height, .. } => *height, + } + } + + fn disposition(&self) -> BlockDisposition { + match self { + ExpectedBlock::ExcerptHeader { .. } => BlockDisposition::Above, + ExpectedBlock::Custom { disposition, .. } => *disposition, + } + } + } + + impl From for ExpectedBlock { + fn from(block: TransformBlock) -> Self { + match block { + TransformBlock::Custom(block) => ExpectedBlock::Custom { + id: block.id, + disposition: block.disposition, + height: block.height, + }, + TransformBlock::ExcerptHeader { + height, + starts_new_buffer, + .. + } => ExpectedBlock::ExcerptHeader { + height, + starts_new_buffer, + }, + } + } + } + } + + fn init_test(cx: &mut gpui::AppContext) { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + } + + impl TransformBlock { + fn as_custom(&self) -> Option<&Block> { + match self { + TransformBlock::Custom(block) => Some(block), + TransformBlock::ExcerptHeader { .. } => None, + } + } + } + + impl BlockSnapshot { + fn to_point(&self, point: BlockPoint, bias: Bias) -> Point { + self.wrap_snapshot.to_point(self.to_wrap_point(point), bias) + } + } +} diff --git a/crates/editor2/src/display_map/wrap_map.rs b/crates/editor2/src/display_map/wrap_map.rs index 408142ae07d453ba7de30604735f91295ee0217b..a2ac0ec849bfb9b26983c897a2ae3cc2ebd9878c 100644 --- a/crates/editor2/src/display_map/wrap_map.rs +++ b/crates/editor2/src/display_map/wrap_map.rs @@ -162,7 +162,7 @@ impl WrapMap { { let tab_snapshot = new_snapshot.tab_snapshot.clone(); let range = TabPoint::zero()..tab_snapshot.max_point(); - let edits = new_snapshot + edits = new_snapshot .update( tab_snapshot, &[TabEdit { @@ -741,49 +741,48 @@ impl WrapSnapshot { } fn check_invariants(&self) { - // todo!() - // #[cfg(test)] - // { - // assert_eq!( - // TabPoint::from(self.transforms.summary().input.lines), - // self.tab_snapshot.max_point() - // ); - - // { - // let mut transforms = self.transforms.cursor::<()>().peekable(); - // while let Some(transform) = transforms.next() { - // if let Some(next_transform) = transforms.peek() { - // assert!(transform.is_isomorphic() != next_transform.is_isomorphic()); - // } - // } - // } - - // let text = language::Rope::from(self.text().as_str()); - // let mut input_buffer_rows = self.tab_snapshot.buffer_rows(0); - // let mut expected_buffer_rows = Vec::new(); - // let mut prev_tab_row = 0; - // for display_row in 0..=self.max_point().row() { - // let tab_point = self.to_tab_point(WrapPoint::new(display_row, 0)); - // if tab_point.row() == prev_tab_row && display_row != 0 { - // expected_buffer_rows.push(None); - // } else { - // expected_buffer_rows.push(input_buffer_rows.next().unwrap()); - // } - - // prev_tab_row = tab_point.row(); - // assert_eq!(self.line_len(display_row), text.line_len(display_row)); - // } - - // for start_display_row in 0..expected_buffer_rows.len() { - // assert_eq!( - // self.buffer_rows(start_display_row as u32) - // .collect::>(), - // &expected_buffer_rows[start_display_row..], - // "invalid buffer_rows({}..)", - // start_display_row - // ); - // } - // } + #[cfg(test)] + { + assert_eq!( + TabPoint::from(self.transforms.summary().input.lines), + self.tab_snapshot.max_point() + ); + + { + let mut transforms = self.transforms.cursor::<()>().peekable(); + while let Some(transform) = transforms.next() { + if let Some(next_transform) = transforms.peek() { + assert!(transform.is_isomorphic() != next_transform.is_isomorphic()); + } + } + } + + let text = language::Rope::from(self.text().as_str()); + let mut input_buffer_rows = self.tab_snapshot.buffer_rows(0); + let mut expected_buffer_rows = Vec::new(); + let mut prev_tab_row = 0; + for display_row in 0..=self.max_point().row() { + let tab_point = self.to_tab_point(WrapPoint::new(display_row, 0)); + if tab_point.row() == prev_tab_row && display_row != 0 { + expected_buffer_rows.push(None); + } else { + expected_buffer_rows.push(input_buffer_rows.next().unwrap()); + } + + prev_tab_row = tab_point.row(); + assert_eq!(self.line_len(display_row), text.line_len(display_row)); + } + + for start_display_row in 0..expected_buffer_rows.len() { + assert_eq!( + self.buffer_rows(start_display_row as u32) + .collect::>(), + &expected_buffer_rows[start_display_row..], + "invalid buffer_rows({}..)", + start_display_row + ); + } + } } } @@ -1026,337 +1025,334 @@ fn consolidate_wrap_edits(edits: &mut Vec) { } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::{ -// display_map::{fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap}, -// MultiBuffer, -// }; -// use gpui::test::observe; -// use rand::prelude::*; -// use settings::SettingsStore; -// use smol::stream::StreamExt; -// use std::{cmp, env, num::NonZeroU32}; -// use text::Rope; - -// #[gpui::test(iterations = 100)] -// async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) { -// init_test(cx); - -// cx.foreground().set_block_on_ticks(0..=50); -// let operations = env::var("OPERATIONS") -// .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) -// .unwrap_or(10); - -// let font_cache = cx.font_cache().clone(); -// let font_system = cx.platform().fonts(); -// let mut wrap_width = if rng.gen_bool(0.1) { -// None -// } else { -// Some(rng.gen_range(0.0..=1000.0)) -// }; -// let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); -// let family_id = font_cache -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = font_cache -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; - -// log::info!("Tab size: {}", tab_size); -// log::info!("Wrap width: {:?}", wrap_width); - -// let buffer = cx.update(|cx| { -// if rng.gen() { -// MultiBuffer::build_random(&mut rng, cx) -// } else { -// let len = rng.gen_range(0..10); -// let text = util::RandomCharIter::new(&mut rng) -// .take(len) -// .collect::(); -// MultiBuffer::build_simple(&text, cx) -// } -// }); -// let mut buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); -// log::info!("Buffer text: {:?}", buffer_snapshot.text()); -// let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); -// log::info!("InlayMap text: {:?}", inlay_snapshot.text()); -// let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot.clone()); -// log::info!("FoldMap text: {:?}", fold_snapshot.text()); -// let (mut tab_map, _) = TabMap::new(fold_snapshot.clone(), tab_size); -// let tabs_snapshot = tab_map.set_max_expansion_column(32); -// log::info!("TabMap text: {:?}", tabs_snapshot.text()); - -// let mut line_wrapper = LineWrapper::new(font_id, font_size, font_system); -// let unwrapped_text = tabs_snapshot.text(); -// let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper); - -// let (wrap_map, _) = -// cx.update(|cx| WrapMap::new(tabs_snapshot.clone(), font_id, font_size, wrap_width, cx)); -// let mut notifications = observe(&wrap_map, cx); - -// if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { -// notifications.next().await.unwrap(); -// } - -// let (initial_snapshot, _) = wrap_map.update(cx, |map, cx| { -// assert!(!map.is_rewrapping()); -// map.sync(tabs_snapshot.clone(), Vec::new(), cx) -// }); - -// let actual_text = initial_snapshot.text(); -// assert_eq!( -// actual_text, expected_text, -// "unwrapped text is: {:?}", -// unwrapped_text -// ); -// log::info!("Wrapped text: {:?}", actual_text); - -// let mut next_inlay_id = 0; -// let mut edits = Vec::new(); -// for _i in 0..operations { -// log::info!("{} ==============================================", _i); - -// let mut buffer_edits = Vec::new(); -// match rng.gen_range(0..=100) { -// 0..=19 => { -// wrap_width = if rng.gen_bool(0.2) { -// None -// } else { -// Some(rng.gen_range(0.0..=1000.0)) -// }; -// log::info!("Setting wrap width to {:?}", wrap_width); -// wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); -// } -// 20..=39 => { -// for (fold_snapshot, fold_edits) in fold_map.randomly_mutate(&mut rng) { -// let (tabs_snapshot, tab_edits) = -// tab_map.sync(fold_snapshot, fold_edits, tab_size); -// let (mut snapshot, wrap_edits) = -// wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx)); -// snapshot.check_invariants(); -// snapshot.verify_chunks(&mut rng); -// edits.push((snapshot, wrap_edits)); -// } -// } -// 40..=59 => { -// let (inlay_snapshot, inlay_edits) = -// inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng); -// let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); -// let (tabs_snapshot, tab_edits) = -// tab_map.sync(fold_snapshot, fold_edits, tab_size); -// let (mut snapshot, wrap_edits) = -// wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx)); -// snapshot.check_invariants(); -// snapshot.verify_chunks(&mut rng); -// edits.push((snapshot, wrap_edits)); -// } -// _ => { -// buffer.update(cx, |buffer, cx| { -// let subscription = buffer.subscribe(); -// let edit_count = rng.gen_range(1..=5); -// buffer.randomly_mutate(&mut rng, edit_count, cx); -// buffer_snapshot = buffer.snapshot(cx); -// buffer_edits.extend(subscription.consume()); -// }); -// } -// } - -// log::info!("Buffer text: {:?}", buffer_snapshot.text()); -// let (inlay_snapshot, inlay_edits) = -// inlay_map.sync(buffer_snapshot.clone(), buffer_edits); -// log::info!("InlayMap text: {:?}", inlay_snapshot.text()); -// let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); -// log::info!("FoldMap text: {:?}", fold_snapshot.text()); -// let (tabs_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size); -// log::info!("TabMap text: {:?}", tabs_snapshot.text()); - -// let unwrapped_text = tabs_snapshot.text(); -// let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper); -// let (mut snapshot, wrap_edits) = -// wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot.clone(), tab_edits, cx)); -// snapshot.check_invariants(); -// snapshot.verify_chunks(&mut rng); -// edits.push((snapshot, wrap_edits)); - -// if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) && rng.gen_bool(0.4) { -// log::info!("Waiting for wrapping to finish"); -// while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { -// notifications.next().await.unwrap(); -// } -// wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty())); -// } - -// if !wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { -// let (mut wrapped_snapshot, wrap_edits) = -// wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, Vec::new(), cx)); -// let actual_text = wrapped_snapshot.text(); -// let actual_longest_row = wrapped_snapshot.longest_row(); -// log::info!("Wrapping finished: {:?}", actual_text); -// wrapped_snapshot.check_invariants(); -// wrapped_snapshot.verify_chunks(&mut rng); -// edits.push((wrapped_snapshot.clone(), wrap_edits)); -// assert_eq!( -// actual_text, expected_text, -// "unwrapped text is: {:?}", -// unwrapped_text -// ); - -// let mut summary = TextSummary::default(); -// for (ix, item) in wrapped_snapshot -// .transforms -// .items(&()) -// .into_iter() -// .enumerate() -// { -// summary += &item.summary.output; -// log::info!("{} summary: {:?}", ix, item.summary.output,); -// } - -// if tab_size.get() == 1 -// || !wrapped_snapshot -// .tab_snapshot -// .fold_snapshot -// .text() -// .contains('\t') -// { -// let mut expected_longest_rows = Vec::new(); -// let mut longest_line_len = -1; -// for (row, line) in expected_text.split('\n').enumerate() { -// let line_char_count = line.chars().count() as isize; -// if line_char_count > longest_line_len { -// expected_longest_rows.clear(); -// longest_line_len = line_char_count; -// } -// if line_char_count >= longest_line_len { -// expected_longest_rows.push(row as u32); -// } -// } - -// assert!( -// expected_longest_rows.contains(&actual_longest_row), -// "incorrect longest row {}. expected {:?} with length {}", -// actual_longest_row, -// expected_longest_rows, -// longest_line_len, -// ) -// } -// } -// } - -// let mut initial_text = Rope::from(initial_snapshot.text().as_str()); -// for (snapshot, patch) in edits { -// let snapshot_text = Rope::from(snapshot.text().as_str()); -// for edit in &patch { -// let old_start = initial_text.point_to_offset(Point::new(edit.new.start, 0)); -// let old_end = initial_text.point_to_offset(cmp::min( -// Point::new(edit.new.start + edit.old.len() as u32, 0), -// initial_text.max_point(), -// )); -// let new_start = snapshot_text.point_to_offset(Point::new(edit.new.start, 0)); -// let new_end = snapshot_text.point_to_offset(cmp::min( -// Point::new(edit.new.end, 0), -// snapshot_text.max_point(), -// )); -// let new_text = snapshot_text -// .chunks_in_range(new_start..new_end) -// .collect::(); - -// initial_text.replace(old_start..old_end, &new_text); -// } -// assert_eq!(initial_text.to_string(), snapshot_text.to_string()); -// } - -// if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { -// log::info!("Waiting for wrapping to finish"); -// while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { -// notifications.next().await.unwrap(); -// } -// } -// wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty())); -// } - -// fn init_test(cx: &mut gpui::TestAppContext) { -// cx.foreground().forbid_parking(); -// cx.update(|cx| { -// cx.set_global(SettingsStore::test(cx)); -// theme::init((), cx); -// }); -// } - -// fn wrap_text( -// unwrapped_text: &str, -// wrap_width: Option, -// line_wrapper: &mut LineWrapper, -// ) -> String { -// if let Some(wrap_width) = wrap_width { -// let mut wrapped_text = String::new(); -// for (row, line) in unwrapped_text.split('\n').enumerate() { -// if row > 0 { -// wrapped_text.push('\n') -// } - -// let mut prev_ix = 0; -// for boundary in line_wrapper.wrap_line(line, wrap_width) { -// wrapped_text.push_str(&line[prev_ix..boundary.ix]); -// wrapped_text.push('\n'); -// wrapped_text.push_str(&" ".repeat(boundary.next_indent as usize)); -// prev_ix = boundary.ix; -// } -// wrapped_text.push_str(&line[prev_ix..]); -// } -// wrapped_text -// } else { -// unwrapped_text.to_string() -// } -// } - -// impl WrapSnapshot { -// pub fn text(&self) -> String { -// self.text_chunks(0).collect() -// } - -// pub fn text_chunks(&self, wrap_row: u32) -> impl Iterator { -// self.chunks( -// wrap_row..self.max_point().row() + 1, -// false, -// Highlights::default(), -// ) -// .map(|h| h.text) -// } - -// fn verify_chunks(&mut self, rng: &mut impl Rng) { -// for _ in 0..5 { -// let mut end_row = rng.gen_range(0..=self.max_point().row()); -// let start_row = rng.gen_range(0..=end_row); -// end_row += 1; - -// let mut expected_text = self.text_chunks(start_row).collect::(); -// if expected_text.ends_with('\n') { -// expected_text.push('\n'); -// } -// let mut expected_text = expected_text -// .lines() -// .take((end_row - start_row) as usize) -// .collect::>() -// .join("\n"); -// if end_row <= self.max_point().row() { -// expected_text.push('\n'); -// } - -// let actual_text = self -// .chunks(start_row..end_row, true, Highlights::default()) -// .map(|c| c.text) -// .collect::(); -// assert_eq!( -// expected_text, -// actual_text, -// "chunks != highlighted_chunks for rows {:?}", -// start_row..end_row -// ); -// } -// } -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + display_map::{fold_map::FoldMap, inlay_map::InlayMap, tab_map::TabMap}, + MultiBuffer, + }; + use gpui::{font, px, test::observe, Platform}; + use rand::prelude::*; + use settings::SettingsStore; + use smol::stream::StreamExt; + use std::{cmp, env, num::NonZeroU32}; + use text::Rope; + use theme::LoadThemes; + + #[gpui::test(iterations = 100)] + async fn test_random_wraps(cx: &mut gpui::TestAppContext, mut rng: StdRng) { + // todo!() this test is flaky + init_test(cx); + + cx.background_executor.set_block_on_ticks(0..=50); + let operations = env::var("OPERATIONS") + .map(|i| i.parse().expect("invalid `OPERATIONS` variable")) + .unwrap_or(10); + + let text_system = cx.read(|cx| cx.text_system().clone()); + let mut wrap_width = if rng.gen_bool(0.1) { + None + } else { + Some(px(rng.gen_range(0.0..=1000.0))) + }; + let tab_size = NonZeroU32::new(rng.gen_range(1..=4)).unwrap(); + let font = font("Helvetica"); + let font_id = text_system.font_id(&font).unwrap(); + let font_size = px(14.0); + + log::info!("Tab size: {}", tab_size); + log::info!("Wrap width: {:?}", wrap_width); + + let buffer = cx.update(|cx| { + if rng.gen() { + MultiBuffer::build_random(&mut rng, cx) + } else { + let len = rng.gen_range(0..10); + let text = util::RandomCharIter::new(&mut rng) + .take(len) + .collect::(); + MultiBuffer::build_simple(&text, cx) + } + }); + let mut buffer_snapshot = buffer.read_with(cx, |buffer, cx| buffer.snapshot(cx)); + log::info!("Buffer text: {:?}", buffer_snapshot.text()); + let (mut inlay_map, inlay_snapshot) = InlayMap::new(buffer_snapshot.clone()); + log::info!("InlayMap text: {:?}", inlay_snapshot.text()); + let (mut fold_map, fold_snapshot) = FoldMap::new(inlay_snapshot.clone()); + log::info!("FoldMap text: {:?}", fold_snapshot.text()); + let (mut tab_map, _) = TabMap::new(fold_snapshot.clone(), tab_size); + let tabs_snapshot = tab_map.set_max_expansion_column(32); + log::info!("TabMap text: {:?}", tabs_snapshot.text()); + + let mut line_wrapper = text_system.line_wrapper(font.clone(), font_size).unwrap(); + let unwrapped_text = tabs_snapshot.text(); + let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper); + + let (wrap_map, _) = + cx.update(|cx| WrapMap::new(tabs_snapshot.clone(), font, font_size, wrap_width, cx)); + let mut notifications = observe(&wrap_map, cx); + + if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { + notifications.next().await.unwrap(); + } + + let (initial_snapshot, _) = wrap_map.update(cx, |map, cx| { + assert!(!map.is_rewrapping()); + map.sync(tabs_snapshot.clone(), Vec::new(), cx) + }); + + let actual_text = initial_snapshot.text(); + assert_eq!( + actual_text, expected_text, + "unwrapped text is: {:?}", + unwrapped_text + ); + log::info!("Wrapped text: {:?}", actual_text); + + let mut next_inlay_id = 0; + let mut edits = Vec::new(); + for _i in 0..operations { + log::info!("{} ==============================================", _i); + + let mut buffer_edits = Vec::new(); + match rng.gen_range(0..=100) { + 0..=19 => { + wrap_width = if rng.gen_bool(0.2) { + None + } else { + Some(px(rng.gen_range(0.0..=1000.0))) + }; + log::info!("Setting wrap width to {:?}", wrap_width); + wrap_map.update(cx, |map, cx| map.set_wrap_width(wrap_width, cx)); + } + 20..=39 => { + for (fold_snapshot, fold_edits) in fold_map.randomly_mutate(&mut rng) { + let (tabs_snapshot, tab_edits) = + tab_map.sync(fold_snapshot, fold_edits, tab_size); + let (mut snapshot, wrap_edits) = + wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx)); + snapshot.check_invariants(); + snapshot.verify_chunks(&mut rng); + edits.push((snapshot, wrap_edits)); + } + } + 40..=59 => { + let (inlay_snapshot, inlay_edits) = + inlay_map.randomly_mutate(&mut next_inlay_id, &mut rng); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + let (tabs_snapshot, tab_edits) = + tab_map.sync(fold_snapshot, fold_edits, tab_size); + let (mut snapshot, wrap_edits) = + wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, tab_edits, cx)); + snapshot.check_invariants(); + snapshot.verify_chunks(&mut rng); + edits.push((snapshot, wrap_edits)); + } + _ => { + buffer.update(cx, |buffer, cx| { + let subscription = buffer.subscribe(); + let edit_count = rng.gen_range(1..=5); + buffer.randomly_mutate(&mut rng, edit_count, cx); + buffer_snapshot = buffer.snapshot(cx); + buffer_edits.extend(subscription.consume()); + }); + } + } + + log::info!("Buffer text: {:?}", buffer_snapshot.text()); + let (inlay_snapshot, inlay_edits) = + inlay_map.sync(buffer_snapshot.clone(), buffer_edits); + log::info!("InlayMap text: {:?}", inlay_snapshot.text()); + let (fold_snapshot, fold_edits) = fold_map.read(inlay_snapshot, inlay_edits); + log::info!("FoldMap text: {:?}", fold_snapshot.text()); + let (tabs_snapshot, tab_edits) = tab_map.sync(fold_snapshot, fold_edits, tab_size); + log::info!("TabMap text: {:?}", tabs_snapshot.text()); + + let unwrapped_text = tabs_snapshot.text(); + let expected_text = wrap_text(&unwrapped_text, wrap_width, &mut line_wrapper); + let (mut snapshot, wrap_edits) = + wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot.clone(), tab_edits, cx)); + snapshot.check_invariants(); + snapshot.verify_chunks(&mut rng); + edits.push((snapshot, wrap_edits)); + + if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) && rng.gen_bool(0.4) { + log::info!("Waiting for wrapping to finish"); + while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { + notifications.next().await.unwrap(); + } + wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty())); + } + + if !wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { + let (mut wrapped_snapshot, wrap_edits) = + wrap_map.update(cx, |map, cx| map.sync(tabs_snapshot, Vec::new(), cx)); + let actual_text = wrapped_snapshot.text(); + let actual_longest_row = wrapped_snapshot.longest_row(); + log::info!("Wrapping finished: {:?}", actual_text); + wrapped_snapshot.check_invariants(); + wrapped_snapshot.verify_chunks(&mut rng); + edits.push((wrapped_snapshot.clone(), wrap_edits)); + assert_eq!( + actual_text, expected_text, + "unwrapped text is: {:?}", + unwrapped_text + ); + + let mut summary = TextSummary::default(); + for (ix, item) in wrapped_snapshot + .transforms + .items(&()) + .into_iter() + .enumerate() + { + summary += &item.summary.output; + log::info!("{} summary: {:?}", ix, item.summary.output,); + } + + if tab_size.get() == 1 + || !wrapped_snapshot + .tab_snapshot + .fold_snapshot + .text() + .contains('\t') + { + let mut expected_longest_rows = Vec::new(); + let mut longest_line_len = -1; + for (row, line) in expected_text.split('\n').enumerate() { + let line_char_count = line.chars().count() as isize; + if line_char_count > longest_line_len { + expected_longest_rows.clear(); + longest_line_len = line_char_count; + } + if line_char_count >= longest_line_len { + expected_longest_rows.push(row as u32); + } + } + + assert!( + expected_longest_rows.contains(&actual_longest_row), + "incorrect longest row {}. expected {:?} with length {}", + actual_longest_row, + expected_longest_rows, + longest_line_len, + ) + } + } + } + + let mut initial_text = Rope::from(initial_snapshot.text().as_str()); + for (snapshot, patch) in edits { + let snapshot_text = Rope::from(snapshot.text().as_str()); + for edit in &patch { + let old_start = initial_text.point_to_offset(Point::new(edit.new.start, 0)); + let old_end = initial_text.point_to_offset(cmp::min( + Point::new(edit.new.start + edit.old.len() as u32, 0), + initial_text.max_point(), + )); + let new_start = snapshot_text.point_to_offset(Point::new(edit.new.start, 0)); + let new_end = snapshot_text.point_to_offset(cmp::min( + Point::new(edit.new.end, 0), + snapshot_text.max_point(), + )); + let new_text = snapshot_text + .chunks_in_range(new_start..new_end) + .collect::(); + + initial_text.replace(old_start..old_end, &new_text); + } + assert_eq!(initial_text.to_string(), snapshot_text.to_string()); + } + + if wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { + log::info!("Waiting for wrapping to finish"); + while wrap_map.read_with(cx, |map, _| map.is_rewrapping()) { + notifications.next().await.unwrap(); + } + } + wrap_map.read_with(cx, |map, _| assert!(map.pending_edits.is_empty())); + } + + fn init_test(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(LoadThemes::JustBase, cx); + }); + } + + fn wrap_text( + unwrapped_text: &str, + wrap_width: Option, + line_wrapper: &mut LineWrapper, + ) -> String { + if let Some(wrap_width) = wrap_width { + let mut wrapped_text = String::new(); + for (row, line) in unwrapped_text.split('\n').enumerate() { + if row > 0 { + wrapped_text.push('\n') + } + + let mut prev_ix = 0; + for boundary in line_wrapper.wrap_line(line, wrap_width) { + wrapped_text.push_str(&line[prev_ix..boundary.ix]); + wrapped_text.push('\n'); + wrapped_text.push_str(&" ".repeat(boundary.next_indent as usize)); + prev_ix = boundary.ix; + } + wrapped_text.push_str(&line[prev_ix..]); + } + wrapped_text + } else { + unwrapped_text.to_string() + } + } + + impl WrapSnapshot { + pub fn text(&self) -> String { + self.text_chunks(0).collect() + } + + pub fn text_chunks(&self, wrap_row: u32) -> impl Iterator { + self.chunks( + wrap_row..self.max_point().row() + 1, + false, + Highlights::default(), + ) + .map(|h| h.text) + } + + fn verify_chunks(&mut self, rng: &mut impl Rng) { + for _ in 0..5 { + let mut end_row = rng.gen_range(0..=self.max_point().row()); + let start_row = rng.gen_range(0..=end_row); + end_row += 1; + + let mut expected_text = self.text_chunks(start_row).collect::(); + if expected_text.ends_with('\n') { + expected_text.push('\n'); + } + let mut expected_text = expected_text + .lines() + .take((end_row - start_row) as usize) + .collect::>() + .join("\n"); + if end_row <= self.max_point().row() { + expected_text.push('\n'); + } + + let actual_text = self + .chunks(start_row..end_row, true, Highlights::default()) + .map(|c| c.text) + .collect::(); + assert_eq!( + expected_text, + actual_text, + "chunks != highlighted_chunks for rows {:?}", + start_row..end_row + ); + } + } + } +} diff --git a/crates/editor2/src/editor.rs b/crates/editor2/src/editor.rs index 99d8a4bb93badfd5a2fe1eaa5df7561d605e444e..529438648ab0a1a2495f60a261112ef73847d90b 100644 --- a/crates/editor2/src/editor.rs +++ b/crates/editor2/src/editor.rs @@ -63,6 +63,7 @@ use language::{ use lazy_static::lazy_static; use link_go_to_definition::{GoToDefinitionLink, InlayHighlight, LinkGoToDefinitionState}; use lsp::{DiagnosticSeverity, LanguageServerId}; +use mouse_context_menu::MouseContextMenu; use movement::TextLayoutDetails; use multi_buffer::ToOffsetUtf16; pub use multi_buffer::{ @@ -99,8 +100,10 @@ use text::{OffsetUtf16, Rope}; use theme::{ ActiveTheme, DiagnosticStyle, PlayerColor, SyntaxTheme, Theme, ThemeColors, ThemeSettings, }; -use ui::prelude::*; -use ui::{h_stack, v_stack, HighlightedLabel, IconButton, Popover, Tooltip}; +use ui::{ + h_stack, v_stack, ButtonSize, ButtonStyle, HighlightedLabel, Icon, IconButton, Popover, Tooltip, +}; +use ui::{prelude::*, IconSize}; use util::{post_inc, RangeExt, ResultExt, TryFutureExt}; use workspace::{ item::{ItemEvent, ItemHandle}, @@ -153,7 +156,6 @@ pub fn render_parsed_markdown( } }), ); - let runs = text_runs_for_highlights(&parsed.text, &editor_style.text, highlights); let mut links = Vec::new(); let mut link_ranges = Vec::new(); @@ -166,7 +168,7 @@ pub fn render_parsed_markdown( InteractiveText::new( element_id, - StyledText::new(parsed.text.clone()).with_runs(runs), + StyledText::new(parsed.text.clone()).with_highlights(&editor_style.text, highlights), ) .on_click(link_ranges, move |clicked_range_ix, cx| { match &links[clicked_range_ix] { @@ -407,133 +409,17 @@ pub fn init_settings(cx: &mut AppContext) { pub fn init(cx: &mut AppContext) { init_settings(cx); - // cx.register_action_type(Editor::new_file); - // cx.register_action_type(Editor::new_file_in_direction); - // cx.register_action_type(Editor::cancel); - // cx.register_action_type(Editor::newline); - // cx.register_action_type(Editor::newline_above); - // cx.register_action_type(Editor::newline_below); - // cx.register_action_type(Editor::backspace); - // cx.register_action_type(Editor::delete); - // cx.register_action_type(Editor::tab); - // cx.register_action_type(Editor::tab_prev); - // cx.register_action_type(Editor::indent); - // cx.register_action_type(Editor::outdent); - // cx.register_action_type(Editor::delete_line); - // cx.register_action_type(Editor::join_lines); - // cx.register_action_type(Editor::sort_lines_case_sensitive); - // cx.register_action_type(Editor::sort_lines_case_insensitive); - // cx.register_action_type(Editor::reverse_lines); - // cx.register_action_type(Editor::shuffle_lines); - // cx.register_action_type(Editor::convert_to_upper_case); - // cx.register_action_type(Editor::convert_to_lower_case); - // cx.register_action_type(Editor::convert_to_title_case); - // cx.register_action_type(Editor::convert_to_snake_case); - // cx.register_action_type(Editor::convert_to_kebab_case); - // cx.register_action_type(Editor::convert_to_upper_camel_case); - // cx.register_action_type(Editor::convert_to_lower_camel_case); - // cx.register_action_type(Editor::delete_to_previous_word_start); - // cx.register_action_type(Editor::delete_to_previous_subword_start); - // cx.register_action_type(Editor::delete_to_next_word_end); - // cx.register_action_type(Editor::delete_to_next_subword_end); - // cx.register_action_type(Editor::delete_to_beginning_of_line); - // cx.register_action_type(Editor::delete_to_end_of_line); - // cx.register_action_type(Editor::cut_to_end_of_line); - // cx.register_action_type(Editor::duplicate_line); - // cx.register_action_type(Editor::move_line_up); - // cx.register_action_type(Editor::move_line_down); - // cx.register_action_type(Editor::transpose); - // cx.register_action_type(Editor::cut); - // cx.register_action_type(Editor::copy); - // cx.register_action_type(Editor::paste); - // cx.register_action_type(Editor::undo); - // cx.register_action_type(Editor::redo); - // cx.register_action_type(Editor::move_page_up); - // cx.register_action_type::(); - // cx.register_action_type(Editor::move_page_down); - // cx.register_action_type(Editor::next_screen); - // cx.register_action_type::(); - // cx.register_action_type::(); - // cx.register_action_type(Editor::move_to_previous_word_start); - // cx.register_action_type(Editor::move_to_previous_subword_start); - // cx.register_action_type(Editor::move_to_next_word_end); - // cx.register_action_type(Editor::move_to_next_subword_end); - // cx.register_action_type(Editor::move_to_beginning_of_line); - // cx.register_action_type(Editor::move_to_end_of_line); - // cx.register_action_type(Editor::move_to_start_of_paragraph); - // cx.register_action_type(Editor::move_to_end_of_paragraph); - // cx.register_action_type(Editor::move_to_beginning); - // cx.register_action_type(Editor::move_to_end); - // cx.register_action_type(Editor::select_up); - // cx.register_action_type(Editor::select_down); - // cx.register_action_type(Editor::select_left); - // cx.register_action_type(Editor::select_right); - // cx.register_action_type(Editor::select_to_previous_word_start); - // cx.register_action_type(Editor::select_to_previous_subword_start); - // cx.register_action_type(Editor::select_to_next_word_end); - // cx.register_action_type(Editor::select_to_next_subword_end); - // cx.register_action_type(Editor::select_to_beginning_of_line); - // cx.register_action_type(Editor::select_to_end_of_line); - // cx.register_action_type(Editor::select_to_start_of_paragraph); - // cx.register_action_type(Editor::select_to_end_of_paragraph); - // cx.register_action_type(Editor::select_to_beginning); - // cx.register_action_type(Editor::select_to_end); - // cx.register_action_type(Editor::select_all); - // cx.register_action_type(Editor::select_all_matches); - // cx.register_action_type(Editor::select_line); - // cx.register_action_type(Editor::split_selection_into_lines); - // cx.register_action_type(Editor::add_selection_above); - // cx.register_action_type(Editor::add_selection_below); - // cx.register_action_type(Editor::select_next); - // cx.register_action_type(Editor::select_previous); - // cx.register_action_type(Editor::toggle_comments); - // cx.register_action_type(Editor::select_larger_syntax_node); - // cx.register_action_type(Editor::select_smaller_syntax_node); - // cx.register_action_type(Editor::move_to_enclosing_bracket); - // cx.register_action_type(Editor::undo_selection); - // cx.register_action_type(Editor::redo_selection); - // cx.register_action_type(Editor::go_to_diagnostic); - // cx.register_action_type(Editor::go_to_prev_diagnostic); - // cx.register_action_type(Editor::go_to_hunk); - // cx.register_action_type(Editor::go_to_prev_hunk); - // cx.register_action_type(Editor::go_to_definition); - // cx.register_action_type(Editor::go_to_definition_split); - // cx.register_action_type(Editor::go_to_type_definition); - // cx.register_action_type(Editor::go_to_type_definition_split); - // cx.register_action_type(Editor::fold); - // cx.register_action_type(Editor::fold_at); - // cx.register_action_type(Editor::unfold_lines); - // cx.register_action_type(Editor::unfold_at); - // cx.register_action_type(Editor::gutter_hover); - // cx.register_action_type(Editor::fold_selected_ranges); - // cx.register_action_type(Editor::show_completions); - // cx.register_action_type(Editor::toggle_code_actions); - // cx.register_action_type(Editor::open_excerpts); - // cx.register_action_type(Editor::toggle_soft_wrap); - // cx.register_action_type(Editor::toggle_inlay_hints); - // cx.register_action_type(Editor::reveal_in_finder); - // cx.register_action_type(Editor::copy_path); - // cx.register_action_type(Editor::copy_relative_path); - // cx.register_action_type(Editor::copy_highlight_json); - // cx.add_async_action(Editor::format); - // cx.register_action_type(Editor::restart_language_server); - // cx.register_action_type(Editor::show_character_palette); - // cx.add_async_action(Editor::confirm_completion); - // cx.add_async_action(Editor::confirm_code_action); - // cx.add_async_action(Editor::rename); - // cx.add_async_action(Editor::confirm_rename); - // cx.add_async_action(Editor::find_all_references); - // cx.register_action_type(Editor::next_copilot_suggestion); - // cx.register_action_type(Editor::previous_copilot_suggestion); - // cx.register_action_type(Editor::copilot_suggest); - // cx.register_action_type(Editor::context_menu_first); - // cx.register_action_type(Editor::context_menu_prev); - // cx.register_action_type(Editor::context_menu_next); - // cx.register_action_type(Editor::context_menu_last); workspace::register_project_item::(cx); workspace::register_followable_item::(cx); workspace::register_deserializable_item::(cx); + cx.observe_new_views( + |workspace: &mut Workspace, cx: &mut ViewContext| { + workspace.register_action(Editor::new_file); + workspace.register_action(Editor::new_file_in_direction); + }, + ) + .detach(); } trait InvalidationRegion { @@ -621,8 +507,6 @@ pub struct Editor { ime_transaction: Option, active_diagnostics: Option, soft_wrap_mode_override: Option, - // get_field_editor_theme: Option>, - // override_text_style: Option>, project: Option>, collaboration_hub: Option>, blink_manager: Model, @@ -636,7 +520,7 @@ pub struct Editor { inlay_background_highlights: TreeMap, InlayBackgroundHighlight>, nav_history: Option, context_menu: RwLock>, - // mouse_context_menu: View, + mouse_context_menu: Option, completion_tasks: Vec<(CompletionId, Task>)>, next_completion_id: CompletionId, available_code_actions: Option<(Model, Arc<[CodeAction]>)>, @@ -1316,11 +1200,7 @@ impl CompletionsMenu { ), ); let completion_label = StyledText::new(completion.label.text.clone()) - .with_runs(text_runs_for_highlights( - &completion.label.text, - &style.text, - highlights, - )); + .with_highlights(&style.text, highlights); let documentation_label = if let Some(Documentation::SingleLine(text)) = documentation { Some(SharedString::from(text.clone())) @@ -1734,21 +1614,11 @@ impl Editor { // Self::new(EditorMode::Full, buffer, None, field_editor_style, cx) // } - // pub fn auto_height( - // max_lines: usize, - // field_editor_style: Option>, - // cx: &mut ViewContext, - // ) -> Self { - // let buffer = cx.build_model(|cx| Buffer::new(0, cx.model_id() as u64, String::new())); - // let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); - // Self::new( - // EditorMode::AutoHeight { max_lines }, - // buffer, - // None, - // field_editor_style, - // cx, - // ) - // } + pub fn auto_height(max_lines: usize, cx: &mut ViewContext) -> Self { + let buffer = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), String::new())); + let buffer = cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)); + Self::new(EditorMode::AutoHeight { max_lines }, buffer, None, cx) + } pub fn for_buffer( buffer: Model, @@ -1768,14 +1638,7 @@ impl Editor { } pub fn clone(&self, cx: &mut ViewContext) -> Self { - let mut clone = Self::new( - self.mode, - self.buffer.clone(), - self.project.clone(), - // todo! - // self.get_field_editor_theme.clone(), - cx, - ); + let mut clone = Self::new(self.mode, self.buffer.clone(), self.project.clone(), cx); self.display_map.update(cx, |display_map, cx| { let snapshot = display_map.snapshot(cx); clone.display_map.update(cx, |display_map, cx| { @@ -1792,17 +1655,11 @@ impl Editor { mode: EditorMode, buffer: Model, project: Option>, - // todo!() - // get_field_editor_theme: Option>, cx: &mut ViewContext, ) -> Self { - // let editor_view_id = cx.view_id(); let style = cx.text_style(); let font_size = style.font_size.to_pixels(cx.rem_size()); let display_map = cx.build_model(|cx| { - // todo!() - // let settings = settings::get::(cx); - // let style = build_style(settings, get_field_editor_theme.as_deref(), None, cx); DisplayMap::new(buffer.clone(), style.font(), font_size, None, 2, 1, cx) }); @@ -1858,7 +1715,6 @@ impl Editor { ime_transaction: Default::default(), active_diagnostics: None, soft_wrap_mode_override, - // get_field_editor_theme, collaboration_hub: project.clone().map(|project| Box::new(project) as _), project, blink_manager: blink_manager.clone(), @@ -1872,8 +1728,7 @@ impl Editor { inlay_background_highlights: Default::default(), nav_history: None, context_menu: RwLock::new(None), - // mouse_context_menu: cx - // .add_view(|cx| context_menu::ContextMenu::new(editor_view_id, cx)), + mouse_context_menu: None, completion_tasks: Default::default(), next_completion_id: 0, next_inlay_id: 0, @@ -1882,7 +1737,6 @@ impl Editor { document_highlights_task: Default::default(), pending_rename: Default::default(), searchable: true, - // override_text_style: None, cursor_shape: Default::default(), autoindent_mode: Some(AutoindentMode::EachLine), collapse_matches: false, @@ -2000,25 +1854,25 @@ impl Editor { } } - // pub fn new_file_in_direction( - // workspace: &mut Workspace, - // action: &workspace::NewFileInDirection, - // cx: &mut ViewContext, - // ) { - // let project = workspace.project().clone(); - // if project.read(cx).is_remote() { - // cx.propagate(); - // } else if let Some(buffer) = project - // .update(cx, |project, cx| project.create_buffer("", None, cx)) - // .log_err() - // { - // workspace.split_item( - // action.0, - // Box::new(cx.add_view(|cx| Editor::for_buffer(buffer, Some(project.clone()), cx))), - // cx, - // ); - // } - // } + pub fn new_file_in_direction( + workspace: &mut Workspace, + action: &workspace::NewFileInDirection, + cx: &mut ViewContext, + ) { + let project = workspace.project().clone(); + if project.read(cx).is_remote() { + cx.propagate(); + } else if let Some(buffer) = project + .update(cx, |project, cx| project.create_buffer("", None, cx)) + .log_err() + { + workspace.split_item( + action.0, + Box::new(cx.build_view(|cx| Editor::for_buffer(buffer, Some(project.clone()), cx))), + cx, + ); + } + } pub fn replica_id(&self, cx: &AppContext) -> ReplicaId { self.buffer.read(cx).replica_id() @@ -2068,14 +1922,14 @@ impl Editor { // self.buffer.read(cx).read(cx).file_at(point).cloned() // } - // pub fn active_excerpt( - // &self, - // cx: &AppContext, - // ) -> Option<(ExcerptId, Model, Range)> { - // self.buffer - // .read(cx) - // .excerpt_containing(self.selections.newest_anchor().head(), cx) - // } + pub fn active_excerpt( + &self, + cx: &AppContext, + ) -> Option<(ExcerptId, Model, Range)> { + self.buffer + .read(cx) + .excerpt_containing(self.selections.newest_anchor().head(), cx) + } // pub fn style(&self, cx: &AppContext) -> EditorStyle { // build_style( @@ -3632,7 +3486,7 @@ impl Editor { drop(context_menu); this.discard_copilot_suggestion(cx); cx.notify(); - } else if this.completion_tasks.is_empty() { + } else if this.completion_tasks.len() <= 1 { // If there are no more completion tasks and the last menu was // empty, we should hide it. If it was already hidden, we should // also show the copilot suggestion when available. @@ -8374,6 +8228,23 @@ impl Editor { cx.notify(); } + pub fn set_style(&mut self, style: EditorStyle, cx: &mut ViewContext) { + let rem_size = cx.rem_size(); + self.display_map.update(cx, |map, cx| { + map.set_font( + style.text.font(), + style.text.font_size.to_pixels(rem_size), + cx, + ) + }); + self.style = Some(style); + } + + #[cfg(any(test, feature = "test-support"))] + pub fn style(&self) -> Option<&EditorStyle> { + self.style.as_ref() + } + pub fn set_wrap_width(&self, width: Option, cx: &mut AppContext) -> bool { self.display_map .update(cx, |map, cx| map.set_wrap_width(width, cx)) @@ -8796,62 +8667,56 @@ impl Editor { // self.searchable // } - // fn open_excerpts(workspace: &mut Workspace, _: &OpenExcerpts, cx: &mut ViewContext) { - // let active_item = workspace.active_item(cx); - // let editor_handle = if let Some(editor) = active_item - // .as_ref() - // .and_then(|item| item.act_as::(cx)) - // { - // editor - // } else { - // cx.propagate(); - // return; - // }; - - // let editor = editor_handle.read(cx); - // let buffer = editor.buffer.read(cx); - // if buffer.is_singleton() { - // cx.propagate(); - // return; - // } + fn open_excerpts(&mut self, _: &OpenExcerpts, cx: &mut ViewContext) { + let buffer = self.buffer.read(cx); + if buffer.is_singleton() { + cx.propagate(); + return; + } - // let mut new_selections_by_buffer = HashMap::default(); - // for selection in editor.selections.all::(cx) { - // for (buffer, mut range, _) in - // buffer.range_to_buffer_ranges(selection.start..selection.end, cx) - // { - // if selection.reversed { - // mem::swap(&mut range.start, &mut range.end); - // } - // new_selections_by_buffer - // .entry(buffer) - // .or_insert(Vec::new()) - // .push(range) - // } - // } + let Some(workspace) = self.workspace() else { + cx.propagate(); + return; + }; - // editor_handle.update(cx, |editor, cx| { - // editor.push_to_nav_history(editor.selections.newest_anchor().head(), None, cx); - // }); - // let pane = workspace.active_pane().clone(); - // pane.update(cx, |pane, _| pane.disable_history()); - - // // We defer the pane interaction because we ourselves are a workspace item - // // and activating a new item causes the pane to call a method on us reentrantly, - // // which panics if we're on the stack. - // cx.defer(move |workspace, cx| { - // for (buffer, ranges) in new_selections_by_buffer.into_iter() { - // let editor = workspace.open_project_item::(buffer, cx); - // editor.update(cx, |editor, cx| { - // editor.change_selections(Some(Autoscroll::newest()), cx, |s| { - // s.select_ranges(ranges); - // }); - // }); - // } - - // pane.update(cx, |pane, _| pane.enable_history()); - // }); - // } + let mut new_selections_by_buffer = HashMap::default(); + for selection in self.selections.all::(cx) { + for (buffer, mut range, _) in + buffer.range_to_buffer_ranges(selection.start..selection.end, cx) + { + if selection.reversed { + mem::swap(&mut range.start, &mut range.end); + } + new_selections_by_buffer + .entry(buffer) + .or_insert(Vec::new()) + .push(range) + } + } + + self.push_to_nav_history(self.selections.newest_anchor().head(), None, cx); + + // We defer the pane interaction because we ourselves are a workspace item + // and activating a new item causes the pane to call a method on us reentrantly, + // which panics if we're on the stack. + cx.window_context().defer(move |cx| { + workspace.update(cx, |workspace, cx| { + let pane = workspace.active_pane().clone(); + pane.update(cx, |pane, _| pane.disable_history()); + + for (buffer, ranges) in new_selections_by_buffer.into_iter() { + let editor = workspace.open_project_item::(buffer, cx); + editor.update(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::newest()), cx, |s| { + s.select_ranges(ranges); + }); + }); + } + + pane.update(cx, |pane, _| pane.enable_history()); + }) + }); + } fn jump( &mut self, @@ -9397,7 +9262,7 @@ impl Render for Editor { fn render(&mut self, cx: &mut ViewContext) -> Self::Element { let settings = ThemeSettings::get_global(cx); let text_style = match self.mode { - EditorMode::SingleLine => TextStyle { + EditorMode::SingleLine | EditorMode::AutoHeight { .. } => TextStyle { color: cx.theme().colors().text, font_family: settings.ui_font.family.clone(), font_features: settings.ui_font.features, @@ -9410,8 +9275,6 @@ impl Render for Editor { white_space: WhiteSpace::Normal, }, - EditorMode::AutoHeight { max_lines } => todo!(), - EditorMode::Full => TextStyle { color: cx.theme().colors().text, font_family: settings.buffer_font.family.clone(), @@ -9446,106 +9309,6 @@ impl Render for Editor { } } -// impl View for Editor { -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// let style = self.style(cx); -// let font_changed = self.display_map.update(cx, |map, cx| { -// map.set_fold_ellipses_color(style.folds.ellipses.text_color); -// map.set_font_with_size(style.text.font_id, style.text.font_size, cx) -// }); - -// if font_changed { -// cx.defer(move |editor, cx: &mut ViewContext| { -// hide_hover(editor, cx); -// hide_link_definition(editor, cx); -// }); -// } - -// Stack::new() -// .with_child(EditorElement::new(style.clone())) -// .with_child(ChildView::new(&self.mouse_context_menu, cx)) -// .into_any() -// } - -// fn ui_name() -> &'static str { -// "Editor" -// } - -// fn focus_in(&mut self, focused: AnyView, cx: &mut ViewContext) { -// if cx.is_self_focused() { -// let focused_event = EditorFocused(cx.handle()); -// cx.emit(Event::Focused); -// cx.emit_global(focused_event); -// } -// if let Some(rename) = self.pending_rename.as_ref() { -// cx.focus(&rename.editor); -// } else if cx.is_self_focused() || !focused.is::() { -// if !self.focused { -// self.blink_manager.update(cx, BlinkManager::enable); -// } -// self.focused = true; -// self.buffer.update(cx, |buffer, cx| { -// buffer.finalize_last_transaction(cx); -// if self.leader_peer_id.is_none() { -// buffer.set_active_selections( -// &self.selections.disjoint_anchors(), -// self.selections.line_mode, -// self.cursor_shape, -// cx, -// ); -// } -// }); -// } -// } - -// fn focus_out(&mut self, _: AnyView, cx: &mut ViewContext) { -// let blurred_event = EditorBlurred(cx.handle()); -// cx.emit_global(blurred_event); -// self.focused = false; -// self.blink_manager.update(cx, BlinkManager::disable); -// self.buffer -// .update(cx, |buffer, cx| buffer.remove_active_selections(cx)); -// self.hide_context_menu(cx); -// hide_hover(self, cx); -// cx.emit(Event::Blurred); -// cx.notify(); -// } - -// fn modifiers_changed( -// &mut self, -// event: &gpui::platform::ModifiersChangedEvent, -// cx: &mut ViewContext, -// ) -> bool { -// let pending_selection = self.has_pending_selection(); - -// if let Some(point) = &self.link_go_to_definition_state.last_trigger_point { -// if event.cmd && !pending_selection { -// let point = point.clone(); -// let snapshot = self.snapshot(cx); -// let kind = point.definition_kind(event.shift); - -// show_link_definition(kind, self, point, snapshot, cx); -// return false; -// } -// } - -// { -// if self.link_go_to_definition_state.symbol_range.is_some() -// || !self.link_go_to_definition_state.definitions.is_empty() -// { -// self.link_go_to_definition_state.symbol_range.take(); -// self.link_go_to_definition_state.definitions.clear(); -// cx.notify(); -// } - -// self.link_go_to_definition_state.task = None; - -// self.clear_highlights::(cx); -// } - -// false -// } - impl InputHandler for Editor { fn text_for_range( &mut self, @@ -9792,72 +9555,6 @@ impl InputHandler for Editor { } } -// fn build_style( -// settings: &ThemeSettings, -// get_field_editor_theme: Option<&GetFieldEditorTheme>, -// override_text_style: Option<&OverrideTextStyle>, -// cx: &mut AppContext, -// ) -> EditorStyle { -// let font_cache = cx.font_cache(); -// let line_height_scalar = settings.line_height(); -// let theme_id = settings.theme.meta.id; -// let mut theme = settings.theme.editor.clone(); -// let mut style = if let Some(get_field_editor_theme) = get_field_editor_theme { -// let field_editor_theme = get_field_editor_theme(&settings.theme); -// theme.text_color = field_editor_theme.text.color; -// theme.selection = field_editor_theme.selection; -// theme.background = field_editor_theme -// .container -// .background_color -// .unwrap_or_default(); -// EditorStyle { -// text: field_editor_theme.text, -// placeholder_text: field_editor_theme.placeholder_text, -// line_height_scalar, -// theme, -// theme_id, -// } -// } else { -// todo!(); -// // let font_family_id = settings.buffer_font_family; -// // let font_family_name = cx.font_cache().family_name(font_family_id).unwrap(); -// // let font_properties = Default::default(); -// // let font_id = font_cache -// // .select_font(font_family_id, &font_properties) -// // .unwrap(); -// // let font_size = settings.buffer_font_size(cx); -// // EditorStyle { -// // text: TextStyle { -// // color: settings.theme.editor.text_color, -// // font_family_name, -// // font_family_id, -// // font_id, -// // font_size, -// // font_properties, -// // underline: Default::default(), -// // soft_wrap: false, -// // }, -// // placeholder_text: None, -// // line_height_scalar, -// // theme, -// // theme_id, -// // } -// }; - -// if let Some(highlight_style) = override_text_style.and_then(|build_style| build_style(&style)) { -// if let Some(highlighted) = style -// .text -// .clone() -// .highlight(highlight_style, font_cache) -// .log_err() -// { -// style.text = highlighted; -// } -// } - -// style -// } - trait SelectionExt { fn offset_range(&self, buffer: &MultiBufferSnapshot) -> Range; fn point_range(&self, buffer: &MultiBufferSnapshot) -> Range; @@ -9999,20 +9696,42 @@ pub fn diagnostic_block_renderer(diagnostic: Diagnostic, is_valid: bool) -> Rend let message = diagnostic.message; Arc::new(move |cx: &mut BlockContext| { let message = message.clone(); + let copy_id: SharedString = format!("copy-{}", cx.block_id.clone()).to_string().into(); + let write_to_clipboard = cx.write_to_clipboard(ClipboardItem::new(message.clone())); + + // TODO: Nate: We should tint the background of the block with the severity color + // We need to extend the theme before we can do this v_stack() .id(cx.block_id) + .relative() .size_full() .bg(gpui::red()) .children(highlighted_lines.iter().map(|(line, highlights)| { - div() + let group_id = cx.block_id.to_string(); + + h_stack() + .group(group_id.clone()) + .gap_2() + .absolute() + .left(cx.anchor_x) + .px_1p5() .child(HighlightedLabel::new(line.clone(), highlights.clone())) - .ml(cx.anchor_x) - })) - .cursor_pointer() - .on_click(cx.listener(move |_, _, cx| { - cx.write_to_clipboard(ClipboardItem::new(message.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)), + ), + ) })) - .tooltip(|cx| Tooltip::text("Copy diagnostic message", cx)) .into_any_element() }) } @@ -10060,31 +9779,6 @@ pub fn diagnostic_style( } } -pub fn text_runs_for_highlights( - text: &str, - default_style: &TextStyle, - highlights: impl IntoIterator, HighlightStyle)>, -) -> Vec { - let mut runs = Vec::new(); - let mut ix = 0; - for (range, highlight) in highlights { - if ix < range.start { - runs.push(default_style.clone().to_run(range.start - ix)); - } - runs.push( - default_style - .clone() - .highlight(highlight) - .to_run(range.len()), - ); - ix = range.end; - } - if ix < text.len() { - runs.push(default_style.to_run(text.len() - ix)); - } - runs -} - pub fn styled_runs_for_code_label<'a>( label: &'a CodeLabel, syntax_theme: &'a theme::SyntaxTheme, diff --git a/crates/editor2/src/editor_tests.rs b/crates/editor2/src/editor_tests.rs index e640be8efe030250876138ed23a885119a5b7dbb..424da8987eb6d673f0e789d4b8ae8b1620967045 100644 --- a/crates/editor2/src/editor_tests.rs +++ b/crates/editor2/src/editor_tests.rs @@ -12,7 +12,7 @@ use futures::StreamExt; use gpui::{ div, serde_json::{self, json}, - Div, TestAppContext, VisualTestContext, WindowBounds, WindowOptions, + Div, Flatten, Platform, TestAppContext, VisualTestContext, WindowBounds, WindowOptions, }; use indoc::indoc; use language::{ @@ -36,121 +36,120 @@ use workspace::{ NavigationEntry, ViewId, }; -// todo(finish edit tests) -// #[gpui::test] -// fn test_edit_events(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// let buffer = cx.build_model(|cx| { -// let mut buffer = language::Buffer::new(0, cx.entity_id().as_u64(), "123456"); -// buffer.set_group_interval(Duration::from_secs(1)); -// buffer -// }); - -// let events = Rc::new(RefCell::new(Vec::new())); -// let editor1 = cx.add_window({ -// let events = events.clone(); -// |cx| { -// let view = cx.view().clone(); -// cx.subscribe(&view, move |_, _, event, _| { -// if matches!(event, Event::Edited | Event::BufferEdited) { -// events.borrow_mut().push(("editor1", event.clone())); -// } -// }) -// .detach(); -// Editor::for_buffer(buffer.clone(), None, cx) -// } -// }); - -// let editor2 = cx.add_window({ -// let events = events.clone(); -// |cx| { -// cx.subscribe(&cx.view().clone(), move |_, _, event, _| { -// if matches!(event, Event::Edited | Event::BufferEdited) { -// events.borrow_mut().push(("editor2", event.clone())); -// } -// }) -// .detach(); -// Editor::for_buffer(buffer.clone(), None, cx) -// } -// }); - -// assert_eq!(mem::take(&mut *events.borrow_mut()), []); - -// // Mutating editor 1 will emit an `Edited` event only for that editor. -// editor1.update(cx, |editor, cx| editor.insert("X", cx)); -// assert_eq!( -// mem::take(&mut *events.borrow_mut()), -// [ -// ("editor1", Event::Edited), -// ("editor1", Event::BufferEdited), -// ("editor2", Event::BufferEdited), -// ] -// ); - -// // Mutating editor 2 will emit an `Edited` event only for that editor. -// editor2.update(cx, |editor, cx| editor.delete(&Delete, cx)); -// assert_eq!( -// mem::take(&mut *events.borrow_mut()), -// [ -// ("editor2", Event::Edited), -// ("editor1", Event::BufferEdited), -// ("editor2", Event::BufferEdited), -// ] -// ); - -// // Undoing on editor 1 will emit an `Edited` event only for that editor. -// editor1.update(cx, |editor, cx| editor.undo(&Undo, cx)); -// assert_eq!( -// mem::take(&mut *events.borrow_mut()), -// [ -// ("editor1", Event::Edited), -// ("editor1", Event::BufferEdited), -// ("editor2", Event::BufferEdited), -// ] -// ); - -// // Redoing on editor 1 will emit an `Edited` event only for that editor. -// editor1.update(cx, |editor, cx| editor.redo(&Redo, cx)); -// assert_eq!( -// mem::take(&mut *events.borrow_mut()), -// [ -// ("editor1", Event::Edited), -// ("editor1", Event::BufferEdited), -// ("editor2", Event::BufferEdited), -// ] -// ); - -// // Undoing on editor 2 will emit an `Edited` event only for that editor. -// editor2.update(cx, |editor, cx| editor.undo(&Undo, cx)); -// assert_eq!( -// mem::take(&mut *events.borrow_mut()), -// [ -// ("editor2", Event::Edited), -// ("editor1", Event::BufferEdited), -// ("editor2", Event::BufferEdited), -// ] -// ); - -// // Redoing on editor 2 will emit an `Edited` event only for that editor. -// editor2.update(cx, |editor, cx| editor.redo(&Redo, cx)); -// assert_eq!( -// mem::take(&mut *events.borrow_mut()), -// [ -// ("editor2", Event::Edited), -// ("editor1", Event::BufferEdited), -// ("editor2", Event::BufferEdited), -// ] -// ); - -// // No event is emitted when the mutation is a no-op. -// editor2.update(cx, |editor, cx| { -// editor.change_selections(None, cx, |s| s.select_ranges([0..0])); - -// editor.backspace(&Backspace, cx); -// }); -// assert_eq!(mem::take(&mut *events.borrow_mut()), []); -// } +#[gpui::test] +fn test_edit_events(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let buffer = cx.build_model(|cx| { + let mut buffer = language::Buffer::new(0, cx.entity_id().as_u64(), "123456"); + buffer.set_group_interval(Duration::from_secs(1)); + buffer + }); + + let events = Rc::new(RefCell::new(Vec::new())); + let editor1 = cx.add_window({ + let events = events.clone(); + |cx| { + let view = cx.view().clone(); + cx.subscribe(&view, move |_, _, event: &EditorEvent, _| { + if matches!(event, EditorEvent::Edited | EditorEvent::BufferEdited) { + events.borrow_mut().push(("editor1", event.clone())); + } + }) + .detach(); + Editor::for_buffer(buffer.clone(), None, cx) + } + }); + + let editor2 = cx.add_window({ + let events = events.clone(); + |cx| { + cx.subscribe(&cx.view().clone(), move |_, _, event: &EditorEvent, _| { + if matches!(event, EditorEvent::Edited | EditorEvent::BufferEdited) { + events.borrow_mut().push(("editor2", event.clone())); + } + }) + .detach(); + Editor::for_buffer(buffer.clone(), None, cx) + } + }); + + assert_eq!(mem::take(&mut *events.borrow_mut()), []); + + // Mutating editor 1 will emit an `Edited` event only for that editor. + editor1.update(cx, |editor, cx| editor.insert("X", cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", EditorEvent::Edited), + ("editor1", EditorEvent::BufferEdited), + ("editor2", EditorEvent::BufferEdited), + ] + ); + + // Mutating editor 2 will emit an `Edited` event only for that editor. + editor2.update(cx, |editor, cx| editor.delete(&Delete, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", EditorEvent::Edited), + ("editor1", EditorEvent::BufferEdited), + ("editor2", EditorEvent::BufferEdited), + ] + ); + + // Undoing on editor 1 will emit an `Edited` event only for that editor. + editor1.update(cx, |editor, cx| editor.undo(&Undo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", EditorEvent::Edited), + ("editor1", EditorEvent::BufferEdited), + ("editor2", EditorEvent::BufferEdited), + ] + ); + + // Redoing on editor 1 will emit an `Edited` event only for that editor. + editor1.update(cx, |editor, cx| editor.redo(&Redo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor1", EditorEvent::Edited), + ("editor1", EditorEvent::BufferEdited), + ("editor2", EditorEvent::BufferEdited), + ] + ); + + // Undoing on editor 2 will emit an `Edited` event only for that editor. + editor2.update(cx, |editor, cx| editor.undo(&Undo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", EditorEvent::Edited), + ("editor1", EditorEvent::BufferEdited), + ("editor2", EditorEvent::BufferEdited), + ] + ); + + // Redoing on editor 2 will emit an `Edited` event only for that editor. + editor2.update(cx, |editor, cx| editor.redo(&Redo, cx)); + assert_eq!( + mem::take(&mut *events.borrow_mut()), + [ + ("editor2", EditorEvent::Edited), + ("editor1", EditorEvent::BufferEdited), + ("editor2", EditorEvent::BufferEdited), + ] + ); + + // No event is emitted when the mutation is a no-op. + editor2.update(cx, |editor, cx| { + editor.change_selections(None, cx, |s| s.select_ranges([0..0])); + + editor.backspace(&Backspace, cx); + }); + assert_eq!(mem::take(&mut *events.borrow_mut()), []); +} #[gpui::test] fn test_undo_redo_with_selection_restoration(cx: &mut TestAppContext) { @@ -515,123 +514,123 @@ fn test_clone(cx: &mut TestAppContext) { } //todo!(editor navigate) -// #[gpui::test] -// async fn test_navigation_history(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// use workspace::item::Item; - -// let fs = FakeFs::new(cx.executor()); -// let project = Project::test(fs, [], cx).await; -// let workspace = cx.add_window(|cx| Workspace::test_new(project, cx)); -// let pane = workspace -// .update(cx, |workspace, _| workspace.active_pane().clone()) -// .unwrap(); - -// workspace.update(cx, |v, cx| { -// cx.build_view(|cx| { -// let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); -// let mut editor = build_editor(buffer.clone(), cx); -// let handle = cx.view(); -// editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); - -// fn pop_history(editor: &mut Editor, cx: &mut WindowContext) -> Option { -// editor.nav_history.as_mut().unwrap().pop_backward(cx) -// } - -// // Move the cursor a small distance. -// // Nothing is added to the navigation history. -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) -// }); -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]) -// }); -// assert!(pop_history(&mut editor, cx).is_none()); - -// // Move the cursor a large distance. -// // The history can jump back to the previous position. -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)]) -// }); -// let nav_entry = pop_history(&mut editor, cx).unwrap(); -// editor.navigate(nav_entry.data.unwrap(), cx); -// assert_eq!(nav_entry.item.id(), cx.entity_id()); -// assert_eq!( -// editor.selections.display_ranges(cx), -// &[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)] -// ); -// assert!(pop_history(&mut editor, cx).is_none()); - -// // Move the cursor a small distance via the mouse. -// // Nothing is added to the navigation history. -// editor.begin_selection(DisplayPoint::new(5, 0), false, 1, cx); -// editor.end_selection(cx); -// assert_eq!( -// editor.selections.display_ranges(cx), -// &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] -// ); -// assert!(pop_history(&mut editor, cx).is_none()); - -// // Move the cursor a large distance via the mouse. -// // The history can jump back to the previous position. -// editor.begin_selection(DisplayPoint::new(15, 0), false, 1, cx); -// editor.end_selection(cx); -// assert_eq!( -// editor.selections.display_ranges(cx), -// &[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)] -// ); -// let nav_entry = pop_history(&mut editor, cx).unwrap(); -// editor.navigate(nav_entry.data.unwrap(), cx); -// assert_eq!(nav_entry.item.id(), cx.entity_id()); -// assert_eq!( -// editor.selections.display_ranges(cx), -// &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] -// ); -// assert!(pop_history(&mut editor, cx).is_none()); - -// // Set scroll position to check later -// editor.set_scroll_position(gpui::Point::::new(5.5, 5.5), cx); -// let original_scroll_position = editor.scroll_manager.anchor(); - -// // Jump to the end of the document and adjust scroll -// editor.move_to_end(&MoveToEnd, cx); -// editor.set_scroll_position(gpui::Point::::new(-2.5, -0.5), cx); -// assert_ne!(editor.scroll_manager.anchor(), original_scroll_position); - -// let nav_entry = pop_history(&mut editor, cx).unwrap(); -// editor.navigate(nav_entry.data.unwrap(), cx); -// assert_eq!(editor.scroll_manager.anchor(), original_scroll_position); - -// // Ensure we don't panic when navigation data contains invalid anchors *and* points. -// let mut invalid_anchor = editor.scroll_manager.anchor().anchor; -// invalid_anchor.text_anchor.buffer_id = Some(999); -// let invalid_point = Point::new(9999, 0); -// editor.navigate( -// Box::new(NavigationData { -// cursor_anchor: invalid_anchor, -// cursor_position: invalid_point, -// scroll_anchor: ScrollAnchor { -// anchor: invalid_anchor, -// offset: Default::default(), -// }, -// scroll_top_row: invalid_point.row, -// }), -// cx, -// ); -// assert_eq!( -// editor.selections.display_ranges(cx), -// &[editor.max_point(cx)..editor.max_point(cx)] -// ); -// assert_eq!( -// editor.scroll_position(cx), -// gpui::Point::new(0., editor.max_point(cx).row() as f32) -// ); - -// editor -// }) -// }); -// } +#[gpui::test] +async fn test_navigation_history(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + use workspace::item::Item; + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project, cx)); + let pane = workspace + .update(cx, |workspace, _| workspace.active_pane().clone()) + .unwrap(); + + workspace.update(cx, |v, cx| { + cx.build_view(|cx| { + let buffer = MultiBuffer::build_simple(&sample_text(300, 5, 'a'), cx); + let mut editor = build_editor(buffer.clone(), cx); + let handle = cx.view(); + editor.set_nav_history(Some(pane.read(cx).nav_history_for_item(&handle))); + + fn pop_history(editor: &mut Editor, cx: &mut WindowContext) -> Option { + editor.nav_history.as_mut().unwrap().pop_backward(cx) + } + + // Move the cursor a small distance. + // Nothing is added to the navigation history. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(1, 0)..DisplayPoint::new(1, 0)]) + }); + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)]) + }); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a large distance. + // The history can jump back to the previous position. + editor.change_selections(None, cx, |s| { + s.select_display_ranges([DisplayPoint::new(13, 0)..DisplayPoint::new(13, 3)]) + }); + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), cx); + assert_eq!(nav_entry.item.id(), cx.entity_id()); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(3, 0)..DisplayPoint::new(3, 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a small distance via the mouse. + // Nothing is added to the navigation history. + editor.begin_selection(DisplayPoint::new(5, 0), false, 1, cx); + editor.end_selection(cx); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Move the cursor a large distance via the mouse. + // The history can jump back to the previous position. + editor.begin_selection(DisplayPoint::new(15, 0), false, 1, cx); + editor.end_selection(cx); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(15, 0)..DisplayPoint::new(15, 0)] + ); + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), cx); + assert_eq!(nav_entry.item.id(), cx.entity_id()); + assert_eq!( + editor.selections.display_ranges(cx), + &[DisplayPoint::new(5, 0)..DisplayPoint::new(5, 0)] + ); + assert!(pop_history(&mut editor, cx).is_none()); + + // Set scroll position to check later + editor.set_scroll_position(gpui::Point::::new(5.5, 5.5), cx); + let original_scroll_position = editor.scroll_manager.anchor(); + + // Jump to the end of the document and adjust scroll + editor.move_to_end(&MoveToEnd, cx); + editor.set_scroll_position(gpui::Point::::new(-2.5, -0.5), cx); + assert_ne!(editor.scroll_manager.anchor(), original_scroll_position); + + let nav_entry = pop_history(&mut editor, cx).unwrap(); + editor.navigate(nav_entry.data.unwrap(), cx); + assert_eq!(editor.scroll_manager.anchor(), original_scroll_position); + + // Ensure we don't panic when navigation data contains invalid anchors *and* points. + let mut invalid_anchor = editor.scroll_manager.anchor().anchor; + invalid_anchor.text_anchor.buffer_id = Some(999); + let invalid_point = Point::new(9999, 0); + editor.navigate( + Box::new(NavigationData { + cursor_anchor: invalid_anchor, + cursor_position: invalid_point, + scroll_anchor: ScrollAnchor { + anchor: invalid_anchor, + offset: Default::default(), + }, + scroll_top_row: invalid_point.row, + }), + cx, + ); + assert_eq!( + editor.selections.display_ranges(cx), + &[editor.max_point(cx)..editor.max_point(cx)] + ); + assert_eq!( + editor.scroll_position(cx), + gpui::Point::new(0., editor.max_point(cx).row() as f32) + ); + + editor + }) + }); +} #[gpui::test] fn test_cancel(cx: &mut TestAppContext) { @@ -959,55 +958,55 @@ fn test_move_cursor_multibyte(cx: &mut TestAppContext) { } //todo!(finish editor tests) -// #[gpui::test] -// fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// let view = cx.add_window(|cx| { -// let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); -// build_editor(buffer.clone(), cx) -// }); -// view.update(cx, |view, cx| { -// view.change_selections(None, cx, |s| { -// s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]); -// }); -// view.move_down(&MoveDown, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[empty_range(1, "abcd".len())] -// ); - -// view.move_down(&MoveDown, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[empty_range(2, "αβγ".len())] -// ); - -// view.move_down(&MoveDown, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[empty_range(3, "abcd".len())] -// ); - -// view.move_down(&MoveDown, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())] -// ); - -// view.move_up(&MoveUp, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[empty_range(3, "abcd".len())] -// ); - -// view.move_up(&MoveUp, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[empty_range(2, "αβγ".len())] -// ); -// }); -// } +#[gpui::test] +fn test_move_cursor_different_line_lengths(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let view = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("ⓐⓑⓒⓓⓔ\nabcd\nαβγ\nabcd\nⓐⓑⓒⓓⓔ\n", cx); + build_editor(buffer.clone(), cx) + }); + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([empty_range(0, "ⓐⓑⓒⓓⓔ".len())]); + }); + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(1, "abcd".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβγ".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(3, "abcd".len())] + ); + + view.move_down(&MoveDown, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(4, "ⓐⓑⓒⓓⓔ".len())] + ); + + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(3, "abcd".len())] + ); + + view.move_up(&MoveUp, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[empty_range(2, "αβγ".len())] + ); + }); +} #[gpui::test] fn test_beginning_end_of_line(cx: &mut TestAppContext) { @@ -1225,532 +1224,551 @@ fn test_prev_next_word_boundary(cx: &mut TestAppContext) { } //todo!(finish editor tests) -// #[gpui::test] -// fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// let view = cx.add_window(|cx| { -// let buffer = MultiBuffer::build_simple("use one::{\n two::three::four::five\n};", cx); -// build_editor(buffer, cx) -// }); - -// view.update(cx, |view, cx| { -// view.set_wrap_width(Some(140.0.into()), cx); -// assert_eq!( -// view.display_text(cx), -// "use one::{\n two::three::\n four::five\n};" -// ); - -// view.change_selections(None, cx, |s| { -// s.select_display_ranges([DisplayPoint::new(1, 7)..DisplayPoint::new(1, 7)]); -// }); - -// view.move_to_next_word_end(&MoveToNextWordEnd, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[DisplayPoint::new(1, 9)..DisplayPoint::new(1, 9)] -// ); - -// view.move_to_next_word_end(&MoveToNextWordEnd, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] -// ); - -// view.move_to_next_word_end(&MoveToNextWordEnd, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] -// ); - -// view.move_to_next_word_end(&MoveToNextWordEnd, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[DisplayPoint::new(2, 8)..DisplayPoint::new(2, 8)] -// ); - -// view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] -// ); - -// view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); -// assert_eq!( -// view.selections.display_ranges(cx), -// &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] -// ); -// }); -// } - -//todo!(simulate_resize) -// #[gpui::test] -// async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); -// let mut cx = EditorTestContext::new(cx).await; - -// let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache())); -// let window = cx.window; -// window.simulate_resize(gpui::Point::new(100., 4. * line_height), &mut cx); - -// cx.set_state( -// &r#"ˇone -// two - -// three -// fourˇ -// five - -// six"# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx)); -// cx.assert_editor_state( -// &r#"one -// two -// ˇ -// three -// four -// five -// ˇ -// six"# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx)); -// cx.assert_editor_state( -// &r#"one -// two - -// three -// four -// five -// ˇ -// sixˇ"# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx)); -// cx.assert_editor_state( -// &r#"one -// two - -// three -// four -// five - -// sixˇ"# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx)); -// cx.assert_editor_state( -// &r#"one -// two - -// three -// four -// five -// ˇ -// six"# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx)); -// cx.assert_editor_state( -// &r#"one -// two -// ˇ -// three -// four -// five - -// six"# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx)); -// cx.assert_editor_state( -// &r#"ˇone -// two - -// three -// four -// five - -// six"# -// .unindent(), -// ); -// } - -// #[gpui::test] -// async fn test_scroll_page_up_page_down(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); -// let mut cx = EditorTestContext::new(cx).await; -// let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache())); -// let window = cx.window; -// window.simulate_resize(Point::new(1000., 4. * line_height + 0.5), &mut cx); - -// cx.set_state( -// &r#"ˇone -// two -// three -// four -// five -// six -// seven -// eight -// nine -// ten -// "#, -// ); - -// cx.update_editor(|editor, cx| { -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 0.) -// ); -// editor.scroll_screen(&ScrollAmount::Page(1.), cx); -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 3.) -// ); -// editor.scroll_screen(&ScrollAmount::Page(1.), cx); -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 6.) -// ); -// editor.scroll_screen(&ScrollAmount::Page(-1.), cx); -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 3.) -// ); - -// editor.scroll_screen(&ScrollAmount::Page(-0.5), cx); -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 1.) -// ); -// editor.scroll_screen(&ScrollAmount::Page(0.5), cx); -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 3.) -// ); -// }); -// } - -// #[gpui::test] -// async fn test_autoscroll(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); -// let mut cx = EditorTestContext::new(cx).await; - -// let line_height = cx.update_editor(|editor, cx| { -// editor.set_vertical_scroll_margin(2, cx); -// editor.style(cx).text.line_height(cx.font_cache()) -// }); - -// let window = cx.window; -// window.simulate_resize(gpui::Point::new(1000., 6.0 * line_height), &mut cx); - -// cx.set_state( -// &r#"ˇone -// two -// three -// four -// five -// six -// seven -// eight -// nine -// ten -// "#, -// ); -// cx.update_editor(|editor, cx| { -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 0.0) -// ); -// }); - -// // Add a cursor below the visible area. Since both cursors cannot fit -// // on screen, the editor autoscrolls to reveal the newest cursor, and -// // allows the vertical scroll margin below that cursor. -// cx.update_editor(|editor, cx| { -// editor.change_selections(Some(Autoscroll::fit()), cx, |selections| { -// selections.select_ranges([ -// Point::new(0, 0)..Point::new(0, 0), -// Point::new(6, 0)..Point::new(6, 0), -// ]); -// }) -// }); -// cx.update_editor(|editor, cx| { -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 3.0) -// ); -// }); - -// // Move down. The editor cursor scrolls down to track the newest cursor. -// cx.update_editor(|editor, cx| { -// editor.move_down(&Default::default(), cx); -// }); -// cx.update_editor(|editor, cx| { -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 4.0) -// ); -// }); - -// // Add a cursor above the visible area. Since both cursors fit on screen, -// // the editor scrolls to show both. -// cx.update_editor(|editor, cx| { -// editor.change_selections(Some(Autoscroll::fit()), cx, |selections| { -// selections.select_ranges([ -// Point::new(1, 0)..Point::new(1, 0), -// Point::new(6, 0)..Point::new(6, 0), -// ]); -// }) -// }); -// cx.update_editor(|editor, cx| { -// assert_eq!( -// editor.snapshot(cx).scroll_position(), -// gpui::Point::new(0., 1.0) -// ); -// }); -// } - -// #[gpui::test] -// async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); -// let mut cx = EditorTestContext::new(cx).await; - -// let line_height = cx.editor(|editor, cx| editor.style(cx).text.line_height(cx.font_cache())); -// let window = cx.window; -// window.simulate_resize(gpui::Point::new(100., 4. * line_height), &mut cx); - -// cx.set_state( -// &r#" -// ˇone -// two -// threeˇ -// four -// five -// six -// seven -// eight -// nine -// ten -// "# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx)); -// cx.assert_editor_state( -// &r#" -// one -// two -// three -// ˇfour -// five -// sixˇ -// seven -// eight -// nine -// ten -// "# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx)); -// cx.assert_editor_state( -// &r#" -// one -// two -// three -// four -// five -// six -// ˇseven -// eight -// nineˇ -// ten -// "# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx)); -// cx.assert_editor_state( -// &r#" -// one -// two -// three -// ˇfour -// five -// sixˇ -// seven -// eight -// nine -// ten -// "# -// .unindent(), -// ); - -// cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx)); -// cx.assert_editor_state( -// &r#" -// ˇone -// two -// threeˇ -// four -// five -// six -// seven -// eight -// nine -// ten -// "# -// .unindent(), -// ); - -// // Test select collapsing -// cx.update_editor(|editor, cx| { -// editor.move_page_down(&MovePageDown::default(), cx); -// editor.move_page_down(&MovePageDown::default(), cx); -// editor.move_page_down(&MovePageDown::default(), cx); -// }); -// cx.assert_editor_state( -// &r#" -// one -// two -// three -// four -// five -// six -// seven -// eight -// nine -// ˇten -// ˇ"# -// .unindent(), -// ); -// } - -#[gpui::test] -async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - let mut cx = EditorTestContext::new(cx).await; - cx.set_state("one «two threeˇ» four"); - cx.update_editor(|editor, cx| { - editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx); - assert_eq!(editor.text(cx), " four"); - }); -} - #[gpui::test] -fn test_delete_to_word_boundary(cx: &mut TestAppContext) { +fn test_prev_next_word_bounds_with_soft_wrap(cx: &mut TestAppContext) { init_test(cx, |_| {}); let view = cx.add_window(|cx| { - let buffer = MultiBuffer::build_simple("one two three four", cx); - build_editor(buffer.clone(), cx) + let buffer = MultiBuffer::build_simple("use one::{\n two::three::four::five\n};", cx); + build_editor(buffer, cx) }); view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // an empty selection - the preceding word fragment is deleted - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - // characters selected - they are deleted - DisplayPoint::new(0, 9)..DisplayPoint::new(0, 12), - ]) - }); - view.delete_to_previous_word_start(&DeleteToPreviousWordStart, cx); - assert_eq!(view.buffer.read(cx).read(cx).text(), "e two te four"); - }); + view.set_wrap_width(Some(140.0.into()), cx); + assert_eq!( + view.display_text(cx), + "use one::{\n two::three::\n four::five\n};" + ); - view.update(cx, |view, cx| { view.change_selections(None, cx, |s| { - s.select_display_ranges([ - // an empty selection - the following word fragment is deleted - DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), - // characters selected - they are deleted - DisplayPoint::new(0, 9)..DisplayPoint::new(0, 10), - ]) + s.select_display_ranges([DisplayPoint::new(1, 7)..DisplayPoint::new(1, 7)]); }); - view.delete_to_next_word_end(&DeleteToNextWordEnd, cx); - assert_eq!(view.buffer.read(cx).read(cx).text(), "e t te our"); - }); -} -#[gpui::test] -fn test_newline(cx: &mut TestAppContext) { - init_test(cx, |_| {}); + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 9)..DisplayPoint::new(1, 9)] + ); - let view = cx.add_window(|cx| { - let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); - build_editor(buffer.clone(), cx) - }); + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] + ); - view.update(cx, |view, cx| { - view.change_selections(None, cx, |s| { - s.select_display_ranges([ - DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), - DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), - DisplayPoint::new(1, 6)..DisplayPoint::new(1, 6), - ]) - }); + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] + ); - view.newline(&Newline, cx); - assert_eq!(view.text(cx), "aa\naa\n \n bb\n bb\n"); + view.move_to_next_word_end(&MoveToNextWordEnd, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(2, 8)..DisplayPoint::new(2, 8)] + ); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(2, 4)..DisplayPoint::new(2, 4)] + ); + + view.move_to_previous_word_start(&MoveToPreviousWordStart, cx); + assert_eq!( + view.selections.display_ranges(cx), + &[DisplayPoint::new(1, 14)..DisplayPoint::new(1, 14)] + ); }); } +//todo!(simulate_resize) #[gpui::test] -fn test_newline_with_old_selections(cx: &mut TestAppContext) { +async fn test_move_start_of_paragraph_end_of_paragraph(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; - let editor = cx.add_window(|cx| { - let buffer = MultiBuffer::build_simple( - " - a - b( - X - ) - c( - X - ) - " - .unindent() - .as_str(), - cx, - ); - let mut editor = build_editor(buffer.clone(), cx); - editor.change_selections(None, cx, |s| { - s.select_ranges([ - Point::new(2, 4)..Point::new(2, 5), - Point::new(5, 4)..Point::new(5, 5), - ]) - }); + let line_height = cx.editor(|editor, cx| { editor + .style() + .unwrap() + .text + .line_height_in_pixels(cx.rem_size()) }); + cx.simulate_window_resize(cx.window, size(px(100.), 4. * line_height)); - editor.update(cx, |editor, cx| { - // Edit the buffer directly, deleting ranges surrounding the editor's selections - editor.buffer.update(cx, |buffer, cx| { - buffer.edit( - [ - (Point::new(1, 2)..Point::new(3, 0), ""), - (Point::new(4, 2)..Point::new(6, 0), ""), - ], - None, - cx, - ); - assert_eq!( - buffer.read(cx).text(), - " - a - b() + cx.set_state( + &r#"ˇone + two + + three + fourˇ + five + + six"# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx)); + cx.assert_editor_state( + &r#"one + two + ˇ + three + four + five + ˇ + six"# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx)); + cx.assert_editor_state( + &r#"one + two + + three + four + five + ˇ + sixˇ"# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_to_end_of_paragraph(&MoveToEndOfParagraph, cx)); + cx.assert_editor_state( + &r#"one + two + + three + four + five + + sixˇ"# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx)); + cx.assert_editor_state( + &r#"one + two + + three + four + five + ˇ + six"# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx)); + cx.assert_editor_state( + &r#"one + two + ˇ + three + four + five + + six"# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_to_start_of_paragraph(&MoveToStartOfParagraph, cx)); + cx.assert_editor_state( + &r#"ˇone + two + + three + four + five + + six"# + .unindent(), + ); +} + +#[gpui::test] +async fn test_scroll_page_up_page_down(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + let line_height = cx.editor(|editor, cx| { + editor + .style() + .unwrap() + .text + .line_height_in_pixels(cx.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 4. * line_height + px(0.5))); + + cx.set_state( + &r#"ˇone + two + three + four + five + six + seven + eight + nine + ten + "#, + ); + + cx.update_editor(|editor, cx| { + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 0.) + ); + editor.scroll_screen(&ScrollAmount::Page(1.), cx); + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 3.) + ); + editor.scroll_screen(&ScrollAmount::Page(1.), cx); + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 6.) + ); + editor.scroll_screen(&ScrollAmount::Page(-1.), cx); + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 3.) + ); + + editor.scroll_screen(&ScrollAmount::Page(-0.5), cx); + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 1.) + ); + editor.scroll_screen(&ScrollAmount::Page(0.5), cx); + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 3.) + ); + }); +} + +#[gpui::test] +async fn test_autoscroll(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.update_editor(|editor, cx| { + editor.set_vertical_scroll_margin(2, cx); + editor + .style() + .unwrap() + .text + .line_height_in_pixels(cx.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(1000.), 6. * line_height)); + + cx.set_state( + &r#"ˇone + two + three + four + five + six + seven + eight + nine + ten + "#, + ); + cx.update_editor(|editor, cx| { + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 0.0) + ); + }); + + // Add a cursor below the visible area. Since both cursors cannot fit + // on screen, the editor autoscrolls to reveal the newest cursor, and + // allows the vertical scroll margin below that cursor. + cx.update_editor(|editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |selections| { + selections.select_ranges([ + Point::new(0, 0)..Point::new(0, 0), + Point::new(6, 0)..Point::new(6, 0), + ]); + }) + }); + cx.update_editor(|editor, cx| { + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 3.0) + ); + }); + + // Move down. The editor cursor scrolls down to track the newest cursor. + cx.update_editor(|editor, cx| { + editor.move_down(&Default::default(), cx); + }); + cx.update_editor(|editor, cx| { + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 4.0) + ); + }); + + // Add a cursor above the visible area. Since both cursors fit on screen, + // the editor scrolls to show both. + cx.update_editor(|editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |selections| { + selections.select_ranges([ + Point::new(1, 0)..Point::new(1, 0), + Point::new(6, 0)..Point::new(6, 0), + ]); + }) + }); + cx.update_editor(|editor, cx| { + assert_eq!( + editor.snapshot(cx).scroll_position(), + gpui::Point::new(0., 1.0) + ); + }); +} + +#[gpui::test] +async fn test_move_page_up_page_down(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let line_height = cx.editor(|editor, cx| { + editor + .style() + .unwrap() + .text + .line_height_in_pixels(cx.rem_size()) + }); + let window = cx.window; + cx.simulate_window_resize(window, size(px(100.), 4. * line_height)); + cx.set_state( + &r#" + ˇone + two + threeˇ + four + five + six + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx)); + cx.assert_editor_state( + &r#" + one + two + three + ˇfour + five + sixˇ + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_down(&MovePageDown::default(), cx)); + cx.assert_editor_state( + &r#" + one + two + three + four + five + six + ˇseven + eight + nineˇ + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx)); + cx.assert_editor_state( + &r#" + one + two + three + ˇfour + five + sixˇ + seven + eight + nine + ten + "# + .unindent(), + ); + + cx.update_editor(|editor, cx| editor.move_page_up(&MovePageUp::default(), cx)); + cx.assert_editor_state( + &r#" + ˇone + two + threeˇ + four + five + six + seven + eight + nine + ten + "# + .unindent(), + ); + + // Test select collapsing + cx.update_editor(|editor, cx| { + editor.move_page_down(&MovePageDown::default(), cx); + editor.move_page_down(&MovePageDown::default(), cx); + editor.move_page_down(&MovePageDown::default(), cx); + }); + cx.assert_editor_state( + &r#" + one + two + three + four + five + six + seven + eight + nine + ˇten + ˇ"# + .unindent(), + ); +} + +#[gpui::test] +async fn test_delete_to_beginning_of_line(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + cx.set_state("one «two threeˇ» four"); + cx.update_editor(|editor, cx| { + editor.delete_to_beginning_of_line(&DeleteToBeginningOfLine, cx); + assert_eq!(editor.text(cx), " four"); + }); +} + +#[gpui::test] +fn test_delete_to_word_boundary(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let view = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("one two three four", cx); + build_editor(buffer.clone(), cx) + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + // an empty selection - the preceding word fragment is deleted + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + // characters selected - they are deleted + DisplayPoint::new(0, 9)..DisplayPoint::new(0, 12), + ]) + }); + view.delete_to_previous_word_start(&DeleteToPreviousWordStart, cx); + assert_eq!(view.buffer.read(cx).read(cx).text(), "e two te four"); + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + // an empty selection - the following word fragment is deleted + DisplayPoint::new(0, 3)..DisplayPoint::new(0, 3), + // characters selected - they are deleted + DisplayPoint::new(0, 9)..DisplayPoint::new(0, 10), + ]) + }); + view.delete_to_next_word_end(&DeleteToNextWordEnd, cx); + assert_eq!(view.buffer.read(cx).read(cx).text(), "e t te our"); + }); +} + +#[gpui::test] +fn test_newline(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let view = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("aaaa\n bbbb\n", cx); + build_editor(buffer.clone(), cx) + }); + + view.update(cx, |view, cx| { + view.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(0, 2)..DisplayPoint::new(0, 2), + DisplayPoint::new(1, 2)..DisplayPoint::new(1, 2), + DisplayPoint::new(1, 6)..DisplayPoint::new(1, 6), + ]) + }); + + view.newline(&Newline, cx); + assert_eq!(view.text(cx), "aa\naa\n \n bb\n bb\n"); + }); +} + +#[gpui::test] +fn test_newline_with_old_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple( + " + a + b( + X + ) + c( + X + ) + " + .unindent() + .as_str(), + cx, + ); + let mut editor = build_editor(buffer.clone(), cx); + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(2, 4)..Point::new(2, 5), + Point::new(5, 4)..Point::new(5, 5), + ]) + }); + editor + }); + + editor.update(cx, |editor, cx| { + // Edit the buffer directly, deleting ranges surrounding the editor's selections + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 2)..Point::new(3, 0), ""), + (Point::new(4, 2)..Point::new(6, 0), ""), + ], + None, + cx, + ); + assert_eq!( + buffer.read(cx).text(), + " + a + b() c() " .unindent() @@ -2493,136 +2511,136 @@ fn test_delete_line(cx: &mut TestAppContext) { } //todo!(select_anchor_ranges) -// #[gpui::test] -// fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// cx.add_window(|cx| { -// let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); -// let mut editor = build_editor(buffer.clone(), cx); -// let buffer = buffer.read(cx).as_singleton().unwrap(); - -// assert_eq!( -// editor.selections.ranges::(cx), -// &[Point::new(0, 0)..Point::new(0, 0)] -// ); - -// // When on single line, replace newline at end by space -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); -// assert_eq!( -// editor.selections.ranges::(cx), -// &[Point::new(0, 3)..Point::new(0, 3)] -// ); - -// // When multiple lines are selected, remove newlines that are spanned by the selection -// editor.change_selections(None, cx, |s| { -// s.select_ranges([Point::new(0, 5)..Point::new(2, 2)]) -// }); -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); -// assert_eq!( -// editor.selections.ranges::(cx), -// &[Point::new(0, 11)..Point::new(0, 11)] -// ); - -// // Undo should be transactional -// editor.undo(&Undo, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); -// assert_eq!( -// editor.selections.ranges::(cx), -// &[Point::new(0, 5)..Point::new(2, 2)] -// ); - -// // When joining an empty line don't insert a space -// editor.change_selections(None, cx, |s| { -// s.select_ranges([Point::new(2, 1)..Point::new(2, 2)]) -// }); -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); -// assert_eq!( -// editor.selections.ranges::(cx), -// [Point::new(2, 3)..Point::new(2, 3)] -// ); - -// // We can remove trailing newlines -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); -// assert_eq!( -// editor.selections.ranges::(cx), -// [Point::new(2, 3)..Point::new(2, 3)] -// ); - -// // We don't blow up on the last line -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); -// assert_eq!( -// editor.selections.ranges::(cx), -// [Point::new(2, 3)..Point::new(2, 3)] -// ); - -// // reset to test indentation -// editor.buffer.update(cx, |buffer, cx| { -// buffer.edit( -// [ -// (Point::new(1, 0)..Point::new(1, 2), " "), -// (Point::new(2, 0)..Point::new(2, 3), " \n\td"), -// ], -// None, -// cx, -// ) -// }); - -// // We remove any leading spaces -// assert_eq!(buffer.read(cx).text(), "aaa bbb\n c\n \n\td"); -// editor.change_selections(None, cx, |s| { -// s.select_ranges([Point::new(0, 1)..Point::new(0, 1)]) -// }); -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb c\n \n\td"); - -// // We don't insert a space for a line containing only spaces -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb c\n\td"); - -// // We ignore any leading tabs -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb c d"); - -// editor -// }); -// } - -// #[gpui::test] -// fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// cx.add_window(|cx| { -// let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); -// let mut editor = build_editor(buffer.clone(), cx); -// let buffer = buffer.read(cx).as_singleton().unwrap(); - -// editor.change_selections(None, cx, |s| { -// s.select_ranges([ -// Point::new(0, 2)..Point::new(1, 1), -// Point::new(1, 2)..Point::new(1, 2), -// Point::new(3, 1)..Point::new(3, 2), -// ]) -// }); - -// editor.join_lines(&JoinLines, cx); -// assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); - -// assert_eq!( -// editor.selections.ranges::(cx), -// [ -// Point::new(0, 7)..Point::new(0, 7), -// Point::new(1, 3)..Point::new(1, 3) -// ] -// ); -// editor -// }); -// } +#[gpui::test] +fn test_join_lines_with_single_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); + let mut editor = build_editor(buffer.clone(), cx); + let buffer = buffer.read(cx).as_singleton().unwrap(); + + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 0)..Point::new(0, 0)] + ); + + // When on single line, replace newline at end by space + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 3)..Point::new(0, 3)] + ); + + // When multiple lines are selected, remove newlines that are spanned by the selection + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(0, 5)..Point::new(2, 2)]) + }); + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb ccc ddd\n\n"); + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 11)..Point::new(0, 11)] + ); + + // Undo should be transactional + editor.undo(&Undo, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n\n"); + assert_eq!( + editor.selections.ranges::(cx), + &[Point::new(0, 5)..Point::new(2, 2)] + ); + + // When joining an empty line don't insert a space + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(2, 1)..Point::new(2, 2)]) + }); + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd\n"); + assert_eq!( + editor.selections.ranges::(cx), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // We can remove trailing newlines + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); + assert_eq!( + editor.selections.ranges::(cx), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // We don't blow up on the last line + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb\nccc\nddd"); + assert_eq!( + editor.selections.ranges::(cx), + [Point::new(2, 3)..Point::new(2, 3)] + ); + + // reset to test indentation + editor.buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(1, 0)..Point::new(1, 2), " "), + (Point::new(2, 0)..Point::new(2, 3), " \n\td"), + ], + None, + cx, + ) + }); + + // We remove any leading spaces + assert_eq!(buffer.read(cx).text(), "aaa bbb\n c\n \n\td"); + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(0, 1)..Point::new(0, 1)]) + }); + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c\n \n\td"); + + // We don't insert a space for a line containing only spaces + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c\n\td"); + + // We ignore any leading tabs + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb c d"); + + editor + }); +} + +#[gpui::test] +fn test_join_lines_with_multi_selection(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("aaa\nbbb\nccc\nddd\n\n", cx); + let mut editor = build_editor(buffer.clone(), cx); + let buffer = buffer.read(cx).as_singleton().unwrap(); + + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(0, 2)..Point::new(1, 1), + Point::new(1, 2)..Point::new(1, 2), + Point::new(3, 1)..Point::new(3, 2), + ]) + }); + + editor.join_lines(&JoinLines, cx); + assert_eq!(buffer.read(cx).text(), "aaa bbb ccc\nddd\n"); + + assert_eq!( + editor.selections.ranges::(cx), + [ + Point::new(0, 7)..Point::new(0, 7), + Point::new(1, 3)..Point::new(1, 3) + ] + ); + editor + }); +} #[gpui::test] async fn test_manipulate_lines_with_single_selection(cx: &mut TestAppContext) { @@ -3053,304 +3071,308 @@ fn test_move_line_up_down_with_blocks(cx: &mut TestAppContext) { Some(Autoscroll::fit()), cx, ); - editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) - }); - editor.move_line_down(&MoveLineDown, cx); - }); -} + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(2, 0)..Point::new(2, 0)]) + }); + editor.move_line_down(&MoveLineDown, cx); + }); +} + +//todo!(test_transpose) +#[gpui::test] +fn test_transpose(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + _ = cx.add_window(|cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), cx); + editor.set_style(EditorStyle::default(), cx); + editor.change_selections(None, cx, |s| s.select_ranges([1..1])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bac"); + assert_eq!(editor.selections.ranges(cx), [2..2]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bca"); + assert_eq!(editor.selections.ranges(cx), [3..3]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bac"); + assert_eq!(editor.selections.ranges(cx), [3..3]); + + editor + }); + + _ = cx.add_window(|cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); + editor.set_style(EditorStyle::default(), cx); + editor.change_selections(None, cx, |s| s.select_ranges([3..3])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acb\nde"); + assert_eq!(editor.selections.ranges(cx), [3..3]); + + editor.change_selections(None, cx, |s| s.select_ranges([4..4])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acbd\ne"); + assert_eq!(editor.selections.ranges(cx), [5..5]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acbde\n"); + assert_eq!(editor.selections.ranges(cx), [6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "acbd\ne"); + assert_eq!(editor.selections.ranges(cx), [6..6]); + + editor + }); + + _ = cx.add_window(|cx| { + let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); + editor.set_style(EditorStyle::default(), cx); + editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2, 4..4])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bacd\ne"); + assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcade\n"); + assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcda\ne"); + assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcade\n"); + assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "bcaed\n"); + assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]); + + editor + }); + + _ = cx.add_window(|cx| { + let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), cx); + editor.set_style(EditorStyle::default(), cx); + editor.change_selections(None, cx, |s| s.select_ranges([4..4])); + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "🏀🍐✋"); + assert_eq!(editor.selections.ranges(cx), [8..8]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "🏀✋🍐"); + assert_eq!(editor.selections.ranges(cx), [11..11]); + + editor.transpose(&Default::default(), cx); + assert_eq!(editor.text(cx), "🏀🍐✋"); + assert_eq!(editor.selections.ranges(cx), [11..11]); + + editor + }); +} + +#[gpui::test] +async fn test_clipboard(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + + cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state("ˇtwo ˇfour ˇsix "); + + // Paste with three cursors. Each cursor pastes one slice of the clipboard text. + cx.set_state("two ˇfour ˇsix ˇ"); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); + + // Paste again but with only two cursors. Since the number of cursors doesn't + // match the number of slices in the clipboard, the entire clipboard text + // is pasted at each cursor. + cx.set_state("ˇtwo one✅ four three six five ˇ"); + cx.update_editor(|e, cx| { + e.handle_input("( ", cx); + e.paste(&Paste, cx); + e.handle_input(") ", cx); + }); + cx.assert_editor_state( + &([ + "( one✅ ", + "three ", + "five ) ˇtwo one✅ four three six five ( one✅ ", + "three ", + "five ) ˇ", + ] + .join("\n")), + ); + + // Cut with three selections, one of which is full-line. + cx.set_state(indoc! {" + 1«2ˇ»3 + 4ˇ567 + «8ˇ»9"}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + 1ˇ3 + ˇ9"}); + + // Paste with three selections, noticing how the copied selection that was full-line + // gets inserted before the second cursor. + cx.set_state(indoc! {" + 1ˇ3 + 9ˇ + «oˇ»ne"}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + 12ˇ3 + 4567 + 9ˇ + 8ˇne"}); + + // Copy with a single cursor only, which writes the whole line into the clipboard. + cx.set_state(indoc! {" + The quick brown + fox juˇmps over + the lazy dog"}); + cx.update_editor(|e, cx| e.copy(&Copy, cx)); + assert_eq!( + cx.test_platform + .read_from_clipboard() + .map(|item| item.text().to_owned()), + Some("fox jumps over\n".to_owned()) + ); + + // Paste with three selections, noticing how the copied full-line selection is inserted + // before the empty selections but replaces the selection that is non-empty. + cx.set_state(indoc! {" + Tˇhe quick brown + «foˇ»x jumps over + tˇhe lazy dog"}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + fox jumps over + Tˇhe quick brown + fox jumps over + ˇx jumps over + fox jumps over + tˇhe lazy dog"}); +} + +#[gpui::test] +async fn test_paste_multiline(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorTestContext::new(cx).await; + let language = Arc::new(Language::new( + LanguageConfig::default(), + Some(tree_sitter_rust::language()), + )); + cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); + + // Cut an indented block, without the leading whitespace. + cx.set_state(indoc! {" + const a: B = ( + c(), + «d( + e, + f + )ˇ» + ); + "}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + ˇ + ); + "}); + + // Paste it at the same position. + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f + )ˇ + ); + "}); -//todo!(test_transpose) -// #[gpui::test] -// fn test_transpose(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); + // Paste it at a line with a lower indent level. + cx.set_state(indoc! {" + ˇ + const a: B = ( + c(), + ); + "}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + d( + e, + f + )ˇ + const a: B = ( + c(), + ); + "}); -// _ = cx.add_window(|cx| { -// let mut editor = build_editor(MultiBuffer::build_simple("abc", cx), cx); + // Cut an indented block, with the leading whitespace. + cx.set_state(indoc! {" + const a: B = ( + c(), + « d( + e, + f + ) + ˇ»); + "}); + cx.update_editor(|e, cx| e.cut(&Cut, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + ˇ); + "}); -// editor.change_selections(None, cx, |s| s.select_ranges([1..1])); -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bac"); -// assert_eq!(editor.selections.ranges(cx), [2..2]); + // Paste it at the same position. + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f + ) + ˇ); + "}); -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bca"); -// assert_eq!(editor.selections.ranges(cx), [3..3]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bac"); -// assert_eq!(editor.selections.ranges(cx), [3..3]); - -// editor -// }); - -// _ = cx.add_window(|cx| { -// let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); - -// editor.change_selections(None, cx, |s| s.select_ranges([3..3])); -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "acb\nde"); -// assert_eq!(editor.selections.ranges(cx), [3..3]); - -// editor.change_selections(None, cx, |s| s.select_ranges([4..4])); -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "acbd\ne"); -// assert_eq!(editor.selections.ranges(cx), [5..5]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "acbde\n"); -// assert_eq!(editor.selections.ranges(cx), [6..6]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "acbd\ne"); -// assert_eq!(editor.selections.ranges(cx), [6..6]); - -// editor -// }); - -// _ = cx.add_window(|cx| { -// let mut editor = build_editor(MultiBuffer::build_simple("abc\nde", cx), cx); - -// editor.change_selections(None, cx, |s| s.select_ranges([1..1, 2..2, 4..4])); -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bacd\ne"); -// assert_eq!(editor.selections.ranges(cx), [2..2, 3..3, 5..5]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bcade\n"); -// assert_eq!(editor.selections.ranges(cx), [3..3, 4..4, 6..6]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bcda\ne"); -// assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bcade\n"); -// assert_eq!(editor.selections.ranges(cx), [4..4, 6..6]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "bcaed\n"); -// assert_eq!(editor.selections.ranges(cx), [5..5, 6..6]); - -// editor -// }); - -// _ = cx.add_window(|cx| { -// let mut editor = build_editor(MultiBuffer::build_simple("🍐🏀✋", cx), cx); - -// editor.change_selections(None, cx, |s| s.select_ranges([4..4])); -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "🏀🍐✋"); -// assert_eq!(editor.selections.ranges(cx), [8..8]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "🏀✋🍐"); -// assert_eq!(editor.selections.ranges(cx), [11..11]); - -// editor.transpose(&Default::default(), cx); -// assert_eq!(editor.text(cx), "🏀🍐✋"); -// assert_eq!(editor.selections.ranges(cx), [11..11]); - -// editor -// }); -// } - -//todo!(clipboard) -// #[gpui::test] -// async fn test_clipboard(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let mut cx = EditorTestContext::new(cx).await; - -// cx.set_state("«one✅ ˇ»two «three ˇ»four «five ˇ»six "); -// cx.update_editor(|e, cx| e.cut(&Cut, cx)); -// cx.assert_editor_state("ˇtwo ˇfour ˇsix "); - -// // Paste with three cursors. Each cursor pastes one slice of the clipboard text. -// cx.set_state("two ˇfour ˇsix ˇ"); -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state("two one✅ ˇfour three ˇsix five ˇ"); - -// // Paste again but with only two cursors. Since the number of cursors doesn't -// // match the number of slices in the clipboard, the entire clipboard text -// // is pasted at each cursor. -// cx.set_state("ˇtwo one✅ four three six five ˇ"); -// cx.update_editor(|e, cx| { -// e.handle_input("( ", cx); -// e.paste(&Paste, cx); -// e.handle_input(") ", cx); -// }); -// cx.assert_editor_state( -// &([ -// "( one✅ ", -// "three ", -// "five ) ˇtwo one✅ four three six five ( one✅ ", -// "three ", -// "five ) ˇ", -// ] -// .join("\n")), -// ); - -// // Cut with three selections, one of which is full-line. -// cx.set_state(indoc! {" -// 1«2ˇ»3 -// 4ˇ567 -// «8ˇ»9"}); -// cx.update_editor(|e, cx| e.cut(&Cut, cx)); -// cx.assert_editor_state(indoc! {" -// 1ˇ3 -// ˇ9"}); - -// // Paste with three selections, noticing how the copied selection that was full-line -// // gets inserted before the second cursor. -// cx.set_state(indoc! {" -// 1ˇ3 -// 9ˇ -// «oˇ»ne"}); -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state(indoc! {" -// 12ˇ3 -// 4567 -// 9ˇ -// 8ˇne"}); - -// // Copy with a single cursor only, which writes the whole line into the clipboard. -// cx.set_state(indoc! {" -// The quick brown -// fox juˇmps over -// the lazy dog"}); -// cx.update_editor(|e, cx| e.copy(&Copy, cx)); -// cx.cx.assert_clipboard_content(Some("fox jumps over\n")); - -// // Paste with three selections, noticing how the copied full-line selection is inserted -// // before the empty selections but replaces the selection that is non-empty. -// cx.set_state(indoc! {" -// Tˇhe quick brown -// «foˇ»x jumps over -// tˇhe lazy dog"}); -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state(indoc! {" -// fox jumps over -// Tˇhe quick brown -// fox jumps over -// ˇx jumps over -// fox jumps over -// tˇhe lazy dog"}); -// } - -// #[gpui::test] -// async fn test_paste_multiline(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let mut cx = EditorTestContext::new(cx).await; -// let language = Arc::new(Language::new( -// LanguageConfig::default(), -// Some(tree_sitter_rust::language()), -// )); -// cx.update_buffer(|buffer, cx| buffer.set_language(Some(language), cx)); - -// // Cut an indented block, without the leading whitespace. -// cx.set_state(indoc! {" -// const a: B = ( -// c(), -// «d( -// e, -// f -// )ˇ» -// ); -// "}); -// cx.update_editor(|e, cx| e.cut(&Cut, cx)); -// cx.assert_editor_state(indoc! {" -// const a: B = ( -// c(), -// ˇ -// ); -// "}); - -// // Paste it at the same position. -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state(indoc! {" -// const a: B = ( -// c(), -// d( -// e, -// f -// )ˇ -// ); -// "}); - -// // Paste it at a line with a lower indent level. -// cx.set_state(indoc! {" -// ˇ -// const a: B = ( -// c(), -// ); -// "}); -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state(indoc! {" -// d( -// e, -// f -// )ˇ -// const a: B = ( -// c(), -// ); -// "}); - -// // Cut an indented block, with the leading whitespace. -// cx.set_state(indoc! {" -// const a: B = ( -// c(), -// « d( -// e, -// f -// ) -// ˇ»); -// "}); -// cx.update_editor(|e, cx| e.cut(&Cut, cx)); -// cx.assert_editor_state(indoc! {" -// const a: B = ( -// c(), -// ˇ); -// "}); - -// // Paste it at the same position. -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state(indoc! {" -// const a: B = ( -// c(), -// d( -// e, -// f -// ) -// ˇ); -// "}); - -// // Paste it at a line with a higher indent level. -// cx.set_state(indoc! {" -// const a: B = ( -// c(), -// d( -// e, -// fˇ -// ) -// ); -// "}); -// cx.update_editor(|e, cx| e.paste(&Paste, cx)); -// cx.assert_editor_state(indoc! {" -// const a: B = ( -// c(), -// d( -// e, -// f d( -// e, -// f -// ) -// ˇ -// ) -// ); -// "}); -// } + // Paste it at a line with a higher indent level. + cx.set_state(indoc! {" + const a: B = ( + c(), + d( + e, + fˇ + ) + ); + "}); + cx.update_editor(|e, cx| e.paste(&Paste, cx)); + cx.assert_editor_state(indoc! {" + const a: B = ( + c(), + d( + e, + f d( + e, + f + ) + ˇ + ) + ); + "}); +} #[gpui::test] fn test_select_all(cx: &mut TestAppContext) { @@ -4809,114 +4831,113 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) { } // todo!(select_anchor_ranges) -// #[gpui::test] -// async fn test_snippets(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let (text, insertion_ranges) = marked_text_ranges( -// indoc! {" -// a.ˇ b -// a.ˇ b -// a.ˇ b -// "}, -// false, -// ); - -// let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); -// let (editor, mut cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); -// let cx = &mut cx; - -// editor.update(cx, |editor, cx| { -// let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); - -// editor -// .insert_snippet(&insertion_ranges, snippet, cx) -// .unwrap(); - -// fn assert(editor: &mut Editor, cx: &mut ViewContext, marked_text: &str) { -// let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); -// assert_eq!(editor.text(cx), expected_text); -// assert_eq!(editor.selections.ranges::(cx), selection_ranges); -// } - -// assert( -// editor, -// cx, -// indoc! {" -// a.f(«one», two, «three») b -// a.f(«one», two, «three») b -// a.f(«one», two, «three») b -// "}, -// ); - -// // Can't move earlier than the first tab stop -// assert!(!editor.move_to_prev_snippet_tabstop(cx)); -// assert( -// editor, -// cx, -// indoc! {" -// a.f(«one», two, «three») b -// a.f(«one», two, «three») b -// a.f(«one», two, «three») b -// "}, -// ); - -// assert!(editor.move_to_next_snippet_tabstop(cx)); -// assert( -// editor, -// cx, -// indoc! {" -// a.f(one, «two», three) b -// a.f(one, «two», three) b -// a.f(one, «two», three) b -// "}, -// ); - -// editor.move_to_prev_snippet_tabstop(cx); -// assert( -// editor, -// cx, -// indoc! {" -// a.f(«one», two, «three») b -// a.f(«one», two, «three») b -// a.f(«one», two, «three») b -// "}, -// ); - -// assert!(editor.move_to_next_snippet_tabstop(cx)); -// assert( -// editor, -// cx, -// indoc! {" -// a.f(one, «two», three) b -// a.f(one, «two», three) b -// a.f(one, «two», three) b -// "}, -// ); -// assert!(editor.move_to_next_snippet_tabstop(cx)); -// assert( -// editor, -// cx, -// indoc! {" -// a.f(one, two, three)ˇ b -// a.f(one, two, three)ˇ b -// a.f(one, two, three)ˇ b -// "}, -// ); - -// // As soon as the last tab stop is reached, snippet state is gone -// editor.move_to_prev_snippet_tabstop(cx); -// assert( -// editor, -// cx, -// indoc! {" -// a.f(one, two, three)ˇ b -// a.f(one, two, three)ˇ b -// a.f(one, two, three)ˇ b -// "}, -// ); -// }); -// } +#[gpui::test] +async fn test_snippets(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + a.ˇ b + a.ˇ b + a.ˇ b + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, mut cx) = cx.add_window_view(|cx| build_editor(buffer, cx)); + + editor.update(cx, |editor, cx| { + let snippet = Snippet::parse("f(${1:one}, ${2:two}, ${1:three})$0").unwrap(); + + editor + .insert_snippet(&insertion_ranges, snippet, cx) + .unwrap(); + + fn assert(editor: &mut Editor, cx: &mut ViewContext, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!(editor.selections.ranges::(cx), selection_ranges); + } + + assert( + editor, + cx, + indoc! {" + a.f(«one», two, «three») b + a.f(«one», two, «three») b + a.f(«one», two, «three») b + "}, + ); + + // Can't move earlier than the first tab stop + assert!(!editor.move_to_prev_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(«one», two, «three») b + a.f(«one», two, «three») b + a.f(«one», two, «three») b + "}, + ); + + assert!(editor.move_to_next_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(one, «two», three) b + a.f(one, «two», three) b + a.f(one, «two», three) b + "}, + ); + + editor.move_to_prev_snippet_tabstop(cx); + assert( + editor, + cx, + indoc! {" + a.f(«one», two, «three») b + a.f(«one», two, «three») b + a.f(«one», two, «three») b + "}, + ); + + assert!(editor.move_to_next_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(one, «two», three) b + a.f(one, «two», three) b + a.f(one, «two», three) b + "}, + ); + assert!(editor.move_to_next_snippet_tabstop(cx)); + assert( + editor, + cx, + indoc! {" + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + "}, + ); + + // As soon as the last tab stop is reached, snippet state is gone + editor.move_to_prev_snippet_tabstop(cx); + assert( + editor, + cx, + indoc! {" + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + a.f(one, two, three)ˇ b + "}, + ); + }); +} #[gpui::test] async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) { @@ -6321,423 +6342,424 @@ async fn test_extra_newline_insertion(cx: &mut gpui::TestAppContext) { "{{} \n", // "}\n", // ) - ); - }); -} + ); + }); +} + +#[gpui::test] +fn test_highlighted_ranges(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let editor = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); + build_editor(buffer.clone(), cx) + }); + + editor.update(cx, |editor, cx| { + struct Type1; + struct Type2; + + let buffer = editor.buffer.read(cx).snapshot(cx); + + let anchor_range = + |range: Range| buffer.anchor_after(range.start)..buffer.anchor_after(range.end); + + editor.highlight_background::( + vec![ + anchor_range(Point::new(2, 1)..Point::new(2, 3)), + anchor_range(Point::new(4, 2)..Point::new(4, 4)), + anchor_range(Point::new(6, 3)..Point::new(6, 5)), + anchor_range(Point::new(8, 4)..Point::new(8, 6)), + ], + |_| Hsla::red(), + cx, + ); + editor.highlight_background::( + vec![ + anchor_range(Point::new(3, 2)..Point::new(3, 5)), + anchor_range(Point::new(5, 3)..Point::new(5, 6)), + anchor_range(Point::new(7, 4)..Point::new(7, 7)), + anchor_range(Point::new(9, 5)..Point::new(9, 8)), + ], + |_| Hsla::green(), + cx, + ); + + let snapshot = editor.snapshot(cx); + let mut highlighted_ranges = editor.background_highlights_in_range( + anchor_range(Point::new(3, 4)..Point::new(7, 4)), + &snapshot, + cx.theme().colors(), + ); + // Enforce a consistent ordering based on color without relying on the ordering of the + // highlight's `TypeId` which is non-executor. + highlighted_ranges.sort_unstable_by_key(|(_, color)| *color); + assert_eq!( + highlighted_ranges, + &[ + ( + DisplayPoint::new(4, 2)..DisplayPoint::new(4, 4), + Hsla::red(), + ), + ( + DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), + Hsla::red(), + ), + ( + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 5), + Hsla::green(), + ), + ( + DisplayPoint::new(5, 3)..DisplayPoint::new(5, 6), + Hsla::green(), + ), + ] + ); + assert_eq!( + editor.background_highlights_in_range( + anchor_range(Point::new(5, 6)..Point::new(6, 4)), + &snapshot, + cx.theme().colors(), + ), + &[( + DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), + Hsla::red(), + )] + ); + }); +} + +// todo!(following) +#[gpui::test] +async fn test_following(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + + let buffer = project.update(cx, |project, cx| { + let buffer = project + .create_buffer(&sample_text(16, 8, 'a'), None, cx) + .unwrap(); + cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)) + }); + let leader = cx.add_window(|cx| build_editor(buffer.clone(), cx)); + let follower = cx.update(|cx| { + cx.open_window( + WindowOptions { + bounds: WindowBounds::Fixed(Bounds::from_corners( + gpui::Point::new((0. as f64).into(), (0. as f64).into()), + gpui::Point::new((10. as f64).into(), (80. as f64).into()), + )), + ..Default::default() + }, + |cx| cx.build_view(|cx| build_editor(buffer.clone(), cx)), + ) + }); + + let is_still_following = Rc::new(RefCell::new(true)); + let follower_edit_event_count = Rc::new(RefCell::new(0)); + let pending_update = Rc::new(RefCell::new(None)); + follower.update(cx, { + let update = pending_update.clone(); + let is_still_following = is_still_following.clone(); + let follower_edit_event_count = follower_edit_event_count.clone(); + |_, cx| { + cx.subscribe( + &leader.root_view(cx).unwrap(), + move |_, leader, event, cx| { + leader + .read(cx) + .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx); + }, + ) + .detach(); + + cx.subscribe( + &follower.root_view(cx).unwrap(), + move |_, _, event: &EditorEvent, cx| { + if matches!(event.to_follow_event(), Some(FollowEvent::Unfollow)) { + *is_still_following.borrow_mut() = false; + } + + if let EditorEvent::BufferEdited = event { + *follower_edit_event_count.borrow_mut() += 1; + } + }, + ) + .detach(); + } + }); + + // Update the selections only + leader.update(cx, |leader, cx| { + leader.change_selections(None, cx, |s| s.select_ranges([1..1])); + }); + follower + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) + }) + .unwrap() + .await + .unwrap(); + follower.update(cx, |follower, cx| { + assert_eq!(follower.selections.ranges(cx), vec![1..1]); + }); + assert_eq!(*is_still_following.borrow(), true); + assert_eq!(*follower_edit_event_count.borrow(), 0); + + // Update the scroll position only + leader.update(cx, |leader, cx| { + leader.set_scroll_position(gpui::Point::new(1.5, 3.5), cx); + }); + follower + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) + }) + .unwrap() + .await + .unwrap(); + assert_eq!( + follower + .update(cx, |follower, cx| follower.scroll_position(cx)) + .unwrap(), + gpui::Point::new(1.5, 3.5) + ); + assert_eq!(*is_still_following.borrow(), true); + assert_eq!(*follower_edit_event_count.borrow(), 0); + + // Update the selections and scroll position. The follower's scroll position is updated + // via autoscroll, not via the leader's exact scroll position. + leader.update(cx, |leader, cx| { + leader.change_selections(None, cx, |s| s.select_ranges([0..0])); + leader.request_autoscroll(Autoscroll::newest(), cx); + leader.set_scroll_position(gpui::Point::new(1.5, 3.5), cx); + }); + follower + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) + }) + .unwrap() + .await + .unwrap(); + follower.update(cx, |follower, cx| { + assert_eq!(follower.scroll_position(cx), gpui::Point::new(1.5, 0.0)); + assert_eq!(follower.selections.ranges(cx), vec![0..0]); + }); + assert_eq!(*is_still_following.borrow(), true); + + // Creating a pending selection that precedes another selection + leader.update(cx, |leader, cx| { + leader.change_selections(None, cx, |s| s.select_ranges([1..1])); + leader.begin_selection(DisplayPoint::new(0, 0), true, 1, cx); + }); + follower + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) + }) + .unwrap() + .await + .unwrap(); + follower.update(cx, |follower, cx| { + assert_eq!(follower.selections.ranges(cx), vec![0..0, 1..1]); + }); + assert_eq!(*is_still_following.borrow(), true); + + // Extend the pending selection so that it surrounds another selection + leader.update(cx, |leader, cx| { + leader.extend_selection(DisplayPoint::new(0, 2), 1, cx); + }); + follower + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) + }) + .unwrap() + .await + .unwrap(); + follower.update(cx, |follower, cx| { + assert_eq!(follower.selections.ranges(cx), vec![0..2]); + }); + + // Scrolling locally breaks the follow + follower.update(cx, |follower, cx| { + let top_anchor = follower.buffer().read(cx).read(cx).anchor_after(0); + follower.set_scroll_anchor( + ScrollAnchor { + anchor: top_anchor, + offset: gpui::Point::new(0.0, 0.5), + }, + cx, + ); + }); + assert_eq!(*is_still_following.borrow(), false); +} + +#[gpui::test] +async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let pane = workspace + .update(cx, |workspace, _| workspace.active_pane().clone()) + .unwrap(); + + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + + let leader = pane.update(cx, |_, cx| { + let multibuffer = cx.build_model(|_| MultiBuffer::new(0)); + cx.build_view(|cx| build_editor(multibuffer.clone(), cx)) + }); + + // Start following the editor when it has no excerpts. + let mut state_message = leader.update(cx, |leader, cx| leader.to_state_proto(cx)); + let follower_1 = cx + .update_window(*workspace.deref(), |_, cx| { + Editor::from_state_proto( + pane.clone(), + workspace.root_view(cx).unwrap(), + ViewId { + creator: Default::default(), + id: 0, + }, + &mut state_message, + cx, + ) + }) + .unwrap() + .unwrap() + .await + .unwrap(); + + let update_message = Rc::new(RefCell::new(None)); + follower_1.update(cx, { + let update = update_message.clone(); + |_, cx| { + cx.subscribe(&leader, move |_, leader, event, cx| { + leader + .read(cx) + .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx); + }) + .detach(); + } + }); + + let (buffer_1, buffer_2) = project.update(cx, |project, cx| { + ( + project + .create_buffer("abc\ndef\nghi\njkl\n", None, cx) + .unwrap(), + project + .create_buffer("mno\npqr\nstu\nvwx\n", None, cx) + .unwrap(), + ) + }); + + // Insert some excerpts. + leader.update(cx, |leader, cx| { + leader.buffer.update(cx, |multibuffer, cx| { + let excerpt_ids = multibuffer.push_excerpts( + buffer_1.clone(), + [ + ExcerptRange { + context: 1..6, + primary: None, + }, + ExcerptRange { + context: 12..15, + primary: None, + }, + ExcerptRange { + context: 0..3, + primary: None, + }, + ], + cx, + ); + multibuffer.insert_excerpts_after( + excerpt_ids[0], + buffer_2.clone(), + [ + ExcerptRange { + context: 8..12, + primary: None, + }, + ExcerptRange { + context: 0..6, + primary: None, + }, + ], + cx, + ); + }); + }); + + // Apply the update of adding the excerpts. + follower_1 + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx) + }) + .await + .unwrap(); + assert_eq!( + follower_1.update(cx, |editor, cx| editor.text(cx)), + leader.update(cx, |editor, cx| editor.text(cx)) + ); + update_message.borrow_mut().take(); + + // Start following separately after it already has excerpts. + let mut state_message = leader.update(cx, |leader, cx| leader.to_state_proto(cx)); + let follower_2 = cx + .update_window(*workspace.deref(), |_, cx| { + Editor::from_state_proto( + pane.clone(), + workspace.root_view(cx).unwrap().clone(), + ViewId { + creator: Default::default(), + id: 0, + }, + &mut state_message, + cx, + ) + }) + .unwrap() + .unwrap() + .await + .unwrap(); + assert_eq!( + follower_2.update(cx, |editor, cx| editor.text(cx)), + leader.update(cx, |editor, cx| editor.text(cx)) + ); -//todo!(finish editor tests) -// #[gpui::test] -// fn test_highlighted_ranges(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// let editor = cx.add_window(|cx| { -// let buffer = MultiBuffer::build_simple(&sample_text(16, 8, 'a'), cx); -// build_editor(buffer.clone(), cx) -// }); - -// editor.update(cx, |editor, cx| { -// struct Type1; -// struct Type2; - -// let buffer = editor.buffer.read(cx).snapshot(cx); - -// let anchor_range = -// |range: Range| buffer.anchor_after(range.start)..buffer.anchor_after(range.end); - -// editor.highlight_background::( -// vec![ -// anchor_range(Point::new(2, 1)..Point::new(2, 3)), -// anchor_range(Point::new(4, 2)..Point::new(4, 4)), -// anchor_range(Point::new(6, 3)..Point::new(6, 5)), -// anchor_range(Point::new(8, 4)..Point::new(8, 6)), -// ], -// |_| Hsla::red(), -// cx, -// ); -// editor.highlight_background::( -// vec![ -// anchor_range(Point::new(3, 2)..Point::new(3, 5)), -// anchor_range(Point::new(5, 3)..Point::new(5, 6)), -// anchor_range(Point::new(7, 4)..Point::new(7, 7)), -// anchor_range(Point::new(9, 5)..Point::new(9, 8)), -// ], -// |_| Hsla::green(), -// cx, -// ); - -// let snapshot = editor.snapshot(cx); -// let mut highlighted_ranges = editor.background_highlights_in_range( -// anchor_range(Point::new(3, 4)..Point::new(7, 4)), -// &snapshot, -// cx.theme().colors(), -// ); -// // Enforce a consistent ordering based on color without relying on the ordering of the -// // highlight's `TypeId` which is non-executor. -// highlighted_ranges.sort_unstable_by_key(|(_, color)| *color); -// assert_eq!( -// highlighted_ranges, -// &[ -// ( -// DisplayPoint::new(3, 2)..DisplayPoint::new(3, 5), -// Hsla::green(), -// ), -// ( -// DisplayPoint::new(5, 3)..DisplayPoint::new(5, 6), -// Hsla::green(), -// ), -// ( -// DisplayPoint::new(4, 2)..DisplayPoint::new(4, 4), -// Hsla::red(), -// ), -// ( -// DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), -// Hsla::red(), -// ), -// ] -// ); -// assert_eq!( -// editor.background_highlights_in_range( -// anchor_range(Point::new(5, 6)..Point::new(6, 4)), -// &snapshot, -// cx.theme().colors(), -// ), -// &[( -// DisplayPoint::new(6, 3)..DisplayPoint::new(6, 5), -// Hsla::red(), -// )] -// ); -// }); -// } + // Remove some excerpts. + leader.update(cx, |leader, cx| { + leader.buffer.update(cx, |multibuffer, cx| { + let excerpt_ids = multibuffer.excerpt_ids(); + multibuffer.remove_excerpts([excerpt_ids[1], excerpt_ids[2]], cx); + multibuffer.remove_excerpts([excerpt_ids[0]], cx); + }); + }); -// todo!(following) -// #[gpui::test] -// async fn test_following(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let fs = FakeFs::new(cx.executor()); -// let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; - -// let buffer = project.update(cx, |project, cx| { -// let buffer = project -// .create_buffer(&sample_text(16, 8, 'a'), None, cx) -// .unwrap(); -// cx.build_model(|cx| MultiBuffer::singleton(buffer, cx)) -// }); -// let leader = cx.add_window(|cx| build_editor(buffer.clone(), cx)); -// let follower = cx.update(|cx| { -// cx.open_window( -// WindowOptions { -// bounds: WindowBounds::Fixed(Bounds::from_corners( -// gpui::Point::new((0. as f64).into(), (0. as f64).into()), -// gpui::Point::new((10. as f64).into(), (80. as f64).into()), -// )), -// ..Default::default() -// }, -// |cx| cx.build_view(|cx| build_editor(buffer.clone(), cx)), -// ) -// }); - -// let is_still_following = Rc::new(RefCell::new(true)); -// let follower_edit_event_count = Rc::new(RefCell::new(0)); -// let pending_update = Rc::new(RefCell::new(None)); -// follower.update(cx, { -// let update = pending_update.clone(); -// let is_still_following = is_still_following.clone(); -// let follower_edit_event_count = follower_edit_event_count.clone(); -// |_, cx| { -// cx.subscribe( -// &leader.root_view(cx).unwrap(), -// move |_, leader, event, cx| { -// leader -// .read(cx) -// .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx); -// }, -// ) -// .detach(); - -// cx.subscribe( -// &follower.root_view(cx).unwrap(), -// move |_, _, event: &Event, cx| { -// if matches!(event.to_follow_event(), Some(FollowEvent::Unfollow)) { -// *is_still_following.borrow_mut() = false; -// } - -// if let Event::BufferEdited = event { -// *follower_edit_event_count.borrow_mut() += 1; -// } -// }, -// ) -// .detach(); -// } -// }); - -// // Update the selections only -// leader.update(cx, |leader, cx| { -// leader.change_selections(None, cx, |s| s.select_ranges([1..1])); -// }); -// follower -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) -// }) -// .unwrap() -// .await -// .unwrap(); -// follower.update(cx, |follower, cx| { -// assert_eq!(follower.selections.ranges(cx), vec![1..1]); -// }); -// assert_eq!(*is_still_following.borrow(), true); -// assert_eq!(*follower_edit_event_count.borrow(), 0); - -// // Update the scroll position only -// leader.update(cx, |leader, cx| { -// leader.set_scroll_position(gpui::Point::new(1.5, 3.5), cx); -// }); -// follower -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) -// }) -// .unwrap() -// .await -// .unwrap(); -// assert_eq!( -// follower -// .update(cx, |follower, cx| follower.scroll_position(cx)) -// .unwrap(), -// gpui::Point::new(1.5, 3.5) -// ); -// assert_eq!(*is_still_following.borrow(), true); -// assert_eq!(*follower_edit_event_count.borrow(), 0); - -// // Update the selections and scroll position. The follower's scroll position is updated -// // via autoscroll, not via the leader's exact scroll position. -// leader.update(cx, |leader, cx| { -// leader.change_selections(None, cx, |s| s.select_ranges([0..0])); -// leader.request_autoscroll(Autoscroll::newest(), cx); -// leader.set_scroll_position(gpui::Point::new(1.5, 3.5), cx); -// }); -// follower -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) -// }) -// .unwrap() -// .await -// .unwrap(); -// follower.update(cx, |follower, cx| { -// assert_eq!(follower.scroll_position(cx), gpui::Point::new(1.5, 0.0)); -// assert_eq!(follower.selections.ranges(cx), vec![0..0]); -// }); -// assert_eq!(*is_still_following.borrow(), true); - -// // Creating a pending selection that precedes another selection -// leader.update(cx, |leader, cx| { -// leader.change_selections(None, cx, |s| s.select_ranges([1..1])); -// leader.begin_selection(DisplayPoint::new(0, 0), true, 1, cx); -// }); -// follower -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) -// }) -// .unwrap() -// .await -// .unwrap(); -// follower.update(cx, |follower, cx| { -// assert_eq!(follower.selections.ranges(cx), vec![0..0, 1..1]); -// }); -// assert_eq!(*is_still_following.borrow(), true); - -// // Extend the pending selection so that it surrounds another selection -// leader.update(cx, |leader, cx| { -// leader.extend_selection(DisplayPoint::new(0, 2), 1, cx); -// }); -// follower -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, pending_update.borrow_mut().take().unwrap(), cx) -// }) -// .unwrap() -// .await -// .unwrap(); -// follower.update(cx, |follower, cx| { -// assert_eq!(follower.selections.ranges(cx), vec![0..2]); -// }); - -// // Scrolling locally breaks the follow -// follower.update(cx, |follower, cx| { -// let top_anchor = follower.buffer().read(cx).read(cx).anchor_after(0); -// follower.set_scroll_anchor( -// ScrollAnchor { -// anchor: top_anchor, -// offset: gpui::Point::new(0.0, 0.5), -// }, -// cx, -// ); -// }); -// assert_eq!(*is_still_following.borrow(), false); -// } - -// #[gpui::test] -// async fn test_following_with_multiple_excerpts(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let fs = FakeFs::new(cx.executor()); -// let project = Project::test(fs, ["/file.rs".as_ref()], cx).await; -// let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); -// let pane = workspace -// .update(cx, |workspace, _| workspace.active_pane().clone()) -// .unwrap(); - -// let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - -// let leader = pane.update(cx, |_, cx| { -// let multibuffer = cx.build_model(|_| MultiBuffer::new(0)); -// cx.build_view(|cx| build_editor(multibuffer.clone(), cx)) -// }); - -// // Start following the editor when it has no excerpts. -// let mut state_message = leader.update(cx, |leader, cx| leader.to_state_proto(cx)); -// let follower_1 = cx -// .update(|cx| { -// Editor::from_state_proto( -// pane.clone(), -// workspace.root_view(cx).unwrap(), -// ViewId { -// creator: Default::default(), -// id: 0, -// }, -// &mut state_message, -// cx, -// ) -// }) -// .unwrap() -// .await -// .unwrap(); - -// let update_message = Rc::new(RefCell::new(None)); -// follower_1.update(cx, { -// let update = update_message.clone(); -// |_, cx| { -// cx.subscribe(&leader, move |_, leader, event, cx| { -// leader -// .read(cx) -// .add_event_to_update_proto(event, &mut *update.borrow_mut(), cx); -// }) -// .detach(); -// } -// }); - -// let (buffer_1, buffer_2) = project.update(cx, |project, cx| { -// ( -// project -// .create_buffer("abc\ndef\nghi\njkl\n", None, cx) -// .unwrap(), -// project -// .create_buffer("mno\npqr\nstu\nvwx\n", None, cx) -// .unwrap(), -// ) -// }); - -// // Insert some excerpts. -// leader.update(cx, |leader, cx| { -// leader.buffer.update(cx, |multibuffer, cx| { -// let excerpt_ids = multibuffer.push_excerpts( -// buffer_1.clone(), -// [ -// ExcerptRange { -// context: 1..6, -// primary: None, -// }, -// ExcerptRange { -// context: 12..15, -// primary: None, -// }, -// ExcerptRange { -// context: 0..3, -// primary: None, -// }, -// ], -// cx, -// ); -// multibuffer.insert_excerpts_after( -// excerpt_ids[0], -// buffer_2.clone(), -// [ -// ExcerptRange { -// context: 8..12, -// primary: None, -// }, -// ExcerptRange { -// context: 0..6, -// primary: None, -// }, -// ], -// cx, -// ); -// }); -// }); - -// // Apply the update of adding the excerpts. -// follower_1 -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx) -// }) -// .await -// .unwrap(); -// assert_eq!( -// follower_1.update(cx, |editor, cx| editor.text(cx)), -// leader.update(cx, |editor, cx| editor.text(cx)) -// ); -// update_message.borrow_mut().take(); - -// // Start following separately after it already has excerpts. -// let mut state_message = leader.update(cx, |leader, cx| leader.to_state_proto(cx)); -// let follower_2 = cx -// .update(|cx| { -// Editor::from_state_proto( -// pane.clone(), -// workspace.clone(), -// ViewId { -// creator: Default::default(), -// id: 0, -// }, -// &mut state_message, -// cx, -// ) -// }) -// .unwrap() -// .await -// .unwrap(); -// assert_eq!( -// follower_2.update(cx, |editor, cx| editor.text(cx)), -// leader.update(cx, |editor, cx| editor.text(cx)) -// ); - -// // Remove some excerpts. -// leader.update(cx, |leader, cx| { -// leader.buffer.update(cx, |multibuffer, cx| { -// let excerpt_ids = multibuffer.excerpt_ids(); -// multibuffer.remove_excerpts([excerpt_ids[1], excerpt_ids[2]], cx); -// multibuffer.remove_excerpts([excerpt_ids[0]], cx); -// }); -// }); - -// // Apply the update of removing the excerpts. -// follower_1 -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx) -// }) -// .await -// .unwrap(); -// follower_2 -// .update(cx, |follower, cx| { -// follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx) -// }) -// .await -// .unwrap(); -// update_message.borrow_mut().take(); -// assert_eq!( -// follower_1.update(cx, |editor, cx| editor.text(cx)), -// leader.update(cx, |editor, cx| editor.text(cx)) -// ); -// } + // Apply the update of removing the excerpts. + follower_1 + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx) + }) + .await + .unwrap(); + follower_2 + .update(cx, |follower, cx| { + follower.apply_update_proto(&project, update_message.borrow().clone().unwrap(), cx) + }) + .await + .unwrap(); + update_message.borrow_mut().take(); + assert_eq!( + follower_1.update(cx, |editor, cx| editor.text(cx)), + leader.update(cx, |editor, cx| editor.text(cx)) + ); +} #[gpui::test] async fn go_to_prev_overlapping_diagnostic( @@ -7047,255 +7069,256 @@ async fn test_move_to_enclosing_bracket(cx: &mut gpui::TestAppContext) { } // todo!(completions) -// #[gpui::test(iterations = 10)] -// async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let (copilot, copilot_lsp) = Copilot::fake(cx); -// cx.update(|cx| cx.set_global(copilot)); -// let mut cx = EditorLspTestContext::new_rust( -// lsp::ServerCapabilities { -// completion_provider: Some(lsp::CompletionOptions { -// trigger_characters: Some(vec![".".to_string(), ":".to_string()]), -// ..Default::default() -// }), -// ..Default::default() -// }, -// cx, -// ) -// .await; - -// // When inserting, ensure autocompletion is favored over Copilot suggestions. -// cx.set_state(indoc! {" -// oneˇ -// two -// three -// "}); -// cx.simulate_keystroke("."); -// let _ = handle_completion_request( -// &mut cx, -// indoc! {" -// one.|<> -// two -// three -// "}, -// vec!["completion_a", "completion_b"], -// ); -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: "one.copilot1".into(), -// range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), -// ..Default::default() -// }], -// vec![], -// ); -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// cx.update_editor(|editor, cx| { -// assert!(editor.context_menu_visible()); -// assert!(!editor.has_active_copilot_suggestion(cx)); - -// // Confirming a completion inserts it and hides the context menu, without showing -// // the copilot suggestion afterwards. -// editor -// .confirm_completion(&Default::default(), cx) -// .unwrap() -// .detach(); -// assert!(!editor.context_menu_visible()); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n"); -// assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n"); -// }); - -// // Ensure Copilot suggestions are shown right away if no autocompletion is available. -// cx.set_state(indoc! {" -// oneˇ -// two -// three -// "}); -// cx.simulate_keystroke("."); -// let _ = handle_completion_request( -// &mut cx, -// indoc! {" -// one.|<> -// two -// three -// "}, -// vec![], -// ); -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: "one.copilot1".into(), -// range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), -// ..Default::default() -// }], -// vec![], -// ); -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// cx.update_editor(|editor, cx| { -// assert!(!editor.context_menu_visible()); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); -// }); - -// // Reset editor, and ensure autocompletion is still favored over Copilot suggestions. -// cx.set_state(indoc! {" -// oneˇ -// two -// three -// "}); -// cx.simulate_keystroke("."); -// let _ = handle_completion_request( -// &mut cx, -// indoc! {" -// one.|<> -// two -// three -// "}, -// vec!["completion_a", "completion_b"], -// ); -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: "one.copilot1".into(), -// range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), -// ..Default::default() -// }], -// vec![], -// ); -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// cx.update_editor(|editor, cx| { -// assert!(editor.context_menu_visible()); -// assert!(!editor.has_active_copilot_suggestion(cx)); - -// // When hiding the context menu, the Copilot suggestion becomes visible. -// editor.hide_context_menu(cx); -// assert!(!editor.context_menu_visible()); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); -// }); - -// // Ensure existing completion is interpolated when inserting again. -// cx.simulate_keystroke("c"); -// executor.run_until_parked(); -// cx.update_editor(|editor, cx| { -// assert!(!editor.context_menu_visible()); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); -// }); - -// // After debouncing, new Copilot completions should be requested. -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: "one.copilot2".into(), -// range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), -// ..Default::default() -// }], -// vec![], -// ); -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// cx.update_editor(|editor, cx| { -// assert!(!editor.context_menu_visible()); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); - -// // Canceling should remove the active Copilot suggestion. -// editor.cancel(&Default::default(), cx); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.c\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); - -// // After canceling, tabbing shouldn't insert the previously shown suggestion. -// editor.tab(&Default::default(), cx); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.c \ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.c \ntwo\nthree\n"); - -// // When undoing the previously active suggestion is shown again. -// editor.undo(&Default::default(), cx); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); -// }); - -// // If an edit occurs outside of this editor, the suggestion is still correctly interpolated. -// cx.update_buffer(|buffer, cx| buffer.edit([(5..5, "o")], None, cx)); -// cx.update_editor(|editor, cx| { -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); - -// // Tabbing when there is an active suggestion inserts it. -// editor.tab(&Default::default(), cx); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.copilot2\ntwo\nthree\n"); - -// // When undoing the previously active suggestion is shown again. -// editor.undo(&Default::default(), cx); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); - -// // Hide suggestion. -// editor.cancel(&Default::default(), cx); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.co\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); -// }); - -// // If an edit occurs outside of this editor but no suggestion is being shown, -// // we won't make it visible. -// cx.update_buffer(|buffer, cx| buffer.edit([(6..6, "p")], None, cx)); -// cx.update_editor(|editor, cx| { -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); -// assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); -// }); - -// // Reset the editor to verify how suggestions behave when tabbing on leading indentation. -// cx.update_editor(|editor, cx| { -// editor.set_text("fn foo() {\n \n}", cx); -// editor.change_selections(None, cx, |s| { -// s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) -// }); -// }); -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: " let x = 4;".into(), -// range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), -// ..Default::default() -// }], -// vec![], -// ); - -// cx.update_editor(|editor, cx| editor.next_copilot_suggestion(&Default::default(), cx)); -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// cx.update_editor(|editor, cx| { -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); -// assert_eq!(editor.text(cx), "fn foo() {\n \n}"); - -// // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. -// editor.tab(&Default::default(), cx); -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.text(cx), "fn foo() {\n \n}"); -// assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); - -// // Tabbing again accepts the suggestion. -// editor.tab(&Default::default(), cx); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); -// assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); -// }); -// } +#[gpui::test(iterations = 10)] +async fn test_copilot(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { + // flaky + init_test(cx, |_| {}); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + cx.update(|cx| cx.set_global(copilot)); + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string(), ":".to_string()]), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + // When inserting, ensure autocompletion is favored over Copilot suggestions. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec!["completion_a", "completion_b"], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.context_menu_visible()); + assert!(!editor.has_active_copilot_suggestion(cx)); + + // Confirming a completion inserts it and hides the context menu, without showing + // the copilot suggestion afterwards. + editor + .confirm_completion(&Default::default(), cx) + .unwrap() + .detach(); + assert!(!editor.context_menu_visible()); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.text(cx), "one.completion_a\ntwo\nthree\n"); + assert_eq!(editor.display_text(cx), "one.completion_a\ntwo\nthree\n"); + }); + + // Ensure Copilot suggestions are shown right away if no autocompletion is available. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec![], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); + }); + + // Reset editor, and ensure autocompletion is still favored over Copilot suggestions. + cx.set_state(indoc! {" + oneˇ + two + three + "}); + cx.simulate_keystroke("."); + let _ = handle_completion_request( + &mut cx, + indoc! {" + one.|<> + two + three + "}, + vec!["completion_a", "completion_b"], + ); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot1".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 4)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.context_menu_visible()); + assert!(!editor.has_active_copilot_suggestion(cx)); + + // When hiding the context menu, the Copilot suggestion becomes visible. + editor.hide_context_menu(cx); + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.\ntwo\nthree\n"); + }); + + // Ensure existing completion is interpolated when inserting again. + cx.simulate_keystroke("c"); + executor.run_until_parked(); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot1\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + }); + + // After debouncing, new Copilot completions should be requested. + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "one.copilot2".into(), + range: lsp::Range::new(lsp::Position::new(0, 0), lsp::Position::new(0, 5)), + ..Default::default() + }], + vec![], + ); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(!editor.context_menu_visible()); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + + // Canceling should remove the active Copilot suggestion. + editor.cancel(&Default::default(), cx); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.c\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + + // After canceling, tabbing shouldn't insert the previously shown suggestion. + editor.tab(&Default::default(), cx); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.c \ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c \ntwo\nthree\n"); + + // When undoing the previously active suggestion is shown again. + editor.undo(&Default::default(), cx); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.c\ntwo\nthree\n"); + }); + + // If an edit occurs outside of this editor, the suggestion is still correctly interpolated. + cx.update_buffer(|buffer, cx| buffer.edit([(5..5, "o")], None, cx)); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); + + // Tabbing when there is an active suggestion inserts it. + editor.tab(&Default::default(), cx); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.copilot2\ntwo\nthree\n"); + + // When undoing the previously active suggestion is shown again. + editor.undo(&Default::default(), cx); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.copilot2\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); + + // Hide suggestion. + editor.cancel(&Default::default(), cx); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.co\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.co\ntwo\nthree\n"); + }); + + // If an edit occurs outside of this editor but no suggestion is being shown, + // we won't make it visible. + cx.update_buffer(|buffer, cx| buffer.edit([(6..6, "p")], None, cx)); + cx.update_editor(|editor, cx| { + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "one.cop\ntwo\nthree\n"); + assert_eq!(editor.text(cx), "one.cop\ntwo\nthree\n"); + }); + + // Reset the editor to verify how suggestions behave when tabbing on leading indentation. + cx.update_editor(|editor, cx| { + editor.set_text("fn foo() {\n \n}", cx); + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(1, 2)..Point::new(1, 2)]) + }); + }); + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: " let x = 4;".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 2)), + ..Default::default() + }], + vec![], + ); + + cx.update_editor(|editor, cx| editor.next_copilot_suggestion(&Default::default(), cx)); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + cx.update_editor(|editor, cx| { + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); + assert_eq!(editor.text(cx), "fn foo() {\n \n}"); + + // Tabbing inside of leading whitespace inserts indentation without accepting the suggestion. + editor.tab(&Default::default(), cx); + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.text(cx), "fn foo() {\n \n}"); + assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); + + // Tabbing again accepts the suggestion. + editor.tab(&Default::default(), cx); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!(editor.text(cx), "fn foo() {\n let x = 4;\n}"); + assert_eq!(editor.display_text(cx), "fn foo() {\n let x = 4;\n}"); + }); +} #[gpui::test] async fn test_copilot_completion_invalidation( @@ -7364,106 +7387,105 @@ async fn test_copilot_completion_invalidation( }); } -//todo!() -// #[gpui::test] -// async fn test_copilot_multibuffer(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let (copilot, copilot_lsp) = Copilot::fake(cx); -// cx.update(|cx| cx.set_global(copilot)); - -// let buffer_1 = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "a = 1\nb = 2\n")); -// let buffer_2 = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "c = 3\nd = 4\n")); -// let multibuffer = cx.build_model(|cx| { -// let mut multibuffer = MultiBuffer::new(0); -// multibuffer.push_excerpts( -// buffer_1.clone(), -// [ExcerptRange { -// context: Point::new(0, 0)..Point::new(2, 0), -// primary: None, -// }], -// cx, -// ); -// multibuffer.push_excerpts( -// buffer_2.clone(), -// [ExcerptRange { -// context: Point::new(0, 0)..Point::new(2, 0), -// primary: None, -// }], -// cx, -// ); -// multibuffer -// }); -// let editor = cx.add_window(|cx| build_editor(multibuffer, cx)); - -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: "b = 2 + a".into(), -// range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), -// ..Default::default() -// }], -// vec![], -// ); -// editor.update(cx, |editor, cx| { -// // Ensure copilot suggestions are shown for the first excerpt. -// editor.change_selections(None, cx, |s| { -// s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) -// }); -// editor.next_copilot_suggestion(&Default::default(), cx); -// }); -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// editor.update(cx, |editor, cx| { -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!( -// editor.display_text(cx), -// "\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n" -// ); -// assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); -// }); - -// handle_copilot_completion_request( -// &copilot_lsp, -// vec![copilot::request::Completion { -// text: "d = 4 + c".into(), -// range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), -// ..Default::default() -// }], -// vec![], -// ); -// editor.update(cx, |editor, cx| { -// // Move to another excerpt, ensuring the suggestion gets cleared. -// editor.change_selections(None, cx, |s| { -// s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) -// }); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!( -// editor.display_text(cx), -// "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n" -// ); -// assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); - -// // Type a character, ensuring we don't even try to interpolate the previous suggestion. -// editor.handle_input(" ", cx); -// assert!(!editor.has_active_copilot_suggestion(cx)); -// assert_eq!( -// editor.display_text(cx), -// "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n" -// ); -// assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); -// }); - -// // Ensure the new suggestion is displayed when the debounce timeout expires. -// executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); -// editor.update(cx, |editor, cx| { -// assert!(editor.has_active_copilot_suggestion(cx)); -// assert_eq!( -// editor.display_text(cx), -// "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n" -// ); -// assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); -// }); -// } +#[gpui::test] +async fn test_copilot_multibuffer(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let (copilot, copilot_lsp) = Copilot::fake(cx); + cx.update(|cx| cx.set_global(copilot)); + + let buffer_1 = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "a = 1\nb = 2\n")); + let buffer_2 = cx.build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "c = 3\nd = 4\n")); + let multibuffer = cx.build_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + buffer_1.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(2, 0), + primary: None, + }], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ExcerptRange { + context: Point::new(0, 0)..Point::new(2, 0), + primary: None, + }], + cx, + ); + multibuffer + }); + let editor = cx.add_window(|cx| build_editor(multibuffer, cx)); + + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "b = 2 + a".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 5)), + ..Default::default() + }], + vec![], + ); + editor.update(cx, |editor, cx| { + // Ensure copilot suggestions are shown for the first excerpt. + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(1, 5)..Point::new(1, 5)]) + }); + editor.next_copilot_suggestion(&Default::default(), cx); + }); + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + editor.update(cx, |editor, cx| { + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2 + a\n\n\n\nc = 3\nd = 4\n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); + }); + + handle_copilot_completion_request( + &copilot_lsp, + vec![copilot::request::Completion { + text: "d = 4 + c".into(), + range: lsp::Range::new(lsp::Position::new(1, 0), lsp::Position::new(1, 6)), + ..Default::default() + }], + vec![], + ); + editor.update(cx, |editor, cx| { + // Move to another excerpt, ensuring the suggestion gets cleared. + editor.change_selections(None, cx, |s| { + s.select_ranges([Point::new(4, 5)..Point::new(4, 5)]) + }); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4\n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4\n"); + + // Type a character, ensuring we don't even try to interpolate the previous suggestion. + editor.handle_input(" ", cx); + assert!(!editor.has_active_copilot_suggestion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 \n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); + }); + + // Ensure the new suggestion is displayed when the debounce timeout expires. + executor.advance_clock(COPILOT_DEBOUNCE_TIMEOUT); + editor.update(cx, |editor, cx| { + assert!(editor.has_active_copilot_suggestion(cx)); + assert_eq!( + editor.display_text(cx), + "\n\na = 1\nb = 2\n\n\n\nc = 3\nd = 4 + c\n" + ); + assert_eq!(editor.text(cx), "a = 1\nb = 2\n\nc = 3\nd = 4 \n"); + }); +} #[gpui::test] async fn test_copilot_disabled_globs(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { diff --git a/crates/editor2/src/element.rs b/crates/editor2/src/element.rs index 24402c7e379e112bf941bd85a51e249d95d257e9..d7b9d0bb40498cd8fb6c51f4cd33d5e6489f4ad1 100644 --- a/crates/editor2/src/element.rs +++ b/crates/editor2/src/element.rs @@ -9,9 +9,11 @@ use crate::{ self, hover_at, HOVER_POPOVER_GAP, MIN_POPOVER_CHARACTER_WIDTH, MIN_POPOVER_LINE_HEIGHT, }, link_go_to_definition::{ - go_to_fetched_definition, go_to_fetched_type_definition, update_go_to_definition_link, - update_inlay_link_and_hover_points, GoToDefinitionTrigger, + go_to_fetched_definition, go_to_fetched_type_definition, show_link_definition, + update_go_to_definition_link, update_inlay_link_and_hover_points, GoToDefinitionTrigger, + LinkGoToDefinitionState, }, + mouse_context_menu, scroll::scroll_amount::ScrollAmount, CursorShape, DisplayPoint, Editor, EditorMode, EditorSettings, EditorSnapshot, EditorStyle, HalfPageDown, HalfPageUp, LineDown, LineUp, MoveDown, OpenExcerpts, PageDown, PageUp, Point, @@ -19,14 +21,15 @@ use crate::{ }; use anyhow::Result; use collections::{BTreeMap, HashMap}; +use git::diff::DiffHunkStatus; use gpui::{ - div, point, px, relative, size, transparent_black, Action, AnyElement, AvailableSpace, - BorrowWindow, Bounds, ContentMask, Corners, DispatchPhase, Edges, Element, ElementId, - ElementInputHandler, Entity, EntityId, Hsla, InteractiveBounds, InteractiveElement, - IntoElement, LineLayout, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, - ParentElement, Pixels, RenderOnce, ScrollWheelEvent, ShapedLine, SharedString, Size, - StackingOrder, StatefulInteractiveElement, Style, Styled, TextRun, TextStyle, View, - ViewContext, WeakView, WindowContext, WrappedLine, + div, overlay, point, px, relative, size, transparent_black, Action, AnchorCorner, AnyElement, + AsyncWindowContext, AvailableSpace, BorrowWindow, Bounds, ContentMask, Corners, CursorStyle, + DispatchPhase, Edges, Element, ElementId, ElementInputHandler, Entity, EntityId, Hsla, + InteractiveBounds, InteractiveElement, IntoElement, LineLayout, ModifiersChangedEvent, + MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, RenderOnce, + ScrollWheelEvent, ShapedLine, SharedString, Size, StackingOrder, StatefulInteractiveElement, + Style, Styled, TextRun, TextStyle, View, ViewContext, WeakView, WindowContext, WrappedLine, }; use itertools::Itertools; use language::language_settings::ShowWhitespaceSetting; @@ -48,8 +51,10 @@ use std::{ }; use sum_tree::Bias; use theme::{ActiveTheme, PlayerColor}; -use ui::prelude::*; -use ui::{h_stack, IconButton, Tooltip}; +use ui::{ + h_stack, ButtonLike, ButtonStyle, Disclosure, IconButton, IconElement, IconSize, Label, Tooltip, +}; +use ui::{prelude::*, Icon}; use util::ResultExt; use workspace::item::Item; @@ -139,8 +144,6 @@ impl EditorElement { register_action(view, cx, Editor::move_right); register_action(view, cx, Editor::move_down); register_action(view, cx, Editor::move_up); - // on_action(cx, Editor::new_file); todo!() - // on_action(cx, Editor::new_file_in_direction); todo!() register_action(view, cx, Editor::cancel); register_action(view, cx, Editor::newline); register_action(view, cx, Editor::newline_above); @@ -263,7 +266,7 @@ impl EditorElement { register_action(view, cx, Editor::fold_selected_ranges); register_action(view, cx, Editor::show_completions); register_action(view, cx, Editor::toggle_code_actions); - // on_action(cx, Editor::open_excerpts); todo!() + register_action(view, cx, Editor::open_excerpts); register_action(view, cx, Editor::toggle_soft_wrap); register_action(view, cx, Editor::toggle_inlay_hints); register_action(view, cx, hover_popover::hover); @@ -312,7 +315,57 @@ impl EditorElement { register_action(view, cx, Editor::context_menu_last); } - fn mouse_down( + fn register_key_listeners(&self, cx: &mut WindowContext) { + cx.on_key_event({ + let editor = self.editor.clone(); + move |event: &ModifiersChangedEvent, phase, cx| { + if phase != DispatchPhase::Bubble { + return; + } + + if editor.update(cx, |editor, cx| Self::modifiers_changed(editor, event, cx)) { + cx.stop_propagation(); + } + } + }); + } + + pub(crate) fn modifiers_changed( + editor: &mut Editor, + event: &ModifiersChangedEvent, + cx: &mut ViewContext, + ) -> bool { + let pending_selection = editor.has_pending_selection(); + + if let Some(point) = &editor.link_go_to_definition_state.last_trigger_point { + if event.command && !pending_selection { + let point = point.clone(); + let snapshot = editor.snapshot(cx); + let kind = point.definition_kind(event.shift); + + show_link_definition(kind, editor, point, snapshot, cx); + return false; + } + } + + { + if editor.link_go_to_definition_state.symbol_range.is_some() + || !editor.link_go_to_definition_state.definitions.is_empty() + { + editor.link_go_to_definition_state.symbol_range.take(); + editor.link_go_to_definition_state.definitions.clear(); + cx.notify(); + } + + editor.link_go_to_definition_state.task = None; + + editor.clear_highlights::(cx); + } + + false + } + + fn mouse_left_down( editor: &mut Editor, event: &MouseDownEvent, position_map: &PositionMap, @@ -365,25 +418,25 @@ impl EditorElement { true } - // fn mouse_right_down( - // editor: &mut Editor, - // position: gpui::Point, - // position_map: &PositionMap, - // text_bounds: Bounds, - // cx: &mut EventContext, - // ) -> bool { - // if !text_bounds.contains_point(position) { - // return false; - // } - // let point_for_position = position_map.point_for_position(text_bounds, position); - // mouse_context_menu::deploy_context_menu( - // editor, - // position, - // point_for_position.previous_valid, - // cx, - // ); - // true - // } + fn mouse_right_down( + editor: &mut Editor, + event: &MouseDownEvent, + position_map: &PositionMap, + text_bounds: Bounds, + cx: &mut ViewContext, + ) -> bool { + if !text_bounds.contains_point(&event.position) { + return false; + } + let point_for_position = position_map.point_for_position(text_bounds, event.position); + mouse_context_menu::deploy_context_menu( + editor, + event.position, + point_for_position.previous_valid, + cx, + ); + true + } fn mouse_up( editor: &mut Editor, @@ -725,87 +778,85 @@ impl EditorElement { } fn paint_diff_hunks(bounds: Bounds, layout: &LayoutState, cx: &mut WindowContext) { - // todo!() - // let diff_style = &theme::current(cx).editor.diff.clone(); - // let line_height = layout.position_map.line_height; - - // let scroll_position = layout.position_map.snapshot.scroll_position(); - // let scroll_top = scroll_position.y * line_height; - - // for hunk in &layout.display_hunks { - // let (display_row_range, status) = match hunk { - // //TODO: This rendering is entirely a horrible hack - // &DisplayDiffHunk::Folded { display_row: row } => { - // let start_y = row as f32 * line_height - scroll_top; - // let end_y = start_y + line_height; - - // let width = diff_style.removed_width_em * line_height; - // let highlight_origin = bounds.origin + point(-width, start_y); - // let highlight_size = point(width * 2., end_y - start_y); - // let highlight_bounds = Bounds::::new(highlight_origin, highlight_size); - - // cx.paint_quad(Quad { - // bounds: highlight_bounds, - // background: Some(diff_style.modified), - // border: Border::new(0., Color::transparent_black()).into(), - // corner_radii: (1. * line_height).into(), - // }); - - // continue; - // } - - // DisplayDiffHunk::Unfolded { - // display_row_range, - // status, - // } => (display_row_range, status), - // }; - - // let color = match status { - // DiffHunkStatus::Added => diff_style.inserted, - // DiffHunkStatus::Modified => diff_style.modified, - - // //TODO: This rendering is entirely a horrible hack - // DiffHunkStatus::Removed => { - // let row = display_row_range.start; - - // let offset = line_height / 2.; - // let start_y = row as f32 * line_height - offset - scroll_top; - // let end_y = start_y + line_height; - - // let width = diff_style.removed_width_em * line_height; - // let highlight_origin = bounds.origin + point(-width, start_y); - // let highlight_size = point(width * 2., end_y - start_y); - // let highlight_bounds = Bounds::::new(highlight_origin, highlight_size); - - // cx.paint_quad(Quad { - // bounds: highlight_bounds, - // background: Some(diff_style.deleted), - // border: Border::new(0., Color::transparent_black()).into(), - // corner_radii: (1. * line_height).into(), - // }); - - // continue; - // } - // }; - - // let start_row = display_row_range.start; - // let end_row = display_row_range.end; - - // let start_y = start_row as f32 * line_height - scroll_top; - // let end_y = end_row as f32 * line_height - scroll_top; - - // let width = diff_style.width_em * line_height; - // let highlight_origin = bounds.origin + point(-width, start_y); - // let highlight_size = point(width * 2., end_y - start_y); - // let highlight_bounds = Bounds::::new(highlight_origin, highlight_size); - - // cx.paint_quad(Quad { - // bounds: highlight_bounds, - // background: Some(color), - // border: Border::new(0., Color::transparent_black()).into(), - // corner_radii: (diff_style.corner_radius * line_height).into(), - // }); - // } + let line_height = layout.position_map.line_height; + + let scroll_position = layout.position_map.snapshot.scroll_position(); + let scroll_top = scroll_position.y * line_height; + + for hunk in &layout.display_hunks { + let (display_row_range, status) = match hunk { + //TODO: This rendering is entirely a horrible hack + &DisplayDiffHunk::Folded { display_row: row } => { + let start_y = row as f32 * line_height - scroll_top; + let end_y = start_y + line_height; + + let width = 0.275 * line_height; + let highlight_origin = bounds.origin + point(-width, start_y); + let highlight_size = size(width * 2., end_y - start_y); + let highlight_bounds = Bounds::new(highlight_origin, highlight_size); + cx.paint_quad( + highlight_bounds, + Corners::all(1. * line_height), + gpui::yellow(), // todo!("use the right color") + Edges::default(), + transparent_black(), + ); + + continue; + } + + DisplayDiffHunk::Unfolded { + display_row_range, + status, + } => (display_row_range, status), + }; + + let color = match status { + DiffHunkStatus::Added => gpui::green(), // todo!("use the appropriate color") + DiffHunkStatus::Modified => gpui::yellow(), // todo!("use the appropriate color") + + //TODO: This rendering is entirely a horrible hack + DiffHunkStatus::Removed => { + let row = display_row_range.start; + + let offset = line_height / 2.; + let start_y = row as f32 * line_height - offset - scroll_top; + let end_y = start_y + line_height; + + let width = 0.275 * line_height; + let highlight_origin = bounds.origin + point(-width, start_y); + let highlight_size = size(width * 2., end_y - start_y); + let highlight_bounds = Bounds::new(highlight_origin, highlight_size); + cx.paint_quad( + highlight_bounds, + Corners::all(1. * line_height), + gpui::red(), // todo!("use the right color") + Edges::default(), + transparent_black(), + ); + + continue; + } + }; + + let start_row = display_row_range.start; + let end_row = display_row_range.end; + + let start_y = start_row as f32 * line_height - scroll_top; + let end_y = end_row as f32 * line_height - scroll_top; + + let width = 0.275 * line_height; + let highlight_origin = bounds.origin + point(-width, start_y); + let highlight_size = size(width * 2., end_y - start_y); + let highlight_bounds = Bounds::new(highlight_origin, highlight_size); + cx.paint_quad( + highlight_bounds, + Corners::all(0.05 * line_height), + color, // todo!("use the right color") + Edges::default(), + transparent_black(), + ); + } } fn paint_text( @@ -831,15 +882,19 @@ impl EditorElement { bounds: text_bounds, }), |cx| { - // todo!("cursor region") - // cx.scene().push_cursor_region(CursorRegion { - // bounds, - // style: if !editor.link_go_to_definition_state.definitions.is_empty { - // CursorStyle::PointingHand - // } else { - // CursorStyle::IBeam - // }, - // }); + if text_bounds.contains_point(&cx.mouse_position()) { + if self + .editor + .read(cx) + .link_go_to_definition_state + .definitions + .is_empty() + { + cx.set_cursor_style(CursorStyle::IBeam); + } else { + cx.set_cursor_style(CursorStyle::PointingHand); + } + } let fold_corner_radius = 0.15 * layout.position_map.line_height; cx.with_element_id(Some("folds"), |cx| { @@ -1138,6 +1193,22 @@ impl EditorElement { } } } + + if let Some(mouse_context_menu) = + self.editor.read(cx).mouse_context_menu.as_ref() + { + let element = overlay() + .position(mouse_context_menu.position) + .child(mouse_context_menu.context_menu.clone()) + .anchor(AnchorCorner::TopLeft) + .snap_to_window(); + element.draw( + gpui::Point::default(), + size(AvailableSpace::MinContent, AvailableSpace::MinContent), + cx, + |_, _| {}, + ); + } }) }, ) @@ -1662,11 +1733,6 @@ impl EditorElement { cx: &mut WindowContext, ) -> LayoutState { self.editor.update(cx, |editor, cx| { - // let mut size = constraint.max; - // if size.x.is_infinite() { - // unimplemented!("we don't yet handle an infinite width constraint on buffer elements"); - // } - let snapshot = editor.snapshot(cx); let style = self.style.clone(); @@ -1689,7 +1755,7 @@ impl EditorElement { let gutter_width; let gutter_margin; if snapshot.show_gutter { - let descent = cx.text_system().descent(font_id, font_size).unwrap(); + let descent = cx.text_system().descent(font_id, font_size); let gutter_padding_factor = 3.5; gutter_padding = (em_width * gutter_padding_factor).round(); @@ -1702,6 +1768,7 @@ impl EditorElement { }; editor.gutter_width = gutter_width; + let text_width = bounds.size.width - gutter_width; let overscroll = size(em_width, px(0.)); let snapshot = { @@ -1728,25 +1795,6 @@ impl EditorElement { .collect::>(); let scroll_height = Pixels::from(snapshot.max_point().row() + 1) * line_height; - // todo!("this should happen during layout") - let editor_mode = snapshot.mode; - if let EditorMode::AutoHeight { max_lines } = editor_mode { - todo!() - // size.set_y( - // scroll_height - // .min(constraint.max_along(Axis::Vertical)) - // .max(constraint.min_along(Axis::Vertical)) - // .max(line_height) - // .min(line_height * max_lines as f32), - // ) - } else if let EditorMode::SingleLine = editor_mode { - bounds.size.height = line_height.min(bounds.size.height); - } - // todo!() - // else if size.y.is_infinite() { - // // size.set_y(scroll_height); - // } - // let gutter_size = size(gutter_width, bounds.size.height); let text_size = size(text_width, bounds.size.height); @@ -2064,7 +2112,7 @@ impl EditorElement { .unwrap(); LayoutState { - mode: editor_mode, + mode: snapshot.mode, position_map: Arc::new(PositionMap { size: bounds.size, scroll_position: point( @@ -2177,7 +2225,8 @@ impl EditorElement { .as_ref() .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) .unwrap_or_default(); - let jump_icon = project::File::from_dyn(buffer.file()).map(|file| { + + let jump_handler = project::File::from_dyn(buffer.file()).map(|file| { let jump_path = ProjectPath { worktree_id: file.worktree_id(cx), path: file.path.clone(), @@ -2188,11 +2237,11 @@ impl EditorElement { .map_or(range.context.start, |primary| primary.start); let jump_position = language::ToPoint::to_point(&jump_anchor, buffer); - IconButton::new(block_id, ui::Icon::ArrowUpRight) - .on_click(cx.listener_for(&self.editor, move |editor, e, cx| { - editor.jump(jump_path.clone(), jump_position, jump_anchor, cx); - })) - .tooltip(|cx| Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx)) + let jump_handler = cx.listener_for(&self.editor, move |editor, e, cx| { + editor.jump(jump_path.clone(), jump_position, jump_anchor, cx); + }); + + jump_handler }); let element = if *starts_new_buffer { @@ -2207,25 +2256,108 @@ impl EditorElement { .map(|p| SharedString::from(p.to_string_lossy().to_string() + "/")); } - h_stack() - .id("path header block") - .size_full() - .bg(gpui::red()) - .child( - filename - .map(SharedString::from) - .unwrap_or_else(|| "untitled".into()), - ) - .children(parent_path) - .children(jump_icon) // .p_x(gutter_padding) + let is_open = true; + + div().id("path header container").size_full().p_1p5().child( + h_stack() + .id("path header block") + .py_1p5() + .pl_3() + .pr_2() + .rounded_lg() + .shadow_md() + .border() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().editor_subheader_background) + .justify_between() + .cursor_pointer() + .hover(|style| style.bg(cx.theme().colors().element_hover)) + .on_click(cx.listener(|_editor, _event, _cx| { + // TODO: Implement collapsing path headers + todo!("Clicking path header") + })) + .child( + h_stack() + .gap_3() + // TODO: Add open/close state and toggle action + .child( + div().border().border_color(gpui::red()).child( + ButtonLike::new("path-header-disclosure-control") + .style(ButtonStyle::Subtle) + .child(IconElement::new(match is_open { + true => Icon::ChevronDown, + false => Icon::ChevronRight, + })), + ), + ) + .child( + h_stack() + .gap_2() + .child(Label::new( + filename + .map(SharedString::from) + .unwrap_or_else(|| "untitled".into()), + )) + .when_some(parent_path, |then, path| { + then.child(Label::new(path).color(Color::Muted)) + }), + ), + ) + .children(jump_handler.map(|jump_handler| { + IconButton::new(block_id, Icon::ArrowUpRight) + .style(ButtonStyle::Subtle) + .on_click(jump_handler) + .tooltip(|cx| { + Tooltip::for_action("Jump to Buffer", &OpenExcerpts, cx) + }) + })), // .p_x(gutter_padding) + ) } else { let text_style = style.text.clone(); h_stack() .id("collapsed context") .size_full() - .bg(gpui::red()) - .child("⋯") - .children(jump_icon) // .p_x(gutter_padding) + .gap(gutter_padding) + .child( + h_stack() + .justify_end() + .flex_none() + .w(gutter_width - gutter_padding) + .h_full() + .text_buffer(cx) + .text_color(cx.theme().colors().editor_line_number) + .child("..."), + ) + .map(|this| { + if let Some(jump_handler) = jump_handler { + this.child( + ButtonLike::new("jump to collapsed context") + .style(ButtonStyle::Transparent) + .full_width() + .on_click(jump_handler) + .tooltip(|cx| { + Tooltip::for_action( + "Jump to Buffer", + &OpenExcerpts, + cx, + ) + }) + .child( + div() + .h_px() + .w_full() + .bg(cx.theme().colors().border_variant) + .group_hover("", |style| { + style.bg(cx.theme().colors().border) + }), + ), + ) + } else { + this.child(div().size_full().bg(gpui::green())) + } + }) + // .child("⋯") + // .children(jump_icon) // .p_x(gutter_padding) }; element.into_any() } @@ -2308,10 +2440,10 @@ impl EditorElement { return; } - let should_cancel = editor.update(cx, |editor, cx| { + let handled = editor.update(cx, |editor, cx| { Self::scroll(editor, event, &position_map, &interactive_bounds, cx) }); - if should_cancel { + if handled { cx.stop_propagation(); } } @@ -2327,19 +2459,25 @@ impl EditorElement { return; } - let should_cancel = editor.update(cx, |editor, cx| { - Self::mouse_down( - editor, - event, - &position_map, - text_bounds, - gutter_bounds, - &stacking_order, - cx, - ) - }); + let handled = match event.button { + MouseButton::Left => editor.update(cx, |editor, cx| { + Self::mouse_left_down( + editor, + event, + &position_map, + text_bounds, + gutter_bounds, + &stacking_order, + cx, + ) + }), + MouseButton::Right => editor.update(cx, |editor, cx| { + Self::mouse_right_down(editor, event, &position_map, text_bounds, cx) + }), + _ => false, + }; - if should_cancel { + if handled { cx.stop_propagation() } } @@ -2351,7 +2489,7 @@ impl EditorElement { let stacking_order = cx.stacking_order().clone(); move |event: &MouseUpEvent, phase, cx| { - let should_cancel = editor.update(cx, |editor, cx| { + let handled = editor.update(cx, |editor, cx| { Self::mouse_up( editor, event, @@ -2362,26 +2500,11 @@ impl EditorElement { ) }); - if should_cancel { + if handled { cx.stop_propagation() } } }); - //todo!() - // on_down(MouseButton::Right, { - // let position_map = layout.position_map.clone(); - // move |event, editor, cx| { - // if !Self::mouse_right_down( - // editor, - // event.position, - // position_map.as_ref(), - // text_bounds, - // cx, - // ) { - // cx.propagate_event(); - // } - // } - // }); cx.on_mouse_event({ let position_map = layout.position_map.clone(); let editor = self.editor.clone(); @@ -2617,19 +2740,44 @@ impl Element for EditorElement { cx: &mut gpui::WindowContext, ) -> (gpui::LayoutId, Self::State) { self.editor.update(cx, |editor, cx| { - editor.style = Some(self.style.clone()); // Long-term, we'd like to eliminate this. + editor.set_style(self.style.clone(), cx); - let rem_size = cx.rem_size(); - let mut style = Style::default(); - style.size.width = relative(1.).into(); - style.size.height = match editor.mode { + let layout_id = match editor.mode { EditorMode::SingleLine => { - self.style.text.line_height_in_pixels(cx.rem_size()).into() + let rem_size = cx.rem_size(); + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = self.style.text.line_height_in_pixels(rem_size).into(); + cx.request_layout(&style, None) + } + EditorMode::AutoHeight { max_lines } => { + let editor_handle = cx.view().clone(); + let max_line_number_width = + self.max_line_number_width(&editor.snapshot(cx), cx); + cx.request_measured_layout( + Style::default(), + move |known_dimensions, available_space, cx| { + editor_handle + .update(cx, |editor, cx| { + compute_auto_height_layout( + editor, + max_lines, + max_line_number_width, + known_dimensions, + cx, + ) + }) + .unwrap_or_default() + }, + ) + } + EditorMode::Full => { + let mut style = Style::default(); + style.size.width = relative(1.).into(); + style.size.height = relative(1.).into(); + cx.request_layout(&style, None) } - EditorMode::AutoHeight { .. } => todo!(), - EditorMode::Full => relative(1.).into(), }; - let layout_id = cx.request_layout(&style, None); (layout_id, ()) }) @@ -2657,6 +2805,7 @@ impl Element for EditorElement { let dispatch_context = self.editor.read(cx).dispatch_context(cx); cx.with_key_dispatch(dispatch_context, Some(focus_handle.clone()), |_, cx| { self.register_actions(cx); + self.register_key_listeners(cx); // We call with_z_index to establish a new stacking context. cx.with_z_index(0, |cx| { @@ -2698,604 +2847,6 @@ impl IntoElement for EditorElement { } } -// impl EditorElement { -// type LayoutState = LayoutState; -// type PaintState = (); - -// fn layout( -// &mut self, -// constraint: SizeConstraint, -// editor: &mut Editor, -// cx: &mut ViewContext, -// ) -> (gpui::Point, Self::LayoutState) { -// let mut size = constraint.max; -// if size.x.is_infinite() { -// unimplemented!("we don't yet handle an infinite width constraint on buffer elements"); -// } - -// let snapshot = editor.snapshot(cx); -// let style = self.style.clone(); - -// let line_height = (style.text.font_size * style.line_height_scalar).round(); - -// let gutter_padding; -// let gutter_width; -// let gutter_margin; -// if snapshot.show_gutter { -// let em_width = style.text.em_width(cx.font_cache()); -// gutter_padding = (em_width * style.gutter_padding_factor).round(); -// gutter_width = self.max_line_number_width(&snapshot, cx) + gutter_padding * 2.0; -// gutter_margin = -style.text.descent(cx.font_cache()); -// } else { -// gutter_padding = 0.0; -// gutter_width = 0.0; -// gutter_margin = 0.0; -// }; - -// let text_width = size.x - gutter_width; -// let em_width = style.text.em_width(cx.font_cache()); -// let em_advance = style.text.em_advance(cx.font_cache()); -// let overscroll = point(em_width, 0.); -// let snapshot = { -// editor.set_visible_line_count(size.y / line_height, cx); - -// let editor_width = text_width - gutter_margin - overscroll.x - em_width; -// let wrap_width = match editor.soft_wrap_mode(cx) { -// SoftWrap::None => (MAX_LINE_LEN / 2) as f32 * em_advance, -// SoftWrap::EditorWidth => editor_width, -// SoftWrap::Column(column) => editor_width.min(column as f32 * em_advance), -// }; - -// if editor.set_wrap_width(Some(wrap_width), cx) { -// editor.snapshot(cx) -// } else { -// snapshot -// } -// }; - -// let wrap_guides = editor -// .wrap_guides(cx) -// .iter() -// .map(|(guide, active)| (self.column_pixels(*guide, cx), *active)) -// .collect(); - -// let scroll_height = (snapshot.max_point().row() + 1) as f32 * line_height; -// if let EditorMode::AutoHeight { max_lines } = snapshot.mode { -// size.set_y( -// scroll_height -// .min(constraint.max_along(Axis::Vertical)) -// .max(constraint.min_along(Axis::Vertical)) -// .max(line_height) -// .min(line_height * max_lines as f32), -// ) -// } else if let EditorMode::SingleLine = snapshot.mode { -// size.set_y(line_height.max(constraint.min_along(Axis::Vertical))) -// } else if size.y.is_infinite() { -// size.set_y(scroll_height); -// } -// let gutter_size = point(gutter_width, size.y); -// let text_size = point(text_width, size.y); - -// let autoscroll_horizontally = editor.autoscroll_vertically(size.y, line_height, cx); -// let mut snapshot = editor.snapshot(cx); - -// let scroll_position = snapshot.scroll_position(); -// // The scroll position is a fractional point, the whole number of which represents -// // the top of the window in terms of display rows. -// let start_row = scroll_position.y as u32; -// let height_in_lines = size.y / line_height; -// let max_row = snapshot.max_point().row(); - -// // Add 1 to ensure selections bleed off screen -// let end_row = 1 + cmp::min( -// (scroll_position.y + height_in_lines).ceil() as u32, -// max_row, -// ); - -// let start_anchor = if start_row == 0 { -// Anchor::min() -// } else { -// snapshot -// .buffer_snapshot -// .anchor_before(DisplayPoint::new(start_row, 0).to_offset(&snapshot, Bias::Left)) -// }; -// let end_anchor = if end_row > max_row { -// Anchor::max -// } else { -// snapshot -// .buffer_snapshot -// .anchor_before(DisplayPoint::new(end_row, 0).to_offset(&snapshot, Bias::Right)) -// }; - -// let mut selections: Vec<(SelectionStyle, Vec)> = Vec::new(); -// let mut active_rows = BTreeMap::new(); -// let mut fold_ranges = Vec::new(); -// let is_singleton = editor.is_singleton(cx); - -// let highlighted_rows = editor.highlighted_rows(); -// let theme = theme::current(cx); -// let highlighted_ranges = editor.background_highlights_in_range( -// start_anchor..end_anchor, -// &snapshot.display_snapshot, -// theme.as_ref(), -// ); - -// fold_ranges.extend( -// snapshot -// .folds_in_range(start_anchor..end_anchor) -// .map(|anchor| { -// let start = anchor.start.to_point(&snapshot.buffer_snapshot); -// ( -// start.row, -// start.to_display_point(&snapshot.display_snapshot) -// ..anchor.end.to_display_point(&snapshot), -// ) -// }), -// ); - -// let mut newest_selection_head = None; - -// if editor.show_local_selections { -// let mut local_selections: Vec> = editor -// .selections -// .disjoint_in_range(start_anchor..end_anchor, cx); -// local_selections.extend(editor.selections.pending(cx)); -// let mut layouts = Vec::new(); -// let newest = editor.selections.newest(cx); -// for selection in local_selections.drain(..) { -// let is_empty = selection.start == selection.end; -// let is_newest = selection == newest; - -// let layout = SelectionLayout::new( -// selection, -// editor.selections.line_mode, -// editor.cursor_shape, -// &snapshot.display_snapshot, -// is_newest, -// true, -// ); -// if is_newest { -// newest_selection_head = Some(layout.head); -// } - -// for row in cmp::max(layout.active_rows.start, start_row) -// ..=cmp::min(layout.active_rows.end, end_row) -// { -// let contains_non_empty_selection = active_rows.entry(row).or_insert(!is_empty); -// *contains_non_empty_selection |= !is_empty; -// } -// layouts.push(layout); -// } - -// selections.push((style.selection, layouts)); -// } - -// if let Some(collaboration_hub) = &editor.collaboration_hub { -// // When following someone, render the local selections in their color. -// if let Some(leader_id) = editor.leader_peer_id { -// if let Some(collaborator) = collaboration_hub.collaborators(cx).get(&leader_id) { -// if let Some(participant_index) = collaboration_hub -// .user_participant_indices(cx) -// .get(&collaborator.user_id) -// { -// if let Some((local_selection_style, _)) = selections.first_mut() { -// *local_selection_style = -// style.selection_style_for_room_participant(participant_index.0); -// } -// } -// } -// } - -// let mut remote_selections = HashMap::default(); -// for selection in snapshot.remote_selections_in_range( -// &(start_anchor..end_anchor), -// collaboration_hub.as_ref(), -// cx, -// ) { -// let selection_style = if let Some(participant_index) = selection.participant_index { -// style.selection_style_for_room_participant(participant_index.0) -// } else { -// style.absent_selection -// }; - -// // Don't re-render the leader's selections, since the local selections -// // match theirs. -// if Some(selection.peer_id) == editor.leader_peer_id { -// continue; -// } - -// remote_selections -// .entry(selection.replica_id) -// .or_insert((selection_style, Vec::new())) -// .1 -// .push(SelectionLayout::new( -// selection.selection, -// selection.line_mode, -// selection.cursor_shape, -// &snapshot.display_snapshot, -// false, -// false, -// )); -// } - -// selections.extend(remote_selections.into_values()); -// } - -// let scrollbar_settings = &settings::get::(cx).scrollbar; -// let show_scrollbars = match scrollbar_settings.show { -// ShowScrollbar::Auto => { -// // Git -// (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot.has_git_diffs()) -// || -// // Selections -// (is_singleton && scrollbar_settings.selections && !highlighted_ranges.is_empty) -// // Scrollmanager -// || editor.scroll_manager.scrollbars_visible() -// } -// ShowScrollbar::System => editor.scroll_manager.scrollbars_visible(), -// ShowScrollbar::Always => true, -// ShowScrollbar::Never => false, -// }; - -// let fold_ranges: Vec<(BufferRow, Range, Color)> = fold_ranges -// .into_iter() -// .map(|(id, fold)| { -// let color = self -// .style -// .folds -// .ellipses -// .background -// .style_for(&mut cx.mouse_state::(id as usize)) -// .color; - -// (id, fold, color) -// }) -// .collect(); - -// let head_for_relative = newest_selection_head.unwrap_or_else(|| { -// let newest = editor.selections.newest::(cx); -// SelectionLayout::new( -// newest, -// editor.selections.line_mode, -// editor.cursor_shape, -// &snapshot.display_snapshot, -// true, -// true, -// ) -// .head -// }); - -// let (line_number_layouts, fold_statuses) = self.layout_line_numbers( -// start_row..end_row, -// &active_rows, -// head_for_relative, -// is_singleton, -// &snapshot, -// cx, -// ); - -// let display_hunks = self.layout_git_gutters(start_row..end_row, &snapshot); - -// let scrollbar_row_range = scroll_position.y..(scroll_position.y + height_in_lines); - -// let mut max_visible_line_width = 0.0; -// let line_layouts = -// self.layout_lines(start_row..end_row, &line_number_layouts, &snapshot, cx); -// for line_with_invisibles in &line_layouts { -// if line_with_invisibles.line.width() > max_visible_line_width { -// max_visible_line_width = line_with_invisibles.line.width(); -// } -// } - -// let style = self.style.clone(); -// let longest_line_width = layout_line( -// snapshot.longest_row(), -// &snapshot, -// &style, -// cx.text_layout_cache(), -// ) -// .width(); -// let scroll_width = longest_line_width.max(max_visible_line_width) + overscroll.x; -// let em_width = style.text.em_width(cx.font_cache()); -// let (scroll_width, blocks) = self.layout_blocks( -// start_row..end_row, -// &snapshot, -// size.x, -// scroll_width, -// gutter_padding, -// gutter_width, -// em_width, -// gutter_width + gutter_margin, -// line_height, -// &style, -// &line_layouts, -// editor, -// cx, -// ); - -// let scroll_max = point( -// ((scroll_width - text_size.x) / em_width).max(0.0), -// max_row as f32, -// ); - -// let clamped = editor.scroll_manager.clamp_scroll_left(scroll_max.x); - -// let autoscrolled = if autoscroll_horizontally { -// editor.autoscroll_horizontally( -// start_row, -// text_size.x, -// scroll_width, -// em_width, -// &line_layouts, -// cx, -// ) -// } else { -// false -// }; - -// if clamped || autoscrolled { -// snapshot = editor.snapshot(cx); -// } - -// let style = editor.style(cx); - -// let mut context_menu = None; -// let mut code_actions_indicator = None; -// if let Some(newest_selection_head) = newest_selection_head { -// if (start_row..end_row).contains(&newest_selection_head.row()) { -// if editor.context_menu_visible() { -// context_menu = -// editor.render_context_menu(newest_selection_head, style.clone(), cx); -// } - -// let active = matches!( -// editor.context_menu.read().as_ref(), -// Some(crate::ContextMenu::CodeActions(_)) -// ); - -// code_actions_indicator = editor -// .render_code_actions_indicator(&style, active, cx) -// .map(|indicator| (newest_selection_head.row(), indicator)); -// } -// } - -// let visible_rows = start_row..start_row + line_layouts.len() as u32; -// let mut hover = editor.hover_state.render( -// &snapshot, -// &style, -// visible_rows, -// editor.workspace.as_ref().map(|(w, _)| w.clone()), -// cx, -// ); -// let mode = editor.mode; - -// let mut fold_indicators = editor.render_fold_indicators( -// fold_statuses, -// &style, -// editor.gutter_hovered, -// line_height, -// gutter_margin, -// cx, -// ); - -// if let Some((_, context_menu)) = context_menu.as_mut() { -// context_menu.layout( -// SizeConstraint { -// min: gpui::Point::::zero(), -// max: point( -// cx.window_size().x * 0.7, -// (12. * line_height).min((size.y - line_height) / 2.), -// ), -// }, -// editor, -// cx, -// ); -// } - -// if let Some((_, indicator)) = code_actions_indicator.as_mut() { -// indicator.layout( -// SizeConstraint::strict_along( -// Axis::Vertical, -// line_height * style.code_actions.vertical_scale, -// ), -// editor, -// cx, -// ); -// } - -// for fold_indicator in fold_indicators.iter_mut() { -// if let Some(indicator) = fold_indicator.as_mut() { -// indicator.layout( -// SizeConstraint::strict_along( -// Axis::Vertical, -// line_height * style.code_actions.vertical_scale, -// ), -// editor, -// cx, -// ); -// } -// } - -// if let Some((_, hover_popovers)) = hover.as_mut() { -// for hover_popover in hover_popovers.iter_mut() { -// hover_popover.layout( -// SizeConstraint { -// min: gpui::Point::::zero(), -// max: point( -// (120. * em_width) // Default size -// .min(size.x / 2.) // Shrink to half of the editor width -// .max(MIN_POPOVER_CHARACTER_WIDTH * em_width), // Apply minimum width of 20 characters -// (16. * line_height) // Default size -// .min(size.y / 2.) // Shrink to half of the editor height -// .max(MIN_POPOVER_LINE_HEIGHT * line_height), // Apply minimum height of 4 lines -// ), -// }, -// editor, -// cx, -// ); -// } -// } - -// let invisible_symbol_font_size = self.style.text.font_size / 2.0; -// let invisible_symbol_style = RunStyle { -// color: self.style.whitespace, -// font_id: self.style.text.font_id, -// underline: Default::default(), -// }; - -// ( -// size, -// LayoutState { -// mode, -// position_map: Arc::new(PositionMap { -// size, -// scroll_max, -// line_layouts, -// line_height, -// em_width, -// em_advance, -// snapshot, -// }), -// visible_display_row_range: start_row..end_row, -// wrap_guides, -// gutter_size, -// gutter_padding, -// text_size, -// scrollbar_row_range, -// show_scrollbars, -// is_singleton, -// max_row, -// gutter_margin, -// active_rows, -// highlighted_rows, -// highlighted_ranges, -// fold_ranges, -// line_number_layouts, -// display_hunks, -// blocks, -// selections, -// context_menu, -// code_actions_indicator, -// fold_indicators, -// tab_invisible: cx.text_layout_cache().layout_str( -// "→", -// invisible_symbol_font_size, -// &[("→".len(), invisible_symbol_style)], -// ), -// space_invisible: cx.text_layout_cache().layout_str( -// "•", -// invisible_symbol_font_size, -// &[("•".len(), invisible_symbol_style)], -// ), -// hover_popovers: hover, -// }, -// ) -// } - -// fn paint( -// &mut self, -// bounds: Bounds, -// visible_bounds: Bounds, -// layout: &mut Self::LayoutState, -// editor: &mut Editor, -// cx: &mut ViewContext, -// ) -> Self::PaintState { -// let visible_bounds = bounds.intersection(visible_bounds).unwrap_or_default(); -// cx.scene().push_layer(Some(visible_bounds)); - -// let gutter_bounds = Bounds::::new(bounds.origin, layout.gutter_size); -// let text_bounds = Bounds::::new( -// bounds.origin + point(layout.gutter_size.x, 0.0), -// layout.text_size, -// ); - -// Self::attach_mouse_handlers( -// &layout.position_map, -// layout.hover_popovers.is_some(), -// visible_bounds, -// text_bounds, -// gutter_bounds, -// bounds, -// cx, -// ); - -// self.paint_background(gutter_bounds, text_bounds, layout, cx); -// if layout.gutter_size.x > 0. { -// self.paint_gutter(gutter_bounds, visible_bounds, layout, editor, cx); -// } -// self.paint_text(text_bounds, visible_bounds, layout, editor, cx); - -// cx.scene().push_layer(Some(bounds)); -// if !layout.blocks.is_empty { -// self.paint_blocks(bounds, visible_bounds, layout, editor, cx); -// } -// self.paint_scrollbar(bounds, layout, &editor, cx); -// cx.scene().pop_layer(); -// cx.scene().pop_layer(); -// } - -// fn rect_for_text_range( -// &self, -// range_utf16: Range, -// bounds: Bounds, -// _: Bounds, -// layout: &Self::LayoutState, -// _: &Self::PaintState, -// _: &Editor, -// _: &ViewContext, -// ) -> Option> { -// let text_bounds = Bounds::::new( -// bounds.origin + point(layout.gutter_size.x, 0.0), -// layout.text_size, -// ); -// let content_origin = text_bounds.origin + point(layout.gutter_margin, 0.); -// let scroll_position = layout.position_map.snapshot.scroll_position(); -// let start_row = scroll_position.y as u32; -// let scroll_top = scroll_position.y * layout.position_map.line_height; -// let scroll_left = scroll_position.x * layout.position_map.em_width; - -// let range_start = OffsetUtf16(range_utf16.start) -// .to_display_point(&layout.position_map.snapshot.display_snapshot); -// if range_start.row() < start_row { -// return None; -// } - -// let line = &layout -// .position_map -// .line_layouts -// .get((range_start.row() - start_row) as usize)? -// .line; -// let range_start_x = line.x_for_index(range_start.column() as usize); -// let range_start_y = range_start.row() as f32 * layout.position_map.line_height; -// Some(Bounds::::new( -// content_origin -// + point( -// range_start_x, -// range_start_y + layout.position_map.line_height, -// ) -// - point(scroll_left, scroll_top), -// point( -// layout.position_map.em_width, -// layout.position_map.line_height, -// ), -// )) -// } - -// fn debug( -// &self, -// bounds: Bounds, -// _: &Self::LayoutState, -// _: &Self::PaintState, -// _: &Editor, -// _: &ViewContext, -// ) -> json::Value { -// json!({ -// "type": "BufferElement", -// "bounds": bounds.to_json() -// }) -// } -// } - type BufferRow = u32; pub struct LayoutState { @@ -3676,448 +3227,491 @@ fn scale_horizontal_mouse_autoscroll_delta(delta: Pixels) -> f32 { (delta.pow(1.2) / 300.0).into() } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::{ -// display_map::{BlockDisposition, BlockProperties}, -// editor_tests::{init_test, update_test_language_settings}, -// Editor, MultiBuffer, -// }; -// use gpui::TestAppContext; -// use language::language_settings; -// use log::info; -// use std::{num::NonZeroU32, sync::Arc}; -// use util::test::sample_text; - -// #[gpui::test] -// fn test_layout_line_numbers(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); -// let editor = cx -// .add_window(|cx| { -// let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); -// Editor::new(EditorMode::Full, buffer, None, None, cx) -// }) -// .root(cx); -// let element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); - -// let layouts = editor.update(cx, |editor, cx| { -// let snapshot = editor.snapshot(cx); -// element -// .layout_line_numbers( -// 0..6, -// &Default::default(), -// DisplayPoint::new(0, 0), -// false, -// &snapshot, -// cx, -// ) -// .0 -// }); -// assert_eq!(layouts.len(), 6); - -// let relative_rows = editor.update(cx, |editor, cx| { -// let snapshot = editor.snapshot(cx); -// element.calculate_relative_line_numbers(&snapshot, &(0..6), Some(3)) -// }); -// assert_eq!(relative_rows[&0], 3); -// assert_eq!(relative_rows[&1], 2); -// assert_eq!(relative_rows[&2], 1); -// // current line has no relative number -// assert_eq!(relative_rows[&4], 1); -// assert_eq!(relative_rows[&5], 2); - -// // works if cursor is before screen -// let relative_rows = editor.update(cx, |editor, cx| { -// let snapshot = editor.snapshot(cx); - -// element.calculate_relative_line_numbers(&snapshot, &(3..6), Some(1)) -// }); -// assert_eq!(relative_rows.len(), 3); -// assert_eq!(relative_rows[&3], 2); -// assert_eq!(relative_rows[&4], 3); -// assert_eq!(relative_rows[&5], 4); - -// // works if cursor is after screen -// let relative_rows = editor.update(cx, |editor, cx| { -// let snapshot = editor.snapshot(cx); - -// element.calculate_relative_line_numbers(&snapshot, &(0..3), Some(6)) -// }); -// assert_eq!(relative_rows.len(), 3); -// assert_eq!(relative_rows[&0], 5); -// assert_eq!(relative_rows[&1], 4); -// assert_eq!(relative_rows[&2], 3); -// } - -// #[gpui::test] -// async fn test_vim_visual_selections(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// let editor = cx -// .add_window(|cx| { -// let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx); -// Editor::new(EditorMode::Full, buffer, None, None, cx) -// }) -// .root(cx); -// let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); -// let (_, state) = editor.update(cx, |editor, cx| { -// editor.cursor_shape = CursorShape::Block; -// editor.change_selections(None, cx, |s| { -// s.select_ranges([ -// Point::new(0, 0)..Point::new(1, 0), -// Point::new(3, 2)..Point::new(3, 3), -// Point::new(5, 6)..Point::new(6, 0), -// ]); -// }); -// element.layout( -// SizeConstraint::new(point(500., 500.), point(500., 500.)), -// editor, -// cx, -// ) -// }); -// assert_eq!(state.selections.len(), 1); -// let local_selections = &state.selections[0].1; -// assert_eq!(local_selections.len(), 3); -// // moves cursor back one line -// assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6)); -// assert_eq!( -// local_selections[0].range, -// DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0) -// ); - -// // moves cursor back one column -// assert_eq!( -// local_selections[1].range, -// DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3) -// ); -// assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2)); - -// // leaves cursor on the max point -// assert_eq!( -// local_selections[2].range, -// DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0) -// ); -// assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0)); - -// // active lines does not include 1 (even though the range of the selection does) -// assert_eq!( -// state.active_rows.keys().cloned().collect::>(), -// vec![0, 3, 5, 6] -// ); - -// // multi-buffer support -// // in DisplayPoint co-ordinates, this is what we're dealing with: -// // 0: [[file -// // 1: header]] -// // 2: aaaaaa -// // 3: bbbbbb -// // 4: cccccc -// // 5: -// // 6: ... -// // 7: ffffff -// // 8: gggggg -// // 9: hhhhhh -// // 10: -// // 11: [[file -// // 12: header]] -// // 13: bbbbbb -// // 14: cccccc -// // 15: dddddd -// let editor = cx -// .add_window(|cx| { -// let buffer = MultiBuffer::build_multi( -// [ -// ( -// &(sample_text(8, 6, 'a') + "\n"), -// vec![ -// Point::new(0, 0)..Point::new(3, 0), -// Point::new(4, 0)..Point::new(7, 0), -// ], -// ), -// ( -// &(sample_text(8, 6, 'a') + "\n"), -// vec![Point::new(1, 0)..Point::new(3, 0)], -// ), -// ], -// cx, -// ); -// Editor::new(EditorMode::Full, buffer, None, None, cx) -// }) -// .root(cx); -// let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); -// let (_, state) = editor.update(cx, |editor, cx| { -// editor.cursor_shape = CursorShape::Block; -// editor.change_selections(None, cx, |s| { -// s.select_display_ranges([ -// DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0), -// DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0), -// ]); -// }); -// element.layout( -// SizeConstraint::new(point(500., 500.), point(500., 500.)), -// editor, -// cx, -// ) -// }); - -// assert_eq!(state.selections.len(), 1); -// let local_selections = &state.selections[0].1; -// assert_eq!(local_selections.len(), 2); - -// // moves cursor on excerpt boundary back a line -// // and doesn't allow selection to bleed through -// assert_eq!( -// local_selections[0].range, -// DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0) -// ); -// assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0)); - -// // moves cursor on buffer boundary back two lines -// // and doesn't allow selection to bleed through -// assert_eq!( -// local_selections[1].range, -// DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0) -// ); -// assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0)); -// } - -// #[gpui::test] -// fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) { -// init_test(cx, |_| {}); - -// let editor = cx -// .add_window(|cx| { -// let buffer = MultiBuffer::build_simple("", cx); -// Editor::new(EditorMode::Full, buffer, None, None, cx) -// }) -// .root(cx); - -// editor.update(cx, |editor, cx| { -// editor.set_placeholder_text("hello", cx); -// editor.insert_blocks( -// [BlockProperties { -// style: BlockStyle::Fixed, -// disposition: BlockDisposition::Above, -// height: 3, -// position: Anchor::min(), -// render: Arc::new(|_| Empty::new().into_any), -// }], -// None, -// cx, -// ); - -// // Blur the editor so that it displays placeholder text. -// cx.blur(); -// }); - -// let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); -// let (size, mut state) = editor.update(cx, |editor, cx| { -// element.layout( -// SizeConstraint::new(point(500., 500.), point(500., 500.)), -// editor, -// cx, -// ) -// }); - -// assert_eq!(state.position_map.line_layouts.len(), 4); -// assert_eq!( -// state -// .line_number_layouts -// .iter() -// .map(Option::is_some) -// .collect::>(), -// &[false, false, false, true] -// ); - -// // Don't panic. -// let bounds = Bounds::::new(Default::default(), size); -// editor.update(cx, |editor, cx| { -// element.paint(bounds, bounds, &mut state, editor, cx); -// }); -// } - -// #[gpui::test] -// fn test_all_invisibles_drawing(cx: &mut TestAppContext) { -// const TAB_SIZE: u32 = 4; - -// let input_text = "\t \t|\t| a b"; -// let expected_invisibles = vec![ -// Invisible::Tab { -// line_start_offset: 0, -// }, -// Invisible::Whitespace { -// line_offset: TAB_SIZE as usize, -// }, -// Invisible::Tab { -// line_start_offset: TAB_SIZE as usize + 1, -// }, -// Invisible::Tab { -// line_start_offset: TAB_SIZE as usize * 2 + 1, -// }, -// Invisible::Whitespace { -// line_offset: TAB_SIZE as usize * 3 + 1, -// }, -// Invisible::Whitespace { -// line_offset: TAB_SIZE as usize * 3 + 3, -// }, -// ]; -// assert_eq!( -// expected_invisibles.len(), -// input_text -// .chars() -// .filter(|initial_char| initial_char.is_whitespace()) -// .count(), -// "Hardcoded expected invisibles differ from the actual ones in '{input_text}'" -// ); - -// init_test(cx, |s| { -// s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); -// s.defaults.tab_size = NonZeroU32::new(TAB_SIZE); -// }); - -// let actual_invisibles = -// collect_invisibles_from_new_editor(cx, EditorMode::Full, &input_text, 500.0); - -// assert_eq!(expected_invisibles, actual_invisibles); -// } - -// #[gpui::test] -// fn test_invisibles_dont_appear_in_certain_editors(cx: &mut TestAppContext) { -// init_test(cx, |s| { -// s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); -// s.defaults.tab_size = NonZeroU32::new(4); -// }); - -// for editor_mode_without_invisibles in [ -// EditorMode::SingleLine, -// EditorMode::AutoHeight { max_lines: 100 }, -// ] { -// let invisibles = collect_invisibles_from_new_editor( -// cx, -// editor_mode_without_invisibles, -// "\t\t\t| | a b", -// 500.0, -// ); -// assert!(invisibles.is_empty, -// "For editor mode {editor_mode_without_invisibles:?} no invisibles was expected but got {invisibles:?}"); -// } -// } - -// #[gpui::test] -// fn test_wrapped_invisibles_drawing(cx: &mut TestAppContext) { -// let tab_size = 4; -// let input_text = "a\tbcd ".repeat(9); -// let repeated_invisibles = [ -// Invisible::Tab { -// line_start_offset: 1, -// }, -// Invisible::Whitespace { -// line_offset: tab_size as usize + 3, -// }, -// Invisible::Whitespace { -// line_offset: tab_size as usize + 4, -// }, -// Invisible::Whitespace { -// line_offset: tab_size as usize + 5, -// }, -// ]; -// let expected_invisibles = std::iter::once(repeated_invisibles) -// .cycle() -// .take(9) -// .flatten() -// .collect::>(); -// assert_eq!( -// expected_invisibles.len(), -// input_text -// .chars() -// .filter(|initial_char| initial_char.is_whitespace()) -// .count(), -// "Hardcoded expected invisibles differ from the actual ones in '{input_text}'" -// ); -// info!("Expected invisibles: {expected_invisibles:?}"); - -// init_test(cx, |_| {}); - -// // Put the same string with repeating whitespace pattern into editors of various size, -// // take deliberately small steps during resizing, to put all whitespace kinds near the wrap point. -// let resize_step = 10.0; -// let mut editor_width = 200.0; -// while editor_width <= 1000.0 { -// update_test_language_settings(cx, |s| { -// s.defaults.tab_size = NonZeroU32::new(tab_size); -// s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); -// s.defaults.preferred_line_length = Some(editor_width as u32); -// s.defaults.soft_wrap = Some(language_settings::SoftWrap::PreferredLineLength); -// }); - -// let actual_invisibles = -// collect_invisibles_from_new_editor(cx, EditorMode::Full, &input_text, editor_width); - -// // Whatever the editor size is, ensure it has the same invisible kinds in the same order -// // (no good guarantees about the offsets: wrapping could trigger padding and its tests should check the offsets). -// let mut i = 0; -// for (actual_index, actual_invisible) in actual_invisibles.iter().enumerate() { -// i = actual_index; -// match expected_invisibles.get(i) { -// Some(expected_invisible) => match (expected_invisible, actual_invisible) { -// (Invisible::Whitespace { .. }, Invisible::Whitespace { .. }) -// | (Invisible::Tab { .. }, Invisible::Tab { .. }) => {} -// _ => { -// panic!("At index {i}, expected invisible {expected_invisible:?} does not match actual {actual_invisible:?} by kind. Actual invisibles: {actual_invisibles:?}") -// } -// }, -// None => panic!("Unexpected extra invisible {actual_invisible:?} at index {i}"), -// } -// } -// let missing_expected_invisibles = &expected_invisibles[i + 1..]; -// assert!( -// missing_expected_invisibles.is_empty, -// "Missing expected invisibles after index {i}: {missing_expected_invisibles:?}" -// ); - -// editor_width += resize_step; -// } -// } - -// fn collect_invisibles_from_new_editor( -// cx: &mut TestAppContext, -// editor_mode: EditorMode, -// input_text: &str, -// editor_width: f32, -// ) -> Vec { -// info!( -// "Creating editor with mode {editor_mode:?}, width {editor_width} and text '{input_text}'" -// ); -// let editor = cx -// .add_window(|cx| { -// let buffer = MultiBuffer::build_simple(&input_text, cx); -// Editor::new(editor_mode, buffer, None, None, cx) -// }) -// .root(cx); - -// let mut element = EditorElement::new(editor.read_with(cx, |editor, cx| editor.style(cx))); -// let (_, layout_state) = editor.update(cx, |editor, cx| { -// editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); -// editor.set_wrap_width(Some(editor_width), cx); - -// element.layout( -// SizeConstraint::new(point(editor_width, 500.), point(editor_width, 500.)), -// editor, -// cx, -// ) -// }); - -// layout_state -// .position_map -// .line_layouts -// .iter() -// .map(|line_with_invisibles| &line_with_invisibles.invisibles) -// .flatten() -// .cloned() -// .collect() -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + display_map::{BlockDisposition, BlockProperties}, + editor_tests::{init_test, update_test_language_settings}, + Editor, MultiBuffer, + }; + use gpui::{EmptyView, TestAppContext}; + use language::language_settings; + use log::info; + use std::{num::NonZeroU32, sync::Arc}; + use util::test::sample_text; + + #[gpui::test] + fn test_shape_line_numbers(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple(&sample_text(6, 6, 'a'), cx); + Editor::new(EditorMode::Full, buffer, None, cx) + }); + + let editor = window.root(cx).unwrap(); + let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let element = EditorElement::new(&editor, style); + + let layouts = window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + element + .shape_line_numbers( + 0..6, + &Default::default(), + DisplayPoint::new(0, 0), + false, + &snapshot, + cx, + ) + .0 + }) + .unwrap(); + assert_eq!(layouts.len(), 6); + + let relative_rows = window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + element.calculate_relative_line_numbers(&snapshot, &(0..6), Some(3)) + }) + .unwrap(); + assert_eq!(relative_rows[&0], 3); + assert_eq!(relative_rows[&1], 2); + assert_eq!(relative_rows[&2], 1); + // current line has no relative number + assert_eq!(relative_rows[&4], 1); + assert_eq!(relative_rows[&5], 2); + + // works if cursor is before screen + let relative_rows = window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + + element.calculate_relative_line_numbers(&snapshot, &(3..6), Some(1)) + }) + .unwrap(); + assert_eq!(relative_rows.len(), 3); + assert_eq!(relative_rows[&3], 2); + assert_eq!(relative_rows[&4], 3); + assert_eq!(relative_rows[&5], 4); + + // works if cursor is after screen + let relative_rows = window + .update(cx, |editor, cx| { + let snapshot = editor.snapshot(cx); + + element.calculate_relative_line_numbers(&snapshot, &(0..3), Some(6)) + }) + .unwrap(); + assert_eq!(relative_rows.len(), 3); + assert_eq!(relative_rows[&0], 5); + assert_eq!(relative_rows[&1], 4); + assert_eq!(relative_rows[&2], 3); + } + + #[gpui::test] + async fn test_vim_visual_selections(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple(&(sample_text(6, 6, 'a') + "\n"), cx); + Editor::new(EditorMode::Full, buffer, None, cx) + }); + let editor = window.root(cx).unwrap(); + let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let mut element = EditorElement::new(&editor, style); + + window + .update(cx, |editor, cx| { + editor.cursor_shape = CursorShape::Block; + editor.change_selections(None, cx, |s| { + s.select_ranges([ + Point::new(0, 0)..Point::new(1, 0), + Point::new(3, 2)..Point::new(3, 3), + Point::new(5, 6)..Point::new(6, 0), + ]); + }); + }) + .unwrap(); + let state = cx + .update_window(window.into(), |_, cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) + .unwrap(); + + assert_eq!(state.selections.len(), 1); + let local_selections = &state.selections[0].1; + assert_eq!(local_selections.len(), 3); + // moves cursor back one line + assert_eq!(local_selections[0].head, DisplayPoint::new(0, 6)); + assert_eq!( + local_selections[0].range, + DisplayPoint::new(0, 0)..DisplayPoint::new(1, 0) + ); + + // moves cursor back one column + assert_eq!( + local_selections[1].range, + DisplayPoint::new(3, 2)..DisplayPoint::new(3, 3) + ); + assert_eq!(local_selections[1].head, DisplayPoint::new(3, 2)); + + // leaves cursor on the max point + assert_eq!( + local_selections[2].range, + DisplayPoint::new(5, 6)..DisplayPoint::new(6, 0) + ); + assert_eq!(local_selections[2].head, DisplayPoint::new(6, 0)); + + // active lines does not include 1 (even though the range of the selection does) + assert_eq!( + state.active_rows.keys().cloned().collect::>(), + vec![0, 3, 5, 6] + ); + + // multi-buffer support + // in DisplayPoint co-ordinates, this is what we're dealing with: + // 0: [[file + // 1: header]] + // 2: aaaaaa + // 3: bbbbbb + // 4: cccccc + // 5: + // 6: ... + // 7: ffffff + // 8: gggggg + // 9: hhhhhh + // 10: + // 11: [[file + // 12: header]] + // 13: bbbbbb + // 14: cccccc + // 15: dddddd + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_multi( + [ + ( + &(sample_text(8, 6, 'a') + "\n"), + vec![ + Point::new(0, 0)..Point::new(3, 0), + Point::new(4, 0)..Point::new(7, 0), + ], + ), + ( + &(sample_text(8, 6, 'a') + "\n"), + vec![Point::new(1, 0)..Point::new(3, 0)], + ), + ], + cx, + ); + Editor::new(EditorMode::Full, buffer, None, cx) + }); + let editor = window.root(cx).unwrap(); + let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let mut element = EditorElement::new(&editor, style); + let state = window.update(cx, |editor, cx| { + editor.cursor_shape = CursorShape::Block; + editor.change_selections(None, cx, |s| { + s.select_display_ranges([ + DisplayPoint::new(4, 0)..DisplayPoint::new(7, 0), + DisplayPoint::new(10, 0)..DisplayPoint::new(13, 0), + ]); + }); + }); + + let state = cx + .update_window(window.into(), |_, cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) + .unwrap(); + assert_eq!(state.selections.len(), 1); + let local_selections = &state.selections[0].1; + assert_eq!(local_selections.len(), 2); + + // moves cursor on excerpt boundary back a line + // and doesn't allow selection to bleed through + assert_eq!( + local_selections[0].range, + DisplayPoint::new(4, 0)..DisplayPoint::new(6, 0) + ); + assert_eq!(local_selections[0].head, DisplayPoint::new(5, 0)); + dbg!("Hi"); + // moves cursor on buffer boundary back two lines + // and doesn't allow selection to bleed through + assert_eq!( + local_selections[1].range, + DisplayPoint::new(10, 0)..DisplayPoint::new(11, 0) + ); + assert_eq!(local_selections[1].head, DisplayPoint::new(10, 0)); + } + + #[gpui::test] + fn test_layout_with_placeholder_text_and_blocks(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple("", cx); + Editor::new(EditorMode::Full, buffer, None, cx) + }); + let editor = window.root(cx).unwrap(); + let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + window + .update(cx, |editor, cx| { + editor.set_placeholder_text("hello", cx); + editor.insert_blocks( + [BlockProperties { + style: BlockStyle::Fixed, + disposition: BlockDisposition::Above, + height: 3, + position: Anchor::min(), + render: Arc::new(|_| div().into_any()), + }], + None, + cx, + ); + + // Blur the editor so that it displays placeholder text. + cx.blur(); + }) + .unwrap(); + + let mut element = EditorElement::new(&editor, style); + let mut state = cx + .update_window(window.into(), |_, cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) + .unwrap(); + let size = state.position_map.size; + + assert_eq!(state.position_map.line_layouts.len(), 4); + assert_eq!( + state + .line_numbers + .iter() + .map(Option::is_some) + .collect::>(), + &[false, false, false, true] + ); + + // Don't panic. + let bounds = Bounds::::new(Default::default(), size); + cx.update_window(window.into(), |_, cx| { + element.paint(bounds, &mut (), cx); + }) + .unwrap() + } + + #[gpui::test] + fn test_all_invisibles_drawing(cx: &mut TestAppContext) { + const TAB_SIZE: u32 = 4; + + let input_text = "\t \t|\t| a b"; + let expected_invisibles = vec![ + Invisible::Tab { + line_start_offset: 0, + }, + Invisible::Whitespace { + line_offset: TAB_SIZE as usize, + }, + Invisible::Tab { + line_start_offset: TAB_SIZE as usize + 1, + }, + Invisible::Tab { + line_start_offset: TAB_SIZE as usize * 2 + 1, + }, + Invisible::Whitespace { + line_offset: TAB_SIZE as usize * 3 + 1, + }, + Invisible::Whitespace { + line_offset: TAB_SIZE as usize * 3 + 3, + }, + ]; + assert_eq!( + expected_invisibles.len(), + input_text + .chars() + .filter(|initial_char| initial_char.is_whitespace()) + .count(), + "Hardcoded expected invisibles differ from the actual ones in '{input_text}'" + ); + + init_test(cx, |s| { + s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); + s.defaults.tab_size = NonZeroU32::new(TAB_SIZE); + }); + + let actual_invisibles = + collect_invisibles_from_new_editor(cx, EditorMode::Full, &input_text, px(500.0)); + + assert_eq!(expected_invisibles, actual_invisibles); + } + + #[gpui::test] + fn test_invisibles_dont_appear_in_certain_editors(cx: &mut TestAppContext) { + init_test(cx, |s| { + s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); + s.defaults.tab_size = NonZeroU32::new(4); + }); + + for editor_mode_without_invisibles in [ + EditorMode::SingleLine, + EditorMode::AutoHeight { max_lines: 100 }, + ] { + let invisibles = collect_invisibles_from_new_editor( + cx, + editor_mode_without_invisibles, + "\t\t\t| | a b", + px(500.0), + ); + assert!(invisibles.is_empty(), + "For editor mode {editor_mode_without_invisibles:?} no invisibles was expected but got {invisibles:?}"); + } + } + + #[gpui::test] + fn test_wrapped_invisibles_drawing(cx: &mut TestAppContext) { + let tab_size = 4; + let input_text = "a\tbcd ".repeat(9); + let repeated_invisibles = [ + Invisible::Tab { + line_start_offset: 1, + }, + Invisible::Whitespace { + line_offset: tab_size as usize + 3, + }, + Invisible::Whitespace { + line_offset: tab_size as usize + 4, + }, + Invisible::Whitespace { + line_offset: tab_size as usize + 5, + }, + ]; + let expected_invisibles = std::iter::once(repeated_invisibles) + .cycle() + .take(9) + .flatten() + .collect::>(); + assert_eq!( + expected_invisibles.len(), + input_text + .chars() + .filter(|initial_char| initial_char.is_whitespace()) + .count(), + "Hardcoded expected invisibles differ from the actual ones in '{input_text}'" + ); + info!("Expected invisibles: {expected_invisibles:?}"); + + init_test(cx, |_| {}); + + // Put the same string with repeating whitespace pattern into editors of various size, + // take deliberately small steps during resizing, to put all whitespace kinds near the wrap point. + let resize_step = 10.0; + let mut editor_width = 200.0; + while editor_width <= 1000.0 { + update_test_language_settings(cx, |s| { + s.defaults.tab_size = NonZeroU32::new(tab_size); + s.defaults.show_whitespaces = Some(ShowWhitespaceSetting::All); + s.defaults.preferred_line_length = Some(editor_width as u32); + s.defaults.soft_wrap = Some(language_settings::SoftWrap::PreferredLineLength); + }); + + let actual_invisibles = collect_invisibles_from_new_editor( + cx, + EditorMode::Full, + &input_text, + px(editor_width), + ); + + // Whatever the editor size is, ensure it has the same invisible kinds in the same order + // (no good guarantees about the offsets: wrapping could trigger padding and its tests should check the offsets). + let mut i = 0; + for (actual_index, actual_invisible) in actual_invisibles.iter().enumerate() { + i = actual_index; + match expected_invisibles.get(i) { + Some(expected_invisible) => match (expected_invisible, actual_invisible) { + (Invisible::Whitespace { .. }, Invisible::Whitespace { .. }) + | (Invisible::Tab { .. }, Invisible::Tab { .. }) => {} + _ => { + panic!("At index {i}, expected invisible {expected_invisible:?} does not match actual {actual_invisible:?} by kind. Actual invisibles: {actual_invisibles:?}") + } + }, + None => panic!("Unexpected extra invisible {actual_invisible:?} at index {i}"), + } + } + let missing_expected_invisibles = &expected_invisibles[i + 1..]; + assert!( + missing_expected_invisibles.is_empty(), + "Missing expected invisibles after index {i}: {missing_expected_invisibles:?}" + ); + + editor_width += resize_step; + } + } + + fn collect_invisibles_from_new_editor( + cx: &mut TestAppContext, + editor_mode: EditorMode, + input_text: &str, + editor_width: Pixels, + ) -> Vec { + info!( + "Creating editor with mode {editor_mode:?}, width {}px and text '{input_text}'", + editor_width.0 + ); + let window = cx.add_window(|cx| { + let buffer = MultiBuffer::build_simple(&input_text, cx); + Editor::new(editor_mode, buffer, None, cx) + }); + let editor = window.root(cx).unwrap(); + let style = cx.update(|cx| editor.read(cx).style().unwrap().clone()); + let mut element = EditorElement::new(&editor, style); + window + .update(cx, |editor, cx| { + editor.set_soft_wrap_mode(language_settings::SoftWrap::EditorWidth, cx); + editor.set_wrap_width(Some(editor_width), cx); + }) + .unwrap(); + let layout_state = cx + .update_window(window.into(), |_, cx| { + element.compute_layout( + Bounds { + origin: point(px(500.), px(500.)), + size: size(px(500.), px(500.)), + }, + cx, + ) + }) + .unwrap(); + + layout_state + .position_map + .line_layouts + .iter() + .map(|line_with_invisibles| &line_with_invisibles.invisibles) + .flatten() + .cloned() + .collect() + } +} pub fn register_action( view: &View, @@ -4134,3 +3728,59 @@ pub fn register_action( } }) } + +fn compute_auto_height_layout( + editor: &mut Editor, + max_lines: usize, + max_line_number_width: Pixels, + known_dimensions: Size>, + cx: &mut ViewContext, +) -> Option> { + let mut width = known_dimensions.width?; + if let Some(height) = known_dimensions.height { + return Some(size(width, height)); + } + + let style = editor.style.as_ref().unwrap(); + let font_id = cx.text_system().font_id(&style.text.font()).unwrap(); + let font_size = style.text.font_size.to_pixels(cx.rem_size()); + let line_height = style.text.line_height_in_pixels(cx.rem_size()); + let em_width = cx + .text_system() + .typographic_bounds(font_id, font_size, 'm') + .unwrap() + .size + .width; + + let mut snapshot = editor.snapshot(cx); + let gutter_padding; + let gutter_width; + let gutter_margin; + if snapshot.show_gutter { + let descent = cx.text_system().descent(font_id, font_size); + let gutter_padding_factor = 3.5; + gutter_padding = (em_width * gutter_padding_factor).round(); + gutter_width = max_line_number_width + gutter_padding * 2.0; + gutter_margin = -descent; + } else { + gutter_padding = Pixels::ZERO; + gutter_width = Pixels::ZERO; + gutter_margin = Pixels::ZERO; + }; + + editor.gutter_width = gutter_width; + let text_width = width - gutter_width; + let overscroll = size(em_width, px(0.)); + + let editor_width = text_width - gutter_margin - overscroll.width - em_width; + if editor.set_wrap_width(Some(editor_width), cx) { + snapshot = editor.snapshot(cx); + } + + let scroll_height = Pixels::from(snapshot.max_point().row() + 1) * line_height; + let height = scroll_height + .max(line_height) + .min(line_height * max_lines as f32); + + Some(size(width, height)) +} diff --git a/crates/editor2/src/git.rs b/crates/editor2/src/git.rs index 6e408cd3a01f7603ccdf97660b8896fdee833d65..f798ab9fb636efb46fc806442af301f3e2b05671 100644 --- a/crates/editor2/src/git.rs +++ b/crates/editor2/src/git.rs @@ -88,195 +88,195 @@ pub fn diff_hunk_to_display(hunk: DiffHunk, snapshot: &DisplaySnapshot) -> } } -// #[cfg(any(test, feature = "test_support"))] -// mod tests { -// // use crate::editor_tests::init_test; -// use crate::Point; -// use gpui::TestAppContext; -// use multi_buffer::{ExcerptRange, MultiBuffer}; -// use project::{FakeFs, Project}; -// use unindent::Unindent; -// #[gpui::test] -// async fn test_diff_hunks_in_range(cx: &mut TestAppContext) { -// use git::diff::DiffHunkStatus; -// init_test(cx, |_| {}); +#[cfg(test)] +mod tests { + use crate::editor_tests::init_test; + use crate::Point; + use gpui::{Context, TestAppContext}; + use multi_buffer::{ExcerptRange, MultiBuffer}; + use project::{FakeFs, Project}; + use unindent::Unindent; + #[gpui::test] + async fn test_diff_hunks_in_range(cx: &mut TestAppContext) { + use git::diff::DiffHunkStatus; + init_test(cx, |_| {}); -// let fs = FakeFs::new(cx.background()); -// let project = Project::test(fs, [], cx).await; + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; -// // buffer has two modified hunks with two rows each -// let buffer_1 = project -// .update(cx, |project, cx| { -// project.create_buffer( -// " -// 1.zero -// 1.ONE -// 1.TWO -// 1.three -// 1.FOUR -// 1.FIVE -// 1.six -// " -// .unindent() -// .as_str(), -// None, -// cx, -// ) -// }) -// .unwrap(); -// buffer_1.update(cx, |buffer, cx| { -// buffer.set_diff_base( -// Some( -// " -// 1.zero -// 1.one -// 1.two -// 1.three -// 1.four -// 1.five -// 1.six -// " -// .unindent(), -// ), -// cx, -// ); -// }); + // buffer has two modified hunks with two rows each + let buffer_1 = project + .update(cx, |project, cx| { + project.create_buffer( + " + 1.zero + 1.ONE + 1.TWO + 1.three + 1.FOUR + 1.FIVE + 1.six + " + .unindent() + .as_str(), + None, + cx, + ) + }) + .unwrap(); + buffer_1.update(cx, |buffer, cx| { + buffer.set_diff_base( + Some( + " + 1.zero + 1.one + 1.two + 1.three + 1.four + 1.five + 1.six + " + .unindent(), + ), + cx, + ); + }); -// // buffer has a deletion hunk and an insertion hunk -// let buffer_2 = project -// .update(cx, |project, cx| { -// project.create_buffer( -// " -// 2.zero -// 2.one -// 2.two -// 2.three -// 2.four -// 2.five -// 2.six -// " -// .unindent() -// .as_str(), -// None, -// cx, -// ) -// }) -// .unwrap(); -// buffer_2.update(cx, |buffer, cx| { -// buffer.set_diff_base( -// Some( -// " -// 2.zero -// 2.one -// 2.one-and-a-half -// 2.two -// 2.three -// 2.four -// 2.six -// " -// .unindent(), -// ), -// cx, -// ); -// }); + // buffer has a deletion hunk and an insertion hunk + let buffer_2 = project + .update(cx, |project, cx| { + project.create_buffer( + " + 2.zero + 2.one + 2.two + 2.three + 2.four + 2.five + 2.six + " + .unindent() + .as_str(), + None, + cx, + ) + }) + .unwrap(); + buffer_2.update(cx, |buffer, cx| { + buffer.set_diff_base( + Some( + " + 2.zero + 2.one + 2.one-and-a-half + 2.two + 2.three + 2.four + 2.six + " + .unindent(), + ), + cx, + ); + }); -// cx.foreground().run_until_parked(); + cx.background_executor.run_until_parked(); -// let multibuffer = cx.add_model(|cx| { -// let mut multibuffer = MultiBuffer::new(0); -// multibuffer.push_excerpts( -// buffer_1.clone(), -// [ -// // excerpt ends in the middle of a modified hunk -// ExcerptRange { -// context: Point::new(0, 0)..Point::new(1, 5), -// primary: Default::default(), -// }, -// // excerpt begins in the middle of a modified hunk -// ExcerptRange { -// context: Point::new(5, 0)..Point::new(6, 5), -// primary: Default::default(), -// }, -// ], -// cx, -// ); -// multibuffer.push_excerpts( -// buffer_2.clone(), -// [ -// // excerpt ends at a deletion -// ExcerptRange { -// context: Point::new(0, 0)..Point::new(1, 5), -// primary: Default::default(), -// }, -// // excerpt starts at a deletion -// ExcerptRange { -// context: Point::new(2, 0)..Point::new(2, 5), -// primary: Default::default(), -// }, -// // excerpt fully contains a deletion hunk -// ExcerptRange { -// context: Point::new(1, 0)..Point::new(2, 5), -// primary: Default::default(), -// }, -// // excerpt fully contains an insertion hunk -// ExcerptRange { -// context: Point::new(4, 0)..Point::new(6, 5), -// primary: Default::default(), -// }, -// ], -// cx, -// ); -// multibuffer -// }); + let multibuffer = cx.build_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + buffer_1.clone(), + [ + // excerpt ends in the middle of a modified hunk + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 5), + primary: Default::default(), + }, + // excerpt begins in the middle of a modified hunk + ExcerptRange { + context: Point::new(5, 0)..Point::new(6, 5), + primary: Default::default(), + }, + ], + cx, + ); + multibuffer.push_excerpts( + buffer_2.clone(), + [ + // excerpt ends at a deletion + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 5), + primary: Default::default(), + }, + // excerpt starts at a deletion + ExcerptRange { + context: Point::new(2, 0)..Point::new(2, 5), + primary: Default::default(), + }, + // excerpt fully contains a deletion hunk + ExcerptRange { + context: Point::new(1, 0)..Point::new(2, 5), + primary: Default::default(), + }, + // excerpt fully contains an insertion hunk + ExcerptRange { + context: Point::new(4, 0)..Point::new(6, 5), + primary: Default::default(), + }, + ], + cx, + ); + multibuffer + }); -// let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx)); + let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx)); -// assert_eq!( -// snapshot.text(), -// " -// 1.zero -// 1.ONE -// 1.FIVE -// 1.six -// 2.zero -// 2.one -// 2.two -// 2.one -// 2.two -// 2.four -// 2.five -// 2.six" -// .unindent() -// ); + assert_eq!( + snapshot.text(), + " + 1.zero + 1.ONE + 1.FIVE + 1.six + 2.zero + 2.one + 2.two + 2.one + 2.two + 2.four + 2.five + 2.six" + .unindent() + ); -// let expected = [ -// (DiffHunkStatus::Modified, 1..2), -// (DiffHunkStatus::Modified, 2..3), -// //TODO: Define better when and where removed hunks show up at range extremities -// (DiffHunkStatus::Removed, 6..6), -// (DiffHunkStatus::Removed, 8..8), -// (DiffHunkStatus::Added, 10..11), -// ]; + let expected = [ + (DiffHunkStatus::Modified, 1..2), + (DiffHunkStatus::Modified, 2..3), + //TODO: Define better when and where removed hunks show up at range extremities + (DiffHunkStatus::Removed, 6..6), + (DiffHunkStatus::Removed, 8..8), + (DiffHunkStatus::Added, 10..11), + ]; -// assert_eq!( -// snapshot -// .git_diff_hunks_in_range(0..12) -// .map(|hunk| (hunk.status(), hunk.buffer_range)) -// .collect::>(), -// &expected, -// ); + assert_eq!( + snapshot + .git_diff_hunks_in_range(0..12) + .map(|hunk| (hunk.status(), hunk.buffer_range)) + .collect::>(), + &expected, + ); -// assert_eq!( -// snapshot -// .git_diff_hunks_in_range_rev(0..12) -// .map(|hunk| (hunk.status(), hunk.buffer_range)) -// .collect::>(), -// expected -// .iter() -// .rev() -// .cloned() -// .collect::>() -// .as_slice(), -// ); -// } -// } + assert_eq!( + snapshot + .git_diff_hunks_in_range_rev(0..12) + .map(|hunk| (hunk.status(), hunk.buffer_range)) + .collect::>(), + expected + .iter() + .rev() + .cloned() + .collect::>() + .as_slice(), + ); + } +} diff --git a/crates/editor2/src/highlight_matching_bracket.rs b/crates/editor2/src/highlight_matching_bracket.rs index d7fd37745f0e1465ba5fec5f018834dec68df52f..1ed7700f37a56cd428015fdffbe25b78b2c02fad 100644 --- a/crates/editor2/src/highlight_matching_bracket.rs +++ b/crates/editor2/src/highlight_matching_bracket.rs @@ -5,7 +5,7 @@ use crate::{Editor, RangeToAnchorExt}; enum MatchingBracketHighlight {} pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewContext) { - // editor.clear_background_highlights::(cx); + editor.clear_background_highlights::(cx); let newest_selection = editor.selections.newest::(cx); // Don't highlight brackets if the selection isn't empty @@ -30,109 +30,109 @@ pub fn refresh_matching_bracket_highlights(editor: &mut Editor, cx: &mut ViewCon } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext}; -// use indoc::indoc; -// use language::{BracketPair, BracketPairConfig, Language, LanguageConfig}; +#[cfg(test)] +mod tests { + use super::*; + use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext}; + use indoc::indoc; + use language::{BracketPair, BracketPairConfig, Language, LanguageConfig}; -// #[gpui::test] -// async fn test_matching_bracket_highlights(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); + #[gpui::test] + async fn test_matching_bracket_highlights(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); -// let mut cx = EditorLspTestContext::new( -// Language::new( -// LanguageConfig { -// name: "Rust".into(), -// path_suffixes: vec!["rs".to_string()], -// brackets: BracketPairConfig { -// pairs: vec![ -// BracketPair { -// start: "{".to_string(), -// end: "}".to_string(), -// close: false, -// newline: true, -// }, -// BracketPair { -// start: "(".to_string(), -// end: ")".to_string(), -// close: false, -// newline: true, -// }, -// ], -// ..Default::default() -// }, -// ..Default::default() -// }, -// Some(tree_sitter_rust::language()), -// ) -// .with_brackets_query(indoc! {r#" -// ("{" @open "}" @close) -// ("(" @open ")" @close) -// "#}) -// .unwrap(), -// Default::default(), -// cx, -// ) -// .await; + let mut cx = EditorLspTestContext::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".to_string()], + brackets: BracketPairConfig { + pairs: vec![ + BracketPair { + start: "{".to_string(), + end: "}".to_string(), + close: false, + newline: true, + }, + BracketPair { + start: "(".to_string(), + end: ")".to_string(), + close: false, + newline: true, + }, + ], + ..Default::default() + }, + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_brackets_query(indoc! {r#" + ("{" @open "}" @close) + ("(" @open ")" @close) + "#}) + .unwrap(), + Default::default(), + cx, + ) + .await; -// // positioning cursor inside bracket highlights both -// cx.set_state(indoc! {r#" -// pub fn test("Test ˇargument") { -// another_test(1, 2, 3); -// } -// "#}); -// cx.assert_editor_background_highlights::(indoc! {r#" -// pub fn test«(»"Test argument"«)» { -// another_test(1, 2, 3); -// } -// "#}); + // positioning cursor inside bracket highlights both + cx.set_state(indoc! {r#" + pub fn test("Test ˇargument") { + another_test(1, 2, 3); + } + "#}); + cx.assert_editor_background_highlights::(indoc! {r#" + pub fn test«(»"Test argument"«)» { + another_test(1, 2, 3); + } + "#}); -// cx.set_state(indoc! {r#" -// pub fn test("Test argument") { -// another_test(1, ˇ2, 3); -// } -// "#}); -// cx.assert_editor_background_highlights::(indoc! {r#" -// pub fn test("Test argument") { -// another_test«(»1, 2, 3«)»; -// } -// "#}); + cx.set_state(indoc! {r#" + pub fn test("Test argument") { + another_test(1, ˇ2, 3); + } + "#}); + cx.assert_editor_background_highlights::(indoc! {r#" + pub fn test("Test argument") { + another_test«(»1, 2, 3«)»; + } + "#}); -// cx.set_state(indoc! {r#" -// pub fn test("Test argument") { -// anotherˇ_test(1, 2, 3); -// } -// "#}); -// cx.assert_editor_background_highlights::(indoc! {r#" -// pub fn test("Test argument") «{» -// another_test(1, 2, 3); -// «}» -// "#}); + cx.set_state(indoc! {r#" + pub fn test("Test argument") { + anotherˇ_test(1, 2, 3); + } + "#}); + cx.assert_editor_background_highlights::(indoc! {r#" + pub fn test("Test argument") «{» + another_test(1, 2, 3); + «}» + "#}); -// // positioning outside of brackets removes highlight -// cx.set_state(indoc! {r#" -// pub fˇn test("Test argument") { -// another_test(1, 2, 3); -// } -// "#}); -// cx.assert_editor_background_highlights::(indoc! {r#" -// pub fn test("Test argument") { -// another_test(1, 2, 3); -// } -// "#}); + // positioning outside of brackets removes highlight + cx.set_state(indoc! {r#" + pub fˇn test("Test argument") { + another_test(1, 2, 3); + } + "#}); + cx.assert_editor_background_highlights::(indoc! {r#" + pub fn test("Test argument") { + another_test(1, 2, 3); + } + "#}); -// // non empty selection dismisses highlight -// cx.set_state(indoc! {r#" -// pub fn test("Te«st argˇ»ument") { -// another_test(1, 2, 3); -// } -// "#}); -// cx.assert_editor_background_highlights::(indoc! {r#" -// pub fn test("Test argument") { -// another_test(1, 2, 3); -// } -// "#}); -// } -// } + // non empty selection dismisses highlight + cx.set_state(indoc! {r#" + pub fn test("Te«st argˇ»ument") { + another_test(1, 2, 3); + } + "#}); + cx.assert_editor_background_highlights::(indoc! {r#" + pub fn test("Test argument") { + another_test(1, 2, 3); + } + "#}); + } +} diff --git a/crates/editor2/src/inlay_hint_cache.rs b/crates/editor2/src/inlay_hint_cache.rs index 1610c4826e7d984cffd9d59656ba445f8ec9558b..aab985ff9030988481796b0a4181189662f749c9 100644 --- a/crates/editor2/src/inlay_hint_cache.rs +++ b/crates/editor2/src/inlay_hint_cache.rs @@ -2432,13 +2432,13 @@ pub mod tests { let language = Arc::new(language); let fs = FakeFs::new(cx.background_executor.clone()); fs.insert_tree( - "/a", - json!({ - "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), - "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), - }), - ) - .await; + "/a", + json!({ + "main.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|i| format!("let i = {i};\n")).collect::>().join("")), + "other.rs": format!("fn main() {{\n{}\n}}", (0..501).map(|j| format!("let j = {j};\n")).collect::>().join("")), + }), + ) + .await; let project = Project::test(fs, ["/a".as_ref()], cx).await; project.update(cx, |project, _| { project.languages().add(Arc::clone(&language)) @@ -2598,24 +2598,22 @@ pub mod tests { cx.executor().run_until_parked(); editor.update(cx, |editor, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - // todo!() there used to be no these hints, but new gpui2 presumably scrolls a bit farther - // (or renders less?) note that tests below pass - "main hint #4".to_string(), - "main hint #5".to_string(), - ]; - assert_eq!( - expected_hints, - cached_hint_labels(editor), - "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" - ); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - assert_eq!(editor.inlay_hint_cache().version, expected_hints.len(), "Every visible excerpt hints should bump the verison"); - }); + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + ]; + assert_eq!( + expected_hints, + cached_hint_labels(editor), + "When scroll is at the edge of a multibuffer, its visible excerpts only should be queried for inlay hints" + ); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, expected_hints.len(), "Every visible excerpt hints should bump the verison"); + }); editor.update(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Next), cx, |s| { @@ -2630,23 +2628,23 @@ pub mod tests { }); cx.executor().run_until_parked(); editor.update(cx, |editor, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - ]; - assert_eq!(expected_hints, cached_hint_labels(editor), - "With more scrolls of the multibuffer, more hints should be added into the cache and nothing invalidated without edits"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - assert_eq!(editor.inlay_hint_cache().version, expected_hints.len(), - "Due to every excerpt having one hint, we update cache per new excerpt scrolled"); - }); + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + ]; + assert_eq!(expected_hints, cached_hint_labels(editor), + "With more scrolls of the multibuffer, more hints should be added into the cache and nothing invalidated without edits"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, expected_hints.len(), + "Due to every excerpt having one hint, we update cache per new excerpt scrolled"); + }); editor.update(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Next), cx, |s| { @@ -2658,26 +2656,26 @@ pub mod tests { )); cx.executor().run_until_parked(); let last_scroll_update_version = editor.update(cx, |editor, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - "other hint #3".to_string(), - "other hint #4".to_string(), - "other hint #5".to_string(), - ]; - assert_eq!(expected_hints, cached_hint_labels(editor), - "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - assert_eq!(editor.inlay_hint_cache().version, expected_hints.len()); - expected_hints.len() - }).unwrap(); + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + "other hint #4".to_string(), + "other hint #5".to_string(), + ]; + assert_eq!(expected_hints, cached_hint_labels(editor), + "After multibuffer was scrolled to the end, all hints for all excerpts should be fetched"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, expected_hints.len()); + expected_hints.len() + }).unwrap(); editor.update(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Next), cx, |s| { @@ -2686,30 +2684,31 @@ pub mod tests { }); cx.executor().run_until_parked(); editor.update(cx, |editor, cx| { - let expected_hints = vec![ - "main hint #0".to_string(), - "main hint #1".to_string(), - "main hint #2".to_string(), - "main hint #3".to_string(), - "main hint #4".to_string(), - "main hint #5".to_string(), - "other hint #0".to_string(), - "other hint #1".to_string(), - "other hint #2".to_string(), - "other hint #3".to_string(), - "other hint #4".to_string(), - "other hint #5".to_string(), - ]; - assert_eq!(expected_hints, cached_hint_labels(editor), - "After multibuffer was scrolled to the end, further scrolls up should not bring more hints"); - assert_eq!(expected_hints, visible_hint_labels(editor, cx)); - assert_eq!(editor.inlay_hint_cache().version, last_scroll_update_version, "No updates should happen during scrolling already scolled buffer"); - }); + let expected_hints = vec![ + "main hint #0".to_string(), + "main hint #1".to_string(), + "main hint #2".to_string(), + "main hint #3".to_string(), + "main hint #4".to_string(), + "main hint #5".to_string(), + "other hint #0".to_string(), + "other hint #1".to_string(), + "other hint #2".to_string(), + "other hint #3".to_string(), + "other hint #4".to_string(), + "other hint #5".to_string(), + ]; + assert_eq!(expected_hints, cached_hint_labels(editor), + "After multibuffer was scrolled to the end, further scrolls up should not bring more hints"); + assert_eq!(expected_hints, visible_hint_labels(editor, cx)); + assert_eq!(editor.inlay_hint_cache().version, last_scroll_update_version, "No updates should happen during scrolling already scolled buffer"); + }); editor_edited.store(true, Ordering::Release); editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { - s.select_ranges([Point::new(56, 0)..Point::new(56, 0)]) + // TODO if this gets set to hint boundary (e.g. 56) we sometimes get an extra cache version bump, why? + s.select_ranges([Point::new(57, 0)..Point::new(57, 0)]) }); editor.handle_input("++++more text++++", cx); }); @@ -2729,15 +2728,15 @@ pub mod tests { expected_hints, cached_hint_labels(editor), "After multibuffer edit, editor gets scolled back to the last selection; \ -all hints should be invalidated and requeried for all of its visible excerpts" + all hints should be invalidated and requeried for all of its visible excerpts" ); assert_eq!(expected_hints, visible_hint_labels(editor, cx)); let current_cache_version = editor.inlay_hint_cache().version; - let minimum_expected_version = last_scroll_update_version + expected_hints.len(); - assert!( - current_cache_version == minimum_expected_version || current_cache_version == minimum_expected_version + 1, - "Due to every excerpt having one hint, cache should update per new excerpt received + 1 potential sporadic update" + assert_eq!( + current_cache_version, + last_scroll_update_version + expected_hints.len(), + "We should have updated cache N times == N of new hints arrived (separately from each excerpt)" ); }); } diff --git a/crates/editor2/src/items.rs b/crates/editor2/src/items.rs index eca3b99d7807b455ba48ed99ef0287a9ee084abf..93bb37c6222932f395d738e4a8bac9ec20d7076c 100644 --- a/crates/editor2/src/items.rs +++ b/crates/editor2/src/items.rs @@ -4,13 +4,14 @@ use crate::{ EditorEvent, EditorSettings, ExcerptId, ExcerptRange, MultiBuffer, MultiBufferSnapshot, NavigationData, ToPoint as _, }; -use anyhow::{anyhow, Context, Result}; +use anyhow::{anyhow, Context as _, Result}; use collections::HashSet; use futures::future::try_join_all; use gpui::{ - div, point, AnyElement, AppContext, AsyncAppContext, Entity, EntityId, EventEmitter, - FocusHandle, Model, ParentElement, Pixels, SharedString, Styled, Subscription, Task, View, - ViewContext, VisualContext, WeakView, WindowContext, + div, point, AnyElement, AppContext, AsyncAppContext, AsyncWindowContext, Context, Div, Entity, + EntityId, EventEmitter, FocusHandle, IntoElement, Model, ParentElement, Pixels, Render, + SharedString, Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, + WindowContext, }; use language::{ proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, CharKind, OffsetRangeExt, @@ -20,6 +21,7 @@ use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPat use rpc::proto::{self, update_view, PeerId}; use settings::Settings; use smallvec::SmallVec; +use std::fmt::Write; use std::{ borrow::Cow, cmp::{self, Ordering}, @@ -31,8 +33,11 @@ use std::{ use text::Selection; use theme::{ActiveTheme, Theme}; use ui::{Color, Label}; -use util::{paths::PathExt, ResultExt, TryFutureExt}; -use workspace::item::{BreadcrumbText, FollowEvent, FollowableEvents, FollowableItemHandle}; +use util::{paths::PathExt, paths::FILE_ROW_COLUMN_DELIMITER, ResultExt, TryFutureExt}; +use workspace::{ + item::{BreadcrumbText, FollowEvent, FollowableEvents, FollowableItemHandle}, + StatusItemView, +}; use workspace::{ item::{FollowableItem, Item, ItemEvent, ItemHandle, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, @@ -71,110 +76,108 @@ impl FollowableItem for Editor { workspace: View, remote_id: ViewId, state: &mut Option, - cx: &mut AppContext, + cx: &mut WindowContext, ) -> Option>>> { - todo!() + let project = workspace.read(cx).project().to_owned(); + let Some(proto::view::Variant::Editor(_)) = state else { + return None; + }; + let Some(proto::view::Variant::Editor(state)) = state.take() else { + unreachable!() + }; + + let client = project.read(cx).client(); + let replica_id = project.read(cx).replica_id(); + let buffer_ids = state + .excerpts + .iter() + .map(|excerpt| excerpt.buffer_id) + .collect::>(); + let buffers = project.update(cx, |project, cx| { + buffer_ids + .iter() + .map(|id| project.open_buffer_by_id(*id, cx)) + .collect::>() + }); + + let pane = pane.downgrade(); + Some(cx.spawn(|mut cx| async move { + let mut buffers = futures::future::try_join_all(buffers).await?; + let editor = pane.update(&mut cx, |pane, cx| { + let mut editors = pane.items_of_type::(); + editors.find(|editor| { + let ids_match = editor.remote_id(&client, cx) == Some(remote_id); + let singleton_buffer_matches = state.singleton + && buffers.first() + == editor.read(cx).buffer.read(cx).as_singleton().as_ref(); + ids_match || singleton_buffer_matches + }) + })?; + + let editor = if let Some(editor) = editor { + editor + } else { + pane.update(&mut cx, |_, cx| { + let multibuffer = cx.build_model(|cx| { + let mut multibuffer; + if state.singleton && buffers.len() == 1 { + multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx) + } else { + multibuffer = MultiBuffer::new(replica_id); + let mut excerpts = state.excerpts.into_iter().peekable(); + while let Some(excerpt) = excerpts.peek() { + let buffer_id = excerpt.buffer_id; + let buffer_excerpts = iter::from_fn(|| { + let excerpt = excerpts.peek()?; + (excerpt.buffer_id == buffer_id) + .then(|| excerpts.next().unwrap()) + }); + let buffer = + buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id); + if let Some(buffer) = buffer { + multibuffer.push_excerpts( + buffer.clone(), + buffer_excerpts.filter_map(deserialize_excerpt_range), + cx, + ); + } + } + }; + + if let Some(title) = &state.title { + multibuffer = multibuffer.with_title(title.clone()) + } + + multibuffer + }); + + cx.build_view(|cx| { + let mut editor = + Editor::for_multibuffer(multibuffer, Some(project.clone()), cx); + editor.remote_id = Some(remote_id); + editor + }) + })? + }; + + update_editor_from_message( + editor.downgrade(), + project, + proto::update_view::Editor { + selections: state.selections, + pending_selection: state.pending_selection, + scroll_top_anchor: state.scroll_top_anchor, + scroll_x: state.scroll_x, + scroll_y: state.scroll_y, + ..Default::default() + }, + &mut cx, + ) + .await?; + + Ok(editor) + })) } - // let project = workspace.read(cx).project().to_owned(); - // let Some(proto::view::Variant::Editor(_)) = state else { - // return None; - // }; - // let Some(proto::view::Variant::Editor(state)) = state.take() else { - // unreachable!() - // }; - - // let client = project.read(cx).client(); - // let replica_id = project.read(cx).replica_id(); - // let buffer_ids = state - // .excerpts - // .iter() - // .map(|excerpt| excerpt.buffer_id) - // .collect::>(); - // let buffers = project.update(cx, |project, cx| { - // buffer_ids - // .iter() - // .map(|id| project.open_buffer_by_id(*id, cx)) - // .collect::>() - // }); - - // let pane = pane.downgrade(); - // Some(cx.spawn(|mut cx| async move { - // let mut buffers = futures::future::try_join_all(buffers).await?; - // let editor = pane.read_with(&cx, |pane, cx| { - // let mut editors = pane.items_of_type::(); - // editors.find(|editor| { - // let ids_match = editor.remote_id(&client, cx) == Some(remote_id); - // let singleton_buffer_matches = state.singleton - // && buffers.first() - // == editor.read(cx).buffer.read(cx).as_singleton().as_ref(); - // ids_match || singleton_buffer_matches - // }) - // })?; - - // let editor = if let Some(editor) = editor { - // editor - // } else { - // pane.update(&mut cx, |_, cx| { - // let multibuffer = cx.add_model(|cx| { - // let mut multibuffer; - // if state.singleton && buffers.len() == 1 { - // multibuffer = MultiBuffer::singleton(buffers.pop().unwrap(), cx) - // } else { - // multibuffer = MultiBuffer::new(replica_id); - // let mut excerpts = state.excerpts.into_iter().peekable(); - // while let Some(excerpt) = excerpts.peek() { - // let buffer_id = excerpt.buffer_id; - // let buffer_excerpts = iter::from_fn(|| { - // let excerpt = excerpts.peek()?; - // (excerpt.buffer_id == buffer_id) - // .then(|| excerpts.next().unwrap()) - // }); - // let buffer = - // buffers.iter().find(|b| b.read(cx).remote_id() == buffer_id); - // if let Some(buffer) = buffer { - // multibuffer.push_excerpts( - // buffer.clone(), - // buffer_excerpts.filter_map(deserialize_excerpt_range), - // cx, - // ); - // } - // } - // }; - - // if let Some(title) = &state.title { - // multibuffer = multibuffer.with_title(title.clone()) - // } - - // multibuffer - // }); - - // cx.add_view(|cx| { - // let mut editor = - // Editor::for_multibuffer(multibuffer, Some(project.clone()), cx); - // editor.remote_id = Some(remote_id); - // editor - // }) - // })? - // }; - - // update_editor_from_message( - // editor.downgrade(), - // project, - // proto::update_view::Editor { - // selections: state.selections, - // pending_selection: state.pending_selection, - // scroll_top_anchor: state.scroll_top_anchor, - // scroll_x: state.scroll_x, - // scroll_y: state.scroll_y, - // ..Default::default() - // }, - // &mut cx, - // ) - // .await?; - - // Ok(editor) - // })) - // } fn set_leader_peer_id(&mut self, leader_peer_id: Option, cx: &mut ViewContext) { self.leader_peer_id = leader_peer_id; @@ -195,7 +198,7 @@ impl FollowableItem for Editor { cx.notify(); } - fn to_state_proto(&self, cx: &AppContext) -> Option { + fn to_state_proto(&self, cx: &WindowContext) -> Option { let buffer = self.buffer.read(cx); let scroll_anchor = self.scroll_manager.anchor(); let excerpts = buffer @@ -242,7 +245,7 @@ impl FollowableItem for Editor { &self, event: &Self::FollowableEvent, update: &mut Option, - cx: &AppContext, + cx: &WindowContext, ) -> bool { let update = update.get_or_insert_with(|| proto::update_view::Variant::Editor(Default::default())); @@ -315,7 +318,7 @@ impl FollowableItem for Editor { }) } - fn is_project_item(&self, _cx: &AppContext) -> bool { + fn is_project_item(&self, _cx: &WindowContext) -> bool { true } } @@ -324,132 +327,129 @@ async fn update_editor_from_message( this: WeakView, project: Model, message: proto::update_view::Editor, - cx: &mut AsyncAppContext, + cx: &mut AsyncWindowContext, ) -> Result<()> { - todo!() + // Open all of the buffers of which excerpts were added to the editor. + let inserted_excerpt_buffer_ids = message + .inserted_excerpts + .iter() + .filter_map(|insertion| Some(insertion.excerpt.as_ref()?.buffer_id)) + .collect::>(); + let inserted_excerpt_buffers = project.update(cx, |project, cx| { + inserted_excerpt_buffer_ids + .into_iter() + .map(|id| project.open_buffer_by_id(id, cx)) + .collect::>() + })?; + let _inserted_excerpt_buffers = try_join_all(inserted_excerpt_buffers).await?; + + // Update the editor's excerpts. + this.update(cx, |editor, cx| { + editor.buffer.update(cx, |multibuffer, cx| { + let mut removed_excerpt_ids = message + .deleted_excerpts + .into_iter() + .map(ExcerptId::from_proto) + .collect::>(); + removed_excerpt_ids.sort_by({ + let multibuffer = multibuffer.read(cx); + move |a, b| a.cmp(&b, &multibuffer) + }); + + let mut insertions = message.inserted_excerpts.into_iter().peekable(); + while let Some(insertion) = insertions.next() { + let Some(excerpt) = insertion.excerpt else { + continue; + }; + let Some(previous_excerpt_id) = insertion.previous_excerpt_id else { + continue; + }; + let buffer_id = excerpt.buffer_id; + let Some(buffer) = project.read(cx).buffer_for_id(buffer_id) else { + continue; + }; + + let adjacent_excerpts = iter::from_fn(|| { + let insertion = insertions.peek()?; + if insertion.previous_excerpt_id.is_none() + && insertion.excerpt.as_ref()?.buffer_id == buffer_id + { + insertions.next()?.excerpt + } else { + None + } + }); + + multibuffer.insert_excerpts_with_ids_after( + ExcerptId::from_proto(previous_excerpt_id), + buffer, + [excerpt] + .into_iter() + .chain(adjacent_excerpts) + .filter_map(|excerpt| { + Some(( + ExcerptId::from_proto(excerpt.id), + deserialize_excerpt_range(excerpt)?, + )) + }), + cx, + ); + } + + multibuffer.remove_excerpts(removed_excerpt_ids, cx); + }); + })?; + + // Deserialize the editor state. + let (selections, pending_selection, scroll_top_anchor) = this.update(cx, |editor, cx| { + let buffer = editor.buffer.read(cx).read(cx); + let selections = message + .selections + .into_iter() + .filter_map(|selection| deserialize_selection(&buffer, selection)) + .collect::>(); + let pending_selection = message + .pending_selection + .and_then(|selection| deserialize_selection(&buffer, selection)); + let scroll_top_anchor = message + .scroll_top_anchor + .and_then(|anchor| deserialize_anchor(&buffer, anchor)); + anyhow::Ok((selections, pending_selection, scroll_top_anchor)) + })??; + + // Wait until the buffer has received all of the operations referenced by + // the editor's new state. + this.update(cx, |editor, cx| { + editor.buffer.update(cx, |buffer, cx| { + buffer.wait_for_anchors( + selections + .iter() + .chain(pending_selection.as_ref()) + .flat_map(|selection| [selection.start, selection.end]) + .chain(scroll_top_anchor), + cx, + ) + }) + })? + .await?; + + // Update the editor's state. + this.update(cx, |editor, cx| { + if !selections.is_empty() || pending_selection.is_some() { + editor.set_selections_from_remote(selections, pending_selection, cx); + editor.request_autoscroll_remotely(Autoscroll::newest(), cx); + } else if let Some(scroll_top_anchor) = scroll_top_anchor { + editor.set_scroll_anchor_remote( + ScrollAnchor { + anchor: scroll_top_anchor, + offset: point(message.scroll_x, message.scroll_y), + }, + cx, + ); + } + })?; + Ok(()) } -// Previous implementation of the above -// // Open all of the buffers of which excerpts were added to the editor. -// let inserted_excerpt_buffer_ids = message -// .inserted_excerpts -// .iter() -// .filter_map(|insertion| Some(insertion.excerpt.as_ref()?.buffer_id)) -// .collect::>(); -// let inserted_excerpt_buffers = project.update(cx, |project, cx| { -// inserted_excerpt_buffer_ids -// .into_iter() -// .map(|id| project.open_buffer_by_id(id, cx)) -// .collect::>() -// })?; -// let _inserted_excerpt_buffers = try_join_all(inserted_excerpt_buffers).await?; - -// // Update the editor's excerpts. -// this.update(cx, |editor, cx| { -// editor.buffer.update(cx, |multibuffer, cx| { -// let mut removed_excerpt_ids = message -// .deleted_excerpts -// .into_iter() -// .map(ExcerptId::from_proto) -// .collect::>(); -// removed_excerpt_ids.sort_by({ -// let multibuffer = multibuffer.read(cx); -// move |a, b| a.cmp(&b, &multibuffer) -// }); - -// let mut insertions = message.inserted_excerpts.into_iter().peekable(); -// while let Some(insertion) = insertions.next() { -// let Some(excerpt) = insertion.excerpt else { -// continue; -// }; -// let Some(previous_excerpt_id) = insertion.previous_excerpt_id else { -// continue; -// }; -// let buffer_id = excerpt.buffer_id; -// let Some(buffer) = project.read(cx).buffer_for_id(buffer_id) else { -// continue; -// }; - -// let adjacent_excerpts = iter::from_fn(|| { -// let insertion = insertions.peek()?; -// if insertion.previous_excerpt_id.is_none() -// && insertion.excerpt.as_ref()?.buffer_id == buffer_id -// { -// insertions.next()?.excerpt -// } else { -// None -// } -// }); - -// multibuffer.insert_excerpts_with_ids_after( -// ExcerptId::from_proto(previous_excerpt_id), -// buffer, -// [excerpt] -// .into_iter() -// .chain(adjacent_excerpts) -// .filter_map(|excerpt| { -// Some(( -// ExcerptId::from_proto(excerpt.id), -// deserialize_excerpt_range(excerpt)?, -// )) -// }), -// cx, -// ); -// } - -// multibuffer.remove_excerpts(removed_excerpt_ids, cx); -// }); -// })?; - -// // Deserialize the editor state. -// let (selections, pending_selection, scroll_top_anchor) = this.update(cx, |editor, cx| { -// let buffer = editor.buffer.read(cx).read(cx); -// let selections = message -// .selections -// .into_iter() -// .filter_map(|selection| deserialize_selection(&buffer, selection)) -// .collect::>(); -// let pending_selection = message -// .pending_selection -// .and_then(|selection| deserialize_selection(&buffer, selection)); -// let scroll_top_anchor = message -// .scroll_top_anchor -// .and_then(|anchor| deserialize_anchor(&buffer, anchor)); -// anyhow::Ok((selections, pending_selection, scroll_top_anchor)) -// })??; - -// // Wait until the buffer has received all of the operations referenced by -// // the editor's new state. -// this.update(cx, |editor, cx| { -// editor.buffer.update(cx, |buffer, cx| { -// buffer.wait_for_anchors( -// selections -// .iter() -// .chain(pending_selection.as_ref()) -// .flat_map(|selection| [selection.start, selection.end]) -// .chain(scroll_top_anchor), -// cx, -// ) -// }) -// })? -// .await?; - -// // Update the editor's state. -// this.update(cx, |editor, cx| { -// if !selections.is_empty() || pending_selection.is_some() { -// editor.set_selections_from_remote(selections, pending_selection, cx); -// editor.request_autoscroll_remotely(Autoscroll::newest(), cx); -// } else if let Some(scroll_top_anchor) = scroll_top_anchor { -// editor.set_scroll_anchor_remote( -// ScrollAnchor { -// anchor: scroll_top_anchor, -// offset: point(message.scroll_x, message.scroll_y), -// }, -// cx, -// ); -// } -// })?; -// Ok(()) -// } fn serialize_excerpt( buffer_id: u64, @@ -529,39 +529,38 @@ fn deserialize_anchor(buffer: &MultiBufferSnapshot, anchor: proto::EditorAnchor) impl Item for Editor { fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { - todo!(); - // if let Ok(data) = data.downcast::() { - // let newest_selection = self.selections.newest::(cx); - // let buffer = self.buffer.read(cx).read(cx); - // let offset = if buffer.can_resolve(&data.cursor_anchor) { - // data.cursor_anchor.to_point(&buffer) - // } else { - // buffer.clip_point(data.cursor_position, Bias::Left) - // }; - - // let mut scroll_anchor = data.scroll_anchor; - // if !buffer.can_resolve(&scroll_anchor.anchor) { - // scroll_anchor.anchor = buffer.anchor_before( - // buffer.clip_point(Point::new(data.scroll_top_row, 0), Bias::Left), - // ); - // } - - // drop(buffer); - - // if newest_selection.head() == offset { - // false - // } else { - // let nav_history = self.nav_history.take(); - // self.set_scroll_anchor(scroll_anchor, cx); - // self.change_selections(Some(Autoscroll::fit()), cx, |s| { - // s.select_ranges([offset..offset]) - // }); - // self.nav_history = nav_history; - // true - // } - // } else { - // false - // } + if let Ok(data) = data.downcast::() { + let newest_selection = self.selections.newest::(cx); + let buffer = self.buffer.read(cx).read(cx); + let offset = if buffer.can_resolve(&data.cursor_anchor) { + data.cursor_anchor.to_point(&buffer) + } else { + buffer.clip_point(data.cursor_position, Bias::Left) + }; + + let mut scroll_anchor = data.scroll_anchor; + if !buffer.can_resolve(&scroll_anchor.anchor) { + scroll_anchor.anchor = buffer.anchor_before( + buffer.clip_point(Point::new(data.scroll_top_row, 0), Bias::Left), + ); + } + + drop(buffer); + + if newest_selection.head() == offset { + false + } else { + let nav_history = self.nav_history.take(); + self.set_scroll_anchor(scroll_anchor, cx); + self.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.select_ranges([offset..offset]) + }); + self.nav_history = nav_history; + true + } + } else { + false + } } fn tab_tooltip_text(&self, cx: &AppContext) -> Option { @@ -765,35 +764,34 @@ impl Item for Editor { } fn breadcrumbs(&self, variant: &Theme, cx: &AppContext) -> Option> { - todo!(); - // let cursor = self.selections.newest_anchor().head(); - // let multibuffer = &self.buffer().read(cx); - // let (buffer_id, symbols) = - // multibuffer.symbols_containing(cursor, Some(&theme.editor.syntax), cx)?; - // let buffer = multibuffer.buffer(buffer_id)?; - - // let buffer = buffer.read(cx); - // let filename = buffer - // .snapshot() - // .resolve_file_path( - // cx, - // self.project - // .as_ref() - // .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) - // .unwrap_or_default(), - // ) - // .map(|path| path.to_string_lossy().to_string()) - // .unwrap_or_else(|| "untitled".to_string()); - - // let mut breadcrumbs = vec![BreadcrumbText { - // text: filename, - // highlights: None, - // }]; - // breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText { - // text: symbol.text, - // highlights: Some(symbol.highlight_ranges), - // })); - // Some(breadcrumbs) + let cursor = self.selections.newest_anchor().head(); + let multibuffer = &self.buffer().read(cx); + let (buffer_id, symbols) = + multibuffer.symbols_containing(cursor, Some(&variant.syntax()), cx)?; + let buffer = multibuffer.buffer(buffer_id)?; + + let buffer = buffer.read(cx); + let filename = buffer + .snapshot() + .resolve_file_path( + cx, + self.project + .as_ref() + .map(|project| project.read(cx).visible_worktrees(cx).count() > 1) + .unwrap_or_default(), + ) + .map(|path| path.to_string_lossy().to_string()) + .unwrap_or_else(|| "untitled".to_string()); + + let mut breadcrumbs = vec![BreadcrumbText { + text: filename, + highlights: None, + }]; + breadcrumbs.extend(symbols.into_iter().map(|symbol| BreadcrumbText { + text: symbol.text, + highlights: Some(symbol.highlight_ranges), + })); + Some(breadcrumbs) } fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { @@ -1120,86 +1118,78 @@ pub struct CursorPosition { _observe_active_editor: Option, } -// impl Default for CursorPosition { -// fn default() -> Self { -// Self::new() -// } -// } - -// impl CursorPosition { -// pub fn new() -> Self { -// Self { -// position: None, -// selected_count: 0, -// _observe_active_editor: None, -// } -// } - -// fn update_position(&mut self, editor: View, cx: &mut ViewContext) { -// let editor = editor.read(cx); -// let buffer = editor.buffer().read(cx).snapshot(cx); - -// self.selected_count = 0; -// let mut last_selection: Option> = None; -// for selection in editor.selections.all::(cx) { -// self.selected_count += selection.end - selection.start; -// if last_selection -// .as_ref() -// .map_or(true, |last_selection| selection.id > last_selection.id) -// { -// last_selection = Some(selection); -// } -// } -// self.position = last_selection.map(|s| s.head().to_point(&buffer)); - -// cx.notify(); -// } -// } - -// impl Entity for CursorPosition { -// type Event = (); -// } - -// impl View for CursorPosition { -// fn ui_name() -> &'static str { -// "CursorPosition" -// } - -// fn render(&mut self, cx: &mut ViewContext) -> AnyElement { -// if let Some(position) = self.position { -// let theme = &theme::current(cx).workspace.status_bar; -// let mut text = format!( -// "{}{FILE_ROW_COLUMN_DELIMITER}{}", -// position.row + 1, -// position.column + 1 -// ); -// if self.selected_count > 0 { -// write!(text, " ({} selected)", self.selected_count).unwrap(); -// } -// Label::new(text, theme.cursor_position.clone()).into_any() -// } else { -// Empty::new().into_any() -// } -// } -// } - -// impl StatusItemView for CursorPosition { -// fn set_active_pane_item( -// &mut self, -// active_pane_item: Option<&dyn ItemHandle>, -// cx: &mut ViewContext, -// ) { -// if let Some(editor) = active_pane_item.and_then(|item| item.act_as::(cx)) { -// self._observe_active_editor = Some(cx.observe(&editor, Self::update_position)); -// self.update_position(editor, cx); -// } else { -// self.position = None; -// self._observe_active_editor = None; -// } - -// cx.notify(); -// } -// } +impl Default for CursorPosition { + fn default() -> Self { + Self::new() + } +} + +impl CursorPosition { + pub fn new() -> Self { + Self { + position: None, + selected_count: 0, + _observe_active_editor: None, + } + } + + fn update_position(&mut self, editor: View, cx: &mut ViewContext) { + let editor = editor.read(cx); + let buffer = editor.buffer().read(cx).snapshot(cx); + + self.selected_count = 0; + let mut last_selection: Option> = None; + for selection in editor.selections.all::(cx) { + self.selected_count += selection.end - selection.start; + if last_selection + .as_ref() + .map_or(true, |last_selection| selection.id > last_selection.id) + { + last_selection = Some(selection); + } + } + self.position = last_selection.map(|s| s.head().to_point(&buffer)); + + cx.notify(); + } +} + +impl Render for CursorPosition { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + div().when_some(self.position, |el, position| { + let mut text = format!( + "{}{FILE_ROW_COLUMN_DELIMITER}{}", + position.row + 1, + position.column + 1 + ); + if self.selected_count > 0 { + write!(text, " ({} selected)", self.selected_count).unwrap(); + } + + el.child(Label::new(text)) + }) + } +} + +impl StatusItemView for CursorPosition { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) { + if let Some(editor) = active_pane_item.and_then(|item| item.act_as::(cx)) { + self._observe_active_editor = Some(cx.observe(&editor, Self::update_position)); + self.update_position(editor, cx); + } else { + self.position = None; + self._observe_active_editor = None; + } + + cx.notify(); + } +} fn path_for_buffer<'a>( buffer: &Model, diff --git a/crates/editor2/src/link_go_to_definition.rs b/crates/editor2/src/link_go_to_definition.rs index 092882573c59961dc9e6cba6ee65aa022367107d..60c966d4c7cf68ea0e420ee3d7270be1d671cbff 100644 --- a/crates/editor2/src/link_go_to_definition.rs +++ b/crates/editor2/src/link_go_to_definition.rs @@ -608,671 +608,672 @@ fn go_to_fetched_definition_of_kind( } } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::{ -// display_map::ToDisplayPoint, -// editor_tests::init_test, -// inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels}, -// test::editor_lsp_test_context::EditorLspTestContext, -// }; -// use futures::StreamExt; -// use gpui::{ -// platform::{self, Modifiers, ModifiersChangedEvent}, -// View, -// }; -// use indoc::indoc; -// use language::language_settings::InlayHintSettings; -// use lsp::request::{GotoDefinition, GotoTypeDefinition}; -// use util::assert_set_eq; - -// #[gpui::test] -// async fn test_link_go_to_type_definition(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let mut cx = EditorLspTestContext::new_rust( -// lsp::ServerCapabilities { -// hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), -// type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)), -// ..Default::default() -// }, -// cx, -// ) -// .await; - -// cx.set_state(indoc! {" -// struct A; -// let vˇariable = A; -// "}); - -// // Basic hold cmd+shift, expect highlight in region if response contains type definition -// let hover_point = cx.display_point(indoc! {" -// struct A; -// let vˇariable = A; -// "}); -// let symbol_range = cx.lsp_range(indoc! {" -// struct A; -// let «variable» = A; -// "}); -// let target_range = cx.lsp_range(indoc! {" -// struct «A»; -// let variable = A; -// "}); - -// let mut requests = -// cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// origin_selection_range: Some(symbol_range), -// target_uri: url.clone(), -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); - -// // Press cmd+shift to trigger highlight -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// true, -// cx, -// ); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); -// cx.assert_editor_text_highlights::(indoc! {" -// struct A; -// let «variable» = A; -// "}); - -// // Unpress shift causes highlight to go away (normal goto-definition is not valid here) -// cx.update_editor(|editor, cx| { -// editor.modifiers_changed( -// &platform::ModifiersChangedEvent { -// modifiers: Modifiers { -// cmd: true, -// ..Default::default() -// }, -// ..Default::default() -// }, -// cx, -// ); -// }); -// // Assert no link highlights -// cx.assert_editor_text_highlights::(indoc! {" -// struct A; -// let variable = A; -// "}); - -// // Cmd+shift click without existing definition requests and jumps -// let hover_point = cx.display_point(indoc! {" -// struct A; -// let vˇariable = A; -// "}); -// let target_range = cx.lsp_range(indoc! {" -// struct «A»; -// let variable = A; -// "}); - -// let mut requests = -// cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// origin_selection_range: None, -// target_uri: url, -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); - -// cx.update_editor(|editor, cx| { -// go_to_fetched_type_definition(editor, PointForPosition::valid(hover_point), false, cx); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); - -// cx.assert_editor_state(indoc! {" -// struct «Aˇ»; -// let variable = A; -// "}); -// } - -// #[gpui::test] -// async fn test_link_go_to_definition(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); - -// let mut cx = EditorLspTestContext::new_rust( -// lsp::ServerCapabilities { -// hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), -// ..Default::default() -// }, -// cx, -// ) -// .await; - -// cx.set_state(indoc! {" -// fn ˇtest() { do_work(); } -// fn do_work() { test(); } -// "}); - -// // Basic hold cmd, expect highlight in region if response contains definition -// let hover_point = cx.display_point(indoc! {" -// fn test() { do_wˇork(); } -// fn do_work() { test(); } -// "}); -// let symbol_range = cx.lsp_range(indoc! {" -// fn test() { «do_work»(); } -// fn do_work() { test(); } -// "}); -// let target_range = cx.lsp_range(indoc! {" -// fn test() { do_work(); } -// fn «do_work»() { test(); } -// "}); - -// let mut requests = cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// origin_selection_range: Some(symbol_range), -// target_uri: url.clone(), -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); - -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// false, -// cx, -// ); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { «do_work»(); } -// fn do_work() { test(); } -// "}); - -// // Unpress cmd causes highlight to go away -// cx.update_editor(|editor, cx| { -// editor.modifiers_changed(&Default::default(), cx); -// }); - -// // Assert no link highlights -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { test(); } -// "}); - -// // Response without source range still highlights word -// cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_trigger_point = None); -// let mut requests = cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// // No origin range -// origin_selection_range: None, -// target_uri: url.clone(), -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// false, -// cx, -// ); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); - -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { «do_work»(); } -// fn do_work() { test(); } -// "}); - -// // Moving mouse to location with no response dismisses highlight -// let hover_point = cx.display_point(indoc! {" -// fˇn test() { do_work(); } -// fn do_work() { test(); } -// "}); -// let mut requests = cx -// .lsp -// .handle_request::(move |_, _| async move { -// // No definitions returned -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![]))) -// }); -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// false, -// cx, -// ); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); - -// // Assert no link highlights -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { test(); } -// "}); - -// // Move mouse without cmd and then pressing cmd triggers highlight -// let hover_point = cx.display_point(indoc! {" -// fn test() { do_work(); } -// fn do_work() { teˇst(); } -// "}); -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// false, -// false, -// cx, -// ); -// }); -// cx.foreground().run_until_parked(); - -// // Assert no link highlights -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { test(); } -// "}); - -// let symbol_range = cx.lsp_range(indoc! {" -// fn test() { do_work(); } -// fn do_work() { «test»(); } -// "}); -// let target_range = cx.lsp_range(indoc! {" -// fn «test»() { do_work(); } -// fn do_work() { test(); } -// "}); - -// let mut requests = cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// origin_selection_range: Some(symbol_range), -// target_uri: url, -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); -// cx.update_editor(|editor, cx| { -// editor.modifiers_changed( -// &ModifiersChangedEvent { -// modifiers: Modifiers { -// cmd: true, -// ..Default::default() -// }, -// }, -// cx, -// ); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); - -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { «test»(); } -// "}); - -// // Deactivating the window dismisses the highlight -// cx.update_workspace(|workspace, cx| { -// workspace.on_window_activation_changed(false, cx); -// }); -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { test(); } -// "}); - -// // Moving the mouse restores the highlights. -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// false, -// cx, -// ); -// }); -// cx.foreground().run_until_parked(); -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { «test»(); } -// "}); - -// // Moving again within the same symbol range doesn't re-request -// let hover_point = cx.display_point(indoc! {" -// fn test() { do_work(); } -// fn do_work() { tesˇt(); } -// "}); -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// false, -// cx, -// ); -// }); -// cx.foreground().run_until_parked(); -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { «test»(); } -// "}); - -// // Cmd click with existing definition doesn't re-request and dismisses highlight -// cx.update_editor(|editor, cx| { -// go_to_fetched_definition(editor, PointForPosition::valid(hover_point), false, cx); -// }); -// // Assert selection moved to to definition -// cx.lsp -// .handle_request::(move |_, _| async move { -// // Empty definition response to make sure we aren't hitting the lsp and using -// // the cached location instead -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![]))) -// }); -// cx.foreground().run_until_parked(); -// cx.assert_editor_state(indoc! {" -// fn «testˇ»() { do_work(); } -// fn do_work() { test(); } -// "}); - -// // Assert no link highlights after jump -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { test(); } -// "}); - -// // Cmd click without existing definition requests and jumps -// let hover_point = cx.display_point(indoc! {" -// fn test() { do_wˇork(); } -// fn do_work() { test(); } -// "}); -// let target_range = cx.lsp_range(indoc! {" -// fn test() { do_work(); } -// fn «do_work»() { test(); } -// "}); - -// let mut requests = cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// origin_selection_range: None, -// target_uri: url, -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); -// cx.update_editor(|editor, cx| { -// go_to_fetched_definition(editor, PointForPosition::valid(hover_point), false, cx); -// }); -// requests.next().await; -// cx.foreground().run_until_parked(); -// cx.assert_editor_state(indoc! {" -// fn test() { do_work(); } -// fn «do_workˇ»() { test(); } -// "}); - -// // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens -// // 2. Selection is completed, hovering -// let hover_point = cx.display_point(indoc! {" -// fn test() { do_wˇork(); } -// fn do_work() { test(); } -// "}); -// let target_range = cx.lsp_range(indoc! {" -// fn test() { do_work(); } -// fn «do_work»() { test(); } -// "}); -// let mut requests = cx.handle_request::(move |url, _, _| async move { -// Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ -// lsp::LocationLink { -// origin_selection_range: None, -// target_uri: url, -// target_range, -// target_selection_range: target_range, -// }, -// ]))) -// }); - -// // create a pending selection -// let selection_range = cx.ranges(indoc! {" -// fn «test() { do_w»ork(); } -// fn do_work() { test(); } -// "})[0] -// .clone(); -// cx.update_editor(|editor, cx| { -// let snapshot = editor.buffer().read(cx).snapshot(cx); -// let anchor_range = snapshot.anchor_before(selection_range.start) -// ..snapshot.anchor_after(selection_range.end); -// editor.change_selections(Some(crate::Autoscroll::fit()), cx, |s| { -// s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character) -// }); -// }); -// cx.update_editor(|editor, cx| { -// update_go_to_definition_link( -// editor, -// Some(GoToDefinitionTrigger::Text(hover_point)), -// true, -// false, -// cx, -// ); -// }); -// cx.foreground().run_until_parked(); -// assert!(requests.try_next().is_err()); -// cx.assert_editor_text_highlights::(indoc! {" -// fn test() { do_work(); } -// fn do_work() { test(); } -// "}); -// cx.foreground().run_until_parked(); -// } - -// #[gpui::test] -// async fn test_link_go_to_inlay(cx: &mut gpui::TestAppContext) { -// init_test(cx, |settings| { -// settings.defaults.inlay_hints = Some(InlayHintSettings { -// enabled: true, -// show_type_hints: true, -// show_parameter_hints: true, -// show_other_hints: true, -// }) -// }); - -// let mut cx = EditorLspTestContext::new_rust( -// lsp::ServerCapabilities { -// inlay_hint_provider: Some(lsp::OneOf::Left(true)), -// ..Default::default() -// }, -// cx, -// ) -// .await; -// cx.set_state(indoc! {" -// struct TestStruct; - -// fn main() { -// let variableˇ = TestStruct; -// } -// "}); -// let hint_start_offset = cx.ranges(indoc! {" -// struct TestStruct; - -// fn main() { -// let variableˇ = TestStruct; -// } -// "})[0] -// .start; -// let hint_position = cx.to_lsp(hint_start_offset); -// let target_range = cx.lsp_range(indoc! {" -// struct «TestStruct»; - -// fn main() { -// let variable = TestStruct; -// } -// "}); - -// let expected_uri = cx.buffer_lsp_url.clone(); -// let hint_label = ": TestStruct"; -// cx.lsp -// .handle_request::(move |params, _| { -// let expected_uri = expected_uri.clone(); -// async move { -// assert_eq!(params.text_document.uri, expected_uri); -// Ok(Some(vec![lsp::InlayHint { -// position: hint_position, -// label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart { -// value: hint_label.to_string(), -// location: Some(lsp::Location { -// uri: params.text_document.uri, -// range: target_range, -// }), -// ..Default::default() -// }]), -// kind: Some(lsp::InlayHintKind::TYPE), -// text_edits: None, -// tooltip: None, -// padding_left: Some(false), -// padding_right: Some(false), -// data: None, -// }])) -// } -// }) -// .next() -// .await; -// cx.foreground().run_until_parked(); -// cx.update_editor(|editor, cx| { -// let expected_layers = vec![hint_label.to_string()]; -// assert_eq!(expected_layers, cached_hint_labels(editor)); -// assert_eq!(expected_layers, visible_hint_labels(editor, cx)); -// }); - -// let inlay_range = cx -// .ranges(indoc! {" -// struct TestStruct; - -// fn main() { -// let variable« »= TestStruct; -// } -// "}) -// .get(0) -// .cloned() -// .unwrap(); -// let hint_hover_position = cx.update_editor(|editor, cx| { -// let snapshot = editor.snapshot(cx); -// let previous_valid = inlay_range.start.to_display_point(&snapshot); -// let next_valid = inlay_range.end.to_display_point(&snapshot); -// assert_eq!(previous_valid.row(), next_valid.row()); -// assert!(previous_valid.column() < next_valid.column()); -// let exact_unclipped = DisplayPoint::new( -// previous_valid.row(), -// previous_valid.column() + (hint_label.len() / 2) as u32, -// ); -// PointForPosition { -// previous_valid, -// next_valid, -// exact_unclipped, -// column_overshoot_after_line_end: 0, -// } -// }); -// // Press cmd to trigger highlight -// cx.update_editor(|editor, cx| { -// update_inlay_link_and_hover_points( -// &editor.snapshot(cx), -// hint_hover_position, -// editor, -// true, -// false, -// cx, -// ); -// }); -// cx.foreground().run_until_parked(); -// cx.update_editor(|editor, cx| { -// let snapshot = editor.snapshot(cx); -// let actual_highlights = snapshot -// .inlay_highlights::() -// .into_iter() -// .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight)) -// .collect::>(); - -// let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); -// let expected_highlight = InlayHighlight { -// inlay: InlayId::Hint(0), -// inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right), -// range: 0..hint_label.len(), -// }; -// assert_set_eq!(actual_highlights, vec![&expected_highlight]); -// }); - -// // Unpress cmd causes highlight to go away -// cx.update_editor(|editor, cx| { -// editor.modifiers_changed( -// &platform::ModifiersChangedEvent { -// modifiers: Modifiers { -// cmd: false, -// ..Default::default() -// }, -// ..Default::default() -// }, -// cx, -// ); -// }); -// // Assert no link highlights -// cx.update_editor(|editor, cx| { -// let snapshot = editor.snapshot(cx); -// let actual_ranges = snapshot -// .text_highlight_ranges::() -// .map(|ranges| ranges.as_ref().clone().1) -// .unwrap_or_default(); - -// assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}"); -// }); - -// // Cmd+click without existing definition requests and jumps -// cx.update_editor(|editor, cx| { -// editor.modifiers_changed( -// &platform::ModifiersChangedEvent { -// modifiers: Modifiers { -// cmd: true, -// ..Default::default() -// }, -// ..Default::default() -// }, -// cx, -// ); -// update_inlay_link_and_hover_points( -// &editor.snapshot(cx), -// hint_hover_position, -// editor, -// true, -// false, -// cx, -// ); -// }); -// cx.foreground().run_until_parked(); -// cx.update_editor(|editor, cx| { -// go_to_fetched_type_definition(editor, hint_hover_position, false, cx); -// }); -// cx.foreground().run_until_parked(); -// cx.assert_editor_state(indoc! {" -// struct «TestStructˇ»; - -// fn main() { -// let variable = TestStruct; -// } -// "}); -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + display_map::ToDisplayPoint, + editor_tests::init_test, + inlay_hint_cache::tests::{cached_hint_labels, visible_hint_labels}, + test::editor_lsp_test_context::EditorLspTestContext, + }; + use futures::StreamExt; + use gpui::{Modifiers, ModifiersChangedEvent, View}; + use indoc::indoc; + use language::language_settings::InlayHintSettings; + use lsp::request::{GotoDefinition, GotoTypeDefinition}; + use util::assert_set_eq; + + #[gpui::test] + async fn test_link_go_to_type_definition(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + type_definition_provider: Some(lsp::TypeDefinitionProviderCapability::Simple(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + struct A; + let vˇariable = A; + "}); + + // Basic hold cmd+shift, expect highlight in region if response contains type definition + let hover_point = cx.display_point(indoc! {" + struct A; + let vˇariable = A; + "}); + let symbol_range = cx.lsp_range(indoc! {" + struct A; + let «variable» = A; + "}); + let target_range = cx.lsp_range(indoc! {" + struct «A»; + let variable = A; + "}); + + let mut requests = + cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + // Press cmd+shift to trigger highlight + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + true, + cx, + ); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + cx.assert_editor_text_highlights::(indoc! {" + struct A; + let «variable» = A; + "}); + + // Unpress shift causes highlight to go away (normal goto-definition is not valid here) + cx.update_editor(|editor, cx| { + crate::element::EditorElement::modifiers_changed( + editor, + &ModifiersChangedEvent { + modifiers: Modifiers { + command: true, + ..Default::default() + }, + ..Default::default() + }, + cx, + ); + }); + // Assert no link highlights + cx.assert_editor_text_highlights::(indoc! {" + struct A; + let variable = A; + "}); + + // Cmd+shift click without existing definition requests and jumps + let hover_point = cx.display_point(indoc! {" + struct A; + let vˇariable = A; + "}); + let target_range = cx.lsp_range(indoc! {" + struct «A»; + let variable = A; + "}); + + let mut requests = + cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoTypeDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: None, + target_uri: url, + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + cx.update_editor(|editor, cx| { + go_to_fetched_type_definition(editor, PointForPosition::valid(hover_point), false, cx); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + cx.assert_editor_state(indoc! {" + struct «Aˇ»; + let variable = A; + "}); + } + + #[gpui::test] + async fn test_link_go_to_definition(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {" + fn ˇtest() { do_work(); } + fn do_work() { test(); } + "}); + + // Basic hold cmd, expect highlight in region if response contains definition + let hover_point = cx.display_point(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let symbol_range = cx.lsp_range(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + + let mut requests = cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + false, + cx, + ); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + cx.assert_editor_text_highlights::(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + + // Unpress cmd causes highlight to go away + cx.update_editor(|editor, cx| { + crate::element::EditorElement::modifiers_changed(editor, &Default::default(), cx); + }); + + // Assert no link highlights + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { test(); } + "}); + + // Response without source range still highlights word + cx.update_editor(|editor, _| editor.link_go_to_definition_state.last_trigger_point = None); + let mut requests = cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + // No origin range + origin_selection_range: None, + target_uri: url.clone(), + target_range, + target_selection_range: target_range, + }, + ]))) + }); + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + false, + cx, + ); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + cx.assert_editor_text_highlights::(indoc! {" + fn test() { «do_work»(); } + fn do_work() { test(); } + "}); + + // Moving mouse to location with no response dismisses highlight + let hover_point = cx.display_point(indoc! {" + fˇn test() { do_work(); } + fn do_work() { test(); } + "}); + let mut requests = cx + .lsp + .handle_request::(move |_, _| async move { + // No definitions returned + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![]))) + }); + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + false, + cx, + ); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + // Assert no link highlights + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { test(); } + "}); + + // Move mouse without cmd and then pressing cmd triggers highlight + let hover_point = cx.display_point(indoc! {" + fn test() { do_work(); } + fn do_work() { teˇst(); } + "}); + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + false, + false, + cx, + ); + }); + cx.background_executor.run_until_parked(); + + // Assert no link highlights + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { test(); } + "}); + + let symbol_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn do_work() { «test»(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn «test»() { do_work(); } + fn do_work() { test(); } + "}); + + let mut requests = cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: Some(symbol_range), + target_uri: url, + target_range, + target_selection_range: target_range, + }, + ]))) + }); + cx.update_editor(|editor, cx| { + crate::element::EditorElement::modifiers_changed( + editor, + &ModifiersChangedEvent { + modifiers: Modifiers { + command: true, + ..Default::default() + }, + }, + cx, + ); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { «test»(); } + "}); + + // Deactivating the window dismisses the highlight + cx.update_workspace(|workspace, cx| { + workspace.on_window_activation_changed(cx); + }); + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { test(); } + "}); + + // Moving the mouse restores the highlights. + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + false, + cx, + ); + }); + cx.background_executor.run_until_parked(); + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { «test»(); } + "}); + + // Moving again within the same symbol range doesn't re-request + let hover_point = cx.display_point(indoc! {" + fn test() { do_work(); } + fn do_work() { tesˇt(); } + "}); + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + false, + cx, + ); + }); + cx.background_executor.run_until_parked(); + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { «test»(); } + "}); + + // Cmd click with existing definition doesn't re-request and dismisses highlight + cx.update_editor(|editor, cx| { + go_to_fetched_definition(editor, PointForPosition::valid(hover_point), false, cx); + }); + // Assert selection moved to to definition + cx.lsp + .handle_request::(move |_, _| async move { + // Empty definition response to make sure we aren't hitting the lsp and using + // the cached location instead + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![]))) + }); + cx.background_executor.run_until_parked(); + cx.assert_editor_state(indoc! {" + fn «testˇ»() { do_work(); } + fn do_work() { test(); } + "}); + + // Assert no link highlights after jump + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { test(); } + "}); + + // Cmd click without existing definition requests and jumps + let hover_point = cx.display_point(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + + let mut requests = cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: None, + target_uri: url, + target_range, + target_selection_range: target_range, + }, + ]))) + }); + cx.update_editor(|editor, cx| { + go_to_fetched_definition(editor, PointForPosition::valid(hover_point), false, cx); + }); + requests.next().await; + cx.background_executor.run_until_parked(); + cx.assert_editor_state(indoc! {" + fn test() { do_work(); } + fn «do_workˇ»() { test(); } + "}); + + // 1. We have a pending selection, mouse point is over a symbol that we have a response for, hitting cmd and nothing happens + // 2. Selection is completed, hovering + let hover_point = cx.display_point(indoc! {" + fn test() { do_wˇork(); } + fn do_work() { test(); } + "}); + let target_range = cx.lsp_range(indoc! {" + fn test() { do_work(); } + fn «do_work»() { test(); } + "}); + let mut requests = cx.handle_request::(move |url, _, _| async move { + Ok(Some(lsp::GotoDefinitionResponse::Link(vec![ + lsp::LocationLink { + origin_selection_range: None, + target_uri: url, + target_range, + target_selection_range: target_range, + }, + ]))) + }); + + // create a pending selection + let selection_range = cx.ranges(indoc! {" + fn «test() { do_w»ork(); } + fn do_work() { test(); } + "})[0] + .clone(); + cx.update_editor(|editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let anchor_range = snapshot.anchor_before(selection_range.start) + ..snapshot.anchor_after(selection_range.end); + editor.change_selections(Some(crate::Autoscroll::fit()), cx, |s| { + s.set_pending_anchor_range(anchor_range, crate::SelectMode::Character) + }); + }); + cx.update_editor(|editor, cx| { + update_go_to_definition_link( + editor, + Some(GoToDefinitionTrigger::Text(hover_point)), + true, + false, + cx, + ); + }); + cx.background_executor.run_until_parked(); + assert!(requests.try_next().is_err()); + cx.assert_editor_text_highlights::(indoc! {" + fn test() { do_work(); } + fn do_work() { test(); } + "}); + cx.background_executor.run_until_parked(); + } + + #[gpui::test] + async fn test_link_go_to_inlay(cx: &mut gpui::TestAppContext) { + init_test(cx, |settings| { + settings.defaults.inlay_hints = Some(InlayHintSettings { + enabled: true, + show_type_hints: true, + show_parameter_hints: true, + show_other_hints: true, + }) + }); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + inlay_hint_provider: Some(lsp::OneOf::Left(true)), + ..Default::default() + }, + cx, + ) + .await; + cx.set_state(indoc! {" + struct TestStruct; + + fn main() { + let variableˇ = TestStruct; + } + "}); + let hint_start_offset = cx.ranges(indoc! {" + struct TestStruct; + + fn main() { + let variableˇ = TestStruct; + } + "})[0] + .start; + let hint_position = cx.to_lsp(hint_start_offset); + let target_range = cx.lsp_range(indoc! {" + struct «TestStruct»; + + fn main() { + let variable = TestStruct; + } + "}); + + let expected_uri = cx.buffer_lsp_url.clone(); + let hint_label = ": TestStruct"; + cx.lsp + .handle_request::(move |params, _| { + let expected_uri = expected_uri.clone(); + async move { + assert_eq!(params.text_document.uri, expected_uri); + Ok(Some(vec![lsp::InlayHint { + position: hint_position, + label: lsp::InlayHintLabel::LabelParts(vec![lsp::InlayHintLabelPart { + value: hint_label.to_string(), + location: Some(lsp::Location { + uri: params.text_document.uri, + range: target_range, + }), + ..Default::default() + }]), + kind: Some(lsp::InlayHintKind::TYPE), + text_edits: None, + tooltip: None, + padding_left: Some(false), + padding_right: Some(false), + data: None, + }])) + } + }) + .next() + .await; + cx.background_executor.run_until_parked(); + cx.update_editor(|editor, cx| { + let expected_layers = vec![hint_label.to_string()]; + assert_eq!(expected_layers, cached_hint_labels(editor)); + assert_eq!(expected_layers, visible_hint_labels(editor, cx)); + }); + + let inlay_range = cx + .ranges(indoc! {" + struct TestStruct; + + fn main() { + let variable« »= TestStruct; + } + "}) + .get(0) + .cloned() + .unwrap(); + let hint_hover_position = cx.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); + let previous_valid = inlay_range.start.to_display_point(&snapshot); + let next_valid = inlay_range.end.to_display_point(&snapshot); + assert_eq!(previous_valid.row(), next_valid.row()); + assert!(previous_valid.column() < next_valid.column()); + let exact_unclipped = DisplayPoint::new( + previous_valid.row(), + previous_valid.column() + (hint_label.len() / 2) as u32, + ); + PointForPosition { + previous_valid, + next_valid, + exact_unclipped, + column_overshoot_after_line_end: 0, + } + }); + // Press cmd to trigger highlight + cx.update_editor(|editor, cx| { + update_inlay_link_and_hover_points( + &editor.snapshot(cx), + hint_hover_position, + editor, + true, + false, + cx, + ); + }); + cx.background_executor.run_until_parked(); + cx.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); + let actual_highlights = snapshot + .inlay_highlights::() + .into_iter() + .flat_map(|highlights| highlights.values().map(|(_, highlight)| highlight)) + .collect::>(); + + let buffer_snapshot = editor.buffer().update(cx, |buffer, cx| buffer.snapshot(cx)); + let expected_highlight = InlayHighlight { + inlay: InlayId::Hint(0), + inlay_position: buffer_snapshot.anchor_at(inlay_range.start, Bias::Right), + range: 0..hint_label.len(), + }; + assert_set_eq!(actual_highlights, vec![&expected_highlight]); + }); + + // Unpress cmd causes highlight to go away + cx.update_editor(|editor, cx| { + crate::element::EditorElement::modifiers_changed( + editor, + &ModifiersChangedEvent { + modifiers: Modifiers { + command: false, + ..Default::default() + }, + ..Default::default() + }, + cx, + ); + }); + // Assert no link highlights + cx.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); + let actual_ranges = snapshot + .text_highlight_ranges::() + .map(|ranges| ranges.as_ref().clone().1) + .unwrap_or_default(); + + assert!(actual_ranges.is_empty(), "When no cmd is pressed, should have no hint label selected, but got: {actual_ranges:?}"); + }); + + // Cmd+click without existing definition requests and jumps + cx.update_editor(|editor, cx| { + crate::element::EditorElement::modifiers_changed( + editor, + &ModifiersChangedEvent { + modifiers: Modifiers { + command: true, + ..Default::default() + }, + ..Default::default() + }, + cx, + ); + update_inlay_link_and_hover_points( + &editor.snapshot(cx), + hint_hover_position, + editor, + true, + false, + cx, + ); + }); + cx.background_executor.run_until_parked(); + cx.update_editor(|editor, cx| { + go_to_fetched_type_definition(editor, hint_hover_position, false, cx); + }); + cx.background_executor.run_until_parked(); + cx.assert_editor_state(indoc! {" + struct «TestStructˇ»; + + fn main() { + let variable = TestStruct; + } + "}); + } +} diff --git a/crates/editor2/src/mouse_context_menu.rs b/crates/editor2/src/mouse_context_menu.rs index b70a826bf8cf3975bdd5c93df2ba67c31c0fb672..e6c0fc1111473541a285eaf6d4d41bf87c9952af 100644 --- a/crates/editor2/src/mouse_context_menu.rs +++ b/crates/editor2/src/mouse_context_menu.rs @@ -1,5 +1,14 @@ -use crate::{DisplayPoint, Editor, EditorMode, SelectMode}; -use gpui::{Pixels, Point, ViewContext}; +use crate::{ + DisplayPoint, Editor, EditorMode, FindAllReferences, GoToDefinition, GoToTypeDefinition, + Rename, RevealInFinder, SelectMode, ToggleCodeActions, +}; +use gpui::{DismissEvent, Pixels, Point, Subscription, View, ViewContext}; + +pub struct MouseContextMenu { + pub(crate) position: Point, + pub(crate) context_menu: View, + _subscription: Subscription, +} pub fn deploy_context_menu( editor: &mut Editor, @@ -7,88 +16,95 @@ pub fn deploy_context_menu( point: DisplayPoint, cx: &mut ViewContext, ) { - todo!(); + if !editor.is_focused(cx) { + editor.focus(cx); + } - // if !editor.focused { - // cx.focus_self(); - // } + // Don't show context menu for inline editors + if editor.mode() != EditorMode::Full { + return; + } - // // Don't show context menu for inline editors - // if editor.mode() != EditorMode::Full { - // return; - // } + // Don't show the context menu if there isn't a project associated with this editor + if editor.project.is_none() { + return; + } - // // Don't show the context menu if there isn't a project associated with this editor - // if editor.project.is_none() { - // return; - // } + // Move the cursor to the clicked location so that dispatched actions make sense + editor.change_selections(None, cx, |s| { + s.clear_disjoint(); + s.set_pending_display_range(point..point, SelectMode::Character); + }); - // // Move the cursor to the clicked location so that dispatched actions make sense - // editor.change_selections(None, cx, |s| { - // s.clear_disjoint(); - // s.set_pending_display_range(point..point, SelectMode::Character); - // }); + let context_menu = ui::ContextMenu::build(cx, |menu, cx| { + menu.action("Rename Symbol", Box::new(Rename)) + .action("Go to Definition", Box::new(GoToDefinition)) + .action("Go to Type Definition", Box::new(GoToTypeDefinition)) + .action("Find All References", Box::new(FindAllReferences)) + .action( + "Code Actions", + Box::new(ToggleCodeActions { + deployed_from_indicator: false, + }), + ) + .separator() + .action("Reveal in Finder", Box::new(RevealInFinder)) + }); + let context_menu_focus = context_menu.focus_handle(cx); + cx.focus(&context_menu_focus); - // editor.mouse_context_menu.update(cx, |menu, cx| { - // menu.show( - // position, - // AnchorCorner::TopLeft, - // vec![ - // ContextMenuItem::action("Rename Symbol", Rename), - // ContextMenuItem::action("Go to Definition", GoToDefinition), - // ContextMenuItem::action("Go to Type Definition", GoToTypeDefinition), - // ContextMenuItem::action("Find All References", FindAllReferences), - // ContextMenuItem::action( - // "Code Actions", - // ToggleCodeActions { - // deployed_from_indicator: false, - // }, - // ), - // ContextMenuItem::Separator, - // ContextMenuItem::action("Reveal in Finder", RevealInFinder), - // ], - // cx, - // ); - // }); - // cx.notify(); + let _subscription = cx.subscribe(&context_menu, move |this, _, event: &DismissEvent, cx| { + this.mouse_context_menu.take(); + if context_menu_focus.contains_focused(cx) { + this.focus(cx); + } + }); + + editor.mouse_context_menu = Some(MouseContextMenu { + position, + context_menu, + _subscription, + }); + cx.notify(); } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext}; -// use indoc::indoc; +#[cfg(test)] +mod tests { + use super::*; + use crate::{editor_tests::init_test, test::editor_lsp_test_context::EditorLspTestContext}; + use indoc::indoc; -// #[gpui::test] -// async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) { -// init_test(cx, |_| {}); + #[gpui::test] + async fn test_mouse_context_menu(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); -// let mut cx = EditorLspTestContext::new_rust( -// lsp::ServerCapabilities { -// hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), -// ..Default::default() -// }, -// cx, -// ) -// .await; + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + hover_provider: Some(lsp::HoverProviderCapability::Simple(true)), + ..Default::default() + }, + cx, + ) + .await; -// cx.set_state(indoc! {" -// fn teˇst() { -// do_work(); -// } -// "}); -// let point = cx.display_point(indoc! {" -// fn test() { -// do_wˇork(); -// } -// "}); -// cx.update_editor(|editor, cx| deploy_context_menu(editor, Default::default(), point, cx)); + cx.set_state(indoc! {" + fn teˇst() { + do_work(); + } + "}); + let point = cx.display_point(indoc! {" + fn test() { + do_wˇork(); + } + "}); + cx.editor(|editor, app| assert!(editor.mouse_context_menu.is_none())); + cx.update_editor(|editor, cx| deploy_context_menu(editor, Default::default(), point, cx)); -// cx.assert_editor_state(indoc! {" -// fn test() { -// do_wˇork(); -// } -// "}); -// cx.editor(|editor, app| assert!(editor.mouse_context_menu.read(app).visible())); -// } -// } + cx.assert_editor_state(indoc! {" + fn test() { + do_wˇork(); + } + "}); + cx.editor(|editor, app| assert!(editor.mouse_context_menu.is_some())); + } +} diff --git a/crates/editor2/src/movement.rs b/crates/editor2/src/movement.rs index 1414ae702dc4f772e3fcd895345eb849ccbc9217..ab25bb8499aa323178d3573ad563797f1ec0a712 100644 --- a/crates/editor2/src/movement.rs +++ b/crates/editor2/src/movement.rs @@ -452,483 +452,475 @@ pub fn split_display_range_by_lines( result } -// #[cfg(test)] -// mod tests { -// use super::*; -// use crate::{ -// display_map::Inlay, -// test::{}, -// Buffer, DisplayMap, ExcerptRange, InlayId, MultiBuffer, -// }; -// use project::Project; -// use settings::SettingsStore; -// use util::post_inc; - -// #[gpui::test] -// fn test_previous_word_start(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert(marked_text: &str, cx: &mut gpui::AppContext) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// previous_word_start(&snapshot, display_points[1]), -// display_points[0] -// ); -// } - -// assert("\nˇ ˇlorem", cx); -// assert("ˇ\nˇ lorem", cx); -// assert(" ˇloremˇ", cx); -// assert("ˇ ˇlorem", cx); -// assert(" ˇlorˇem", cx); -// assert("\nlorem\nˇ ˇipsum", cx); -// assert("\n\nˇ\nˇ", cx); -// assert(" ˇlorem ˇipsum", cx); -// assert("loremˇ-ˇipsum", cx); -// assert("loremˇ-#$@ˇipsum", cx); -// assert("ˇlorem_ˇipsum", cx); -// assert(" ˇdefγˇ", cx); -// assert(" ˇbcΔˇ", cx); -// assert(" abˇ——ˇcd", cx); -// } - -// #[gpui::test] -// fn test_previous_subword_start(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert(marked_text: &str, cx: &mut gpui::AppContext) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// previous_subword_start(&snapshot, display_points[1]), -// display_points[0] -// ); -// } - -// // Subword boundaries are respected -// assert("lorem_ˇipˇsum", cx); -// assert("lorem_ˇipsumˇ", cx); -// assert("ˇlorem_ˇipsum", cx); -// assert("lorem_ˇipsum_ˇdolor", cx); -// assert("loremˇIpˇsum", cx); -// assert("loremˇIpsumˇ", cx); - -// // Word boundaries are still respected -// assert("\nˇ ˇlorem", cx); -// assert(" ˇloremˇ", cx); -// assert(" ˇlorˇem", cx); -// assert("\nlorem\nˇ ˇipsum", cx); -// assert("\n\nˇ\nˇ", cx); -// assert(" ˇlorem ˇipsum", cx); -// assert("loremˇ-ˇipsum", cx); -// assert("loremˇ-#$@ˇipsum", cx); -// assert(" ˇdefγˇ", cx); -// assert(" bcˇΔˇ", cx); -// assert(" ˇbcδˇ", cx); -// assert(" abˇ——ˇcd", cx); -// } - -// #[gpui::test] -// fn test_find_preceding_boundary(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert( -// marked_text: &str, -// cx: &mut gpui::AppContext, -// is_boundary: impl FnMut(char, char) -> bool, -// ) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// find_preceding_boundary( -// &snapshot, -// display_points[1], -// FindRange::MultiLine, -// is_boundary -// ), -// display_points[0] -// ); -// } - -// assert("abcˇdef\ngh\nijˇk", cx, |left, right| { -// left == 'c' && right == 'd' -// }); -// assert("abcdef\nˇgh\nijˇk", cx, |left, right| { -// left == '\n' && right == 'g' -// }); -// let mut line_count = 0; -// assert("abcdef\nˇgh\nijˇk", cx, |left, _| { -// if left == '\n' { -// line_count += 1; -// line_count == 2 -// } else { -// false -// } -// }); -// } - -// #[gpui::test] -// fn test_find_preceding_boundary_with_inlays(cx: &mut gpui::AppContext) { -// init_test(cx); - -// let input_text = "abcdefghijklmnopqrstuvwxys"; -// let family_id = cx -// .font_cache() -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = cx -// .font_cache() -// .select_font(family_id, &Default::default()) -// .unwrap(); -// let font_size = 14.0; -// let buffer = MultiBuffer::build_simple(input_text, cx); -// let buffer_snapshot = buffer.read(cx).snapshot(cx); -// let display_map = -// cx.add_model(|cx| DisplayMap::new(buffer, font_id, font_size, None, 1, 1, cx)); - -// // add all kinds of inlays between two word boundaries: we should be able to cross them all, when looking for another boundary -// let mut id = 0; -// let inlays = (0..buffer_snapshot.len()) -// .map(|offset| { -// [ -// Inlay { -// id: InlayId::Suggestion(post_inc(&mut id)), -// position: buffer_snapshot.anchor_at(offset, Bias::Left), -// text: format!("test").into(), -// }, -// Inlay { -// id: InlayId::Suggestion(post_inc(&mut id)), -// position: buffer_snapshot.anchor_at(offset, Bias::Right), -// text: format!("test").into(), -// }, -// Inlay { -// id: InlayId::Hint(post_inc(&mut id)), -// position: buffer_snapshot.anchor_at(offset, Bias::Left), -// text: format!("test").into(), -// }, -// Inlay { -// id: InlayId::Hint(post_inc(&mut id)), -// position: buffer_snapshot.anchor_at(offset, Bias::Right), -// text: format!("test").into(), -// }, -// ] -// }) -// .flatten() -// .collect(); -// let snapshot = display_map.update(cx, |map, cx| { -// map.splice_inlays(Vec::new(), inlays, cx); -// map.snapshot(cx) -// }); - -// assert_eq!( -// find_preceding_boundary( -// &snapshot, -// buffer_snapshot.len().to_display_point(&snapshot), -// FindRange::MultiLine, -// |left, _| left == 'e', -// ), -// snapshot -// .buffer_snapshot -// .offset_to_point(5) -// .to_display_point(&snapshot), -// "Should not stop at inlays when looking for boundaries" -// ); -// } - -// #[gpui::test] -// fn test_next_word_end(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert(marked_text: &str, cx: &mut gpui::AppContext) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// next_word_end(&snapshot, display_points[0]), -// display_points[1] -// ); -// } - -// assert("\nˇ loremˇ", cx); -// assert(" ˇloremˇ", cx); -// assert(" lorˇemˇ", cx); -// assert(" loremˇ ˇ\nipsum\n", cx); -// assert("\nˇ\nˇ\n\n", cx); -// assert("loremˇ ipsumˇ ", cx); -// assert("loremˇ-ˇipsum", cx); -// assert("loremˇ#$@-ˇipsum", cx); -// assert("loremˇ_ipsumˇ", cx); -// assert(" ˇbcΔˇ", cx); -// assert(" abˇ——ˇcd", cx); -// } - -// #[gpui::test] -// fn test_next_subword_end(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert(marked_text: &str, cx: &mut gpui::AppContext) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// next_subword_end(&snapshot, display_points[0]), -// display_points[1] -// ); -// } - -// // Subword boundaries are respected -// assert("loˇremˇ_ipsum", cx); -// assert("ˇloremˇ_ipsum", cx); -// assert("loremˇ_ipsumˇ", cx); -// assert("loremˇ_ipsumˇ_dolor", cx); -// assert("loˇremˇIpsum", cx); -// assert("loremˇIpsumˇDolor", cx); - -// // Word boundaries are still respected -// assert("\nˇ loremˇ", cx); -// assert(" ˇloremˇ", cx); -// assert(" lorˇemˇ", cx); -// assert(" loremˇ ˇ\nipsum\n", cx); -// assert("\nˇ\nˇ\n\n", cx); -// assert("loremˇ ipsumˇ ", cx); -// assert("loremˇ-ˇipsum", cx); -// assert("loremˇ#$@-ˇipsum", cx); -// assert("loremˇ_ipsumˇ", cx); -// assert(" ˇbcˇΔ", cx); -// assert(" abˇ——ˇcd", cx); -// } - -// #[gpui::test] -// fn test_find_boundary(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert( -// marked_text: &str, -// cx: &mut gpui::AppContext, -// is_boundary: impl FnMut(char, char) -> bool, -// ) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// find_boundary( -// &snapshot, -// display_points[0], -// FindRange::MultiLine, -// is_boundary -// ), -// display_points[1] -// ); -// } - -// assert("abcˇdef\ngh\nijˇk", cx, |left, right| { -// left == 'j' && right == 'k' -// }); -// assert("abˇcdef\ngh\nˇijk", cx, |left, right| { -// left == '\n' && right == 'i' -// }); -// let mut line_count = 0; -// assert("abcˇdef\ngh\nˇijk", cx, |left, _| { -// if left == '\n' { -// line_count += 1; -// line_count == 2 -// } else { -// false -// } -// }); -// } - -// #[gpui::test] -// fn test_surrounding_word(cx: &mut gpui::AppContext) { -// init_test(cx); - -// fn assert(marked_text: &str, cx: &mut gpui::AppContext) { -// let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); -// assert_eq!( -// surrounding_word(&snapshot, display_points[1]), -// display_points[0]..display_points[2], -// "{}", -// marked_text.to_string() -// ); -// } - -// assert("ˇˇloremˇ ipsum", cx); -// assert("ˇloˇremˇ ipsum", cx); -// assert("ˇloremˇˇ ipsum", cx); -// assert("loremˇ ˇ ˇipsum", cx); -// assert("lorem\nˇˇˇ\nipsum", cx); -// assert("lorem\nˇˇipsumˇ", cx); -// assert("loremˇ,ˇˇ ipsum", cx); -// assert("ˇloremˇˇ, ipsum", cx); -// } - -// #[gpui::test] -// async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) { -// cx.update(|cx| { -// init_test(cx); -// }); - -// let mut cx = EditorTestContext::new(cx).await; -// let editor = cx.editor.clone(); -// let window = cx.window.clone(); -// cx.update_window(window, |cx| { -// let text_layout_details = -// editor.read_with(cx, |editor, cx| editor.text_layout_details(cx)); - -// let family_id = cx -// .font_cache() -// .load_family(&["Helvetica"], &Default::default()) -// .unwrap(); -// let font_id = cx -// .font_cache() -// .select_font(family_id, &Default::default()) -// .unwrap(); - -// let buffer = -// cx.add_model(|cx| Buffer::new(0, cx.model_id() as u64, "abc\ndefg\nhijkl\nmn")); -// let multibuffer = cx.add_model(|cx| { -// let mut multibuffer = MultiBuffer::new(0); -// multibuffer.push_excerpts( -// buffer.clone(), -// [ -// ExcerptRange { -// context: Point::new(0, 0)..Point::new(1, 4), -// primary: None, -// }, -// ExcerptRange { -// context: Point::new(2, 0)..Point::new(3, 2), -// primary: None, -// }, -// ], -// cx, -// ); -// multibuffer -// }); -// let display_map = -// cx.add_model(|cx| DisplayMap::new(multibuffer, font_id, 14.0, None, 2, 2, cx)); -// let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); - -// assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); - -// let col_2_x = snapshot.x_for_point(DisplayPoint::new(2, 2), &text_layout_details); - -// // Can't move up into the first excerpt's header -// assert_eq!( -// up( -// &snapshot, -// DisplayPoint::new(2, 2), -// SelectionGoal::HorizontalPosition(col_2_x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(2, 0), -// SelectionGoal::HorizontalPosition(0.0) -// ), -// ); -// assert_eq!( -// up( -// &snapshot, -// DisplayPoint::new(2, 0), -// SelectionGoal::None, -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(2, 0), -// SelectionGoal::HorizontalPosition(0.0) -// ), -// ); - -// let col_4_x = snapshot.x_for_point(DisplayPoint::new(3, 4), &text_layout_details); - -// // Move up and down within first excerpt -// assert_eq!( -// up( -// &snapshot, -// DisplayPoint::new(3, 4), -// SelectionGoal::HorizontalPosition(col_4_x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(2, 3), -// SelectionGoal::HorizontalPosition(col_4_x) -// ), -// ); -// assert_eq!( -// down( -// &snapshot, -// DisplayPoint::new(2, 3), -// SelectionGoal::HorizontalPosition(col_4_x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(3, 4), -// SelectionGoal::HorizontalPosition(col_4_x) -// ), -// ); - -// let col_5_x = snapshot.x_for_point(DisplayPoint::new(6, 5), &text_layout_details); - -// // Move up and down across second excerpt's header -// assert_eq!( -// up( -// &snapshot, -// DisplayPoint::new(6, 5), -// SelectionGoal::HorizontalPosition(col_5_x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(3, 4), -// SelectionGoal::HorizontalPosition(col_5_x) -// ), -// ); -// assert_eq!( -// down( -// &snapshot, -// DisplayPoint::new(3, 4), -// SelectionGoal::HorizontalPosition(col_5_x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(6, 5), -// SelectionGoal::HorizontalPosition(col_5_x) -// ), -// ); - -// let max_point_x = snapshot.x_for_point(DisplayPoint::new(7, 2), &text_layout_details); - -// // Can't move down off the end -// assert_eq!( -// down( -// &snapshot, -// DisplayPoint::new(7, 0), -// SelectionGoal::HorizontalPosition(0.0), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(7, 2), -// SelectionGoal::HorizontalPosition(max_point_x) -// ), -// ); -// assert_eq!( -// down( -// &snapshot, -// DisplayPoint::new(7, 2), -// SelectionGoal::HorizontalPosition(max_point_x), -// false, -// &text_layout_details -// ), -// ( -// DisplayPoint::new(7, 2), -// SelectionGoal::HorizontalPosition(max_point_x) -// ), -// ); -// }); -// } - -// fn init_test(cx: &mut gpui::AppContext) { -// cx.set_global(SettingsStore::test(cx)); -// theme::init(cx); -// language::init(cx); -// crate::init(cx); -// Project::init_settings(cx); -// } -// } +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + display_map::Inlay, + test::{editor_test_context::EditorTestContext, marked_display_snapshot}, + Buffer, DisplayMap, ExcerptRange, InlayId, MultiBuffer, + }; + use gpui::{font, Context as _}; + use project::Project; + use settings::SettingsStore; + use util::post_inc; + + #[gpui::test] + fn test_previous_word_start(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert(marked_text: &str, cx: &mut gpui::AppContext) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + previous_word_start(&snapshot, display_points[1]), + display_points[0] + ); + } + + assert("\nˇ ˇlorem", cx); + assert("ˇ\nˇ lorem", cx); + assert(" ˇloremˇ", cx); + assert("ˇ ˇlorem", cx); + assert(" ˇlorˇem", cx); + assert("\nlorem\nˇ ˇipsum", cx); + assert("\n\nˇ\nˇ", cx); + assert(" ˇlorem ˇipsum", cx); + assert("loremˇ-ˇipsum", cx); + assert("loremˇ-#$@ˇipsum", cx); + assert("ˇlorem_ˇipsum", cx); + assert(" ˇdefγˇ", cx); + assert(" ˇbcΔˇ", cx); + assert(" abˇ——ˇcd", cx); + } + + #[gpui::test] + fn test_previous_subword_start(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert(marked_text: &str, cx: &mut gpui::AppContext) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + previous_subword_start(&snapshot, display_points[1]), + display_points[0] + ); + } + + // Subword boundaries are respected + assert("lorem_ˇipˇsum", cx); + assert("lorem_ˇipsumˇ", cx); + assert("ˇlorem_ˇipsum", cx); + assert("lorem_ˇipsum_ˇdolor", cx); + assert("loremˇIpˇsum", cx); + assert("loremˇIpsumˇ", cx); + + // Word boundaries are still respected + assert("\nˇ ˇlorem", cx); + assert(" ˇloremˇ", cx); + assert(" ˇlorˇem", cx); + assert("\nlorem\nˇ ˇipsum", cx); + assert("\n\nˇ\nˇ", cx); + assert(" ˇlorem ˇipsum", cx); + assert("loremˇ-ˇipsum", cx); + assert("loremˇ-#$@ˇipsum", cx); + assert(" ˇdefγˇ", cx); + assert(" bcˇΔˇ", cx); + assert(" ˇbcδˇ", cx); + assert(" abˇ——ˇcd", cx); + } + + #[gpui::test] + fn test_find_preceding_boundary(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert( + marked_text: &str, + cx: &mut gpui::AppContext, + is_boundary: impl FnMut(char, char) -> bool, + ) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + find_preceding_boundary( + &snapshot, + display_points[1], + FindRange::MultiLine, + is_boundary + ), + display_points[0] + ); + } + + assert("abcˇdef\ngh\nijˇk", cx, |left, right| { + left == 'c' && right == 'd' + }); + assert("abcdef\nˇgh\nijˇk", cx, |left, right| { + left == '\n' && right == 'g' + }); + let mut line_count = 0; + assert("abcdef\nˇgh\nijˇk", cx, |left, _| { + if left == '\n' { + line_count += 1; + line_count == 2 + } else { + false + } + }); + } + + #[gpui::test] + fn test_find_preceding_boundary_with_inlays(cx: &mut gpui::AppContext) { + init_test(cx); + + let input_text = "abcdefghijklmnopqrstuvwxys"; + let font = font("Helvetica"); + let font_size = px(14.0); + let buffer = MultiBuffer::build_simple(input_text, cx); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + let display_map = + cx.build_model(|cx| DisplayMap::new(buffer, font, font_size, None, 1, 1, cx)); + + // add all kinds of inlays between two word boundaries: we should be able to cross them all, when looking for another boundary + let mut id = 0; + let inlays = (0..buffer_snapshot.len()) + .map(|offset| { + [ + Inlay { + id: InlayId::Suggestion(post_inc(&mut id)), + position: buffer_snapshot.anchor_at(offset, Bias::Left), + text: format!("test").into(), + }, + Inlay { + id: InlayId::Suggestion(post_inc(&mut id)), + position: buffer_snapshot.anchor_at(offset, Bias::Right), + text: format!("test").into(), + }, + Inlay { + id: InlayId::Hint(post_inc(&mut id)), + position: buffer_snapshot.anchor_at(offset, Bias::Left), + text: format!("test").into(), + }, + Inlay { + id: InlayId::Hint(post_inc(&mut id)), + position: buffer_snapshot.anchor_at(offset, Bias::Right), + text: format!("test").into(), + }, + ] + }) + .flatten() + .collect(); + let snapshot = display_map.update(cx, |map, cx| { + map.splice_inlays(Vec::new(), inlays, cx); + map.snapshot(cx) + }); + + assert_eq!( + find_preceding_boundary( + &snapshot, + buffer_snapshot.len().to_display_point(&snapshot), + FindRange::MultiLine, + |left, _| left == 'e', + ), + snapshot + .buffer_snapshot + .offset_to_point(5) + .to_display_point(&snapshot), + "Should not stop at inlays when looking for boundaries" + ); + } + + #[gpui::test] + fn test_next_word_end(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert(marked_text: &str, cx: &mut gpui::AppContext) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + next_word_end(&snapshot, display_points[0]), + display_points[1] + ); + } + + assert("\nˇ loremˇ", cx); + assert(" ˇloremˇ", cx); + assert(" lorˇemˇ", cx); + assert(" loremˇ ˇ\nipsum\n", cx); + assert("\nˇ\nˇ\n\n", cx); + assert("loremˇ ipsumˇ ", cx); + assert("loremˇ-ˇipsum", cx); + assert("loremˇ#$@-ˇipsum", cx); + assert("loremˇ_ipsumˇ", cx); + assert(" ˇbcΔˇ", cx); + assert(" abˇ——ˇcd", cx); + } + + #[gpui::test] + fn test_next_subword_end(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert(marked_text: &str, cx: &mut gpui::AppContext) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + next_subword_end(&snapshot, display_points[0]), + display_points[1] + ); + } + + // Subword boundaries are respected + assert("loˇremˇ_ipsum", cx); + assert("ˇloremˇ_ipsum", cx); + assert("loremˇ_ipsumˇ", cx); + assert("loremˇ_ipsumˇ_dolor", cx); + assert("loˇremˇIpsum", cx); + assert("loremˇIpsumˇDolor", cx); + + // Word boundaries are still respected + assert("\nˇ loremˇ", cx); + assert(" ˇloremˇ", cx); + assert(" lorˇemˇ", cx); + assert(" loremˇ ˇ\nipsum\n", cx); + assert("\nˇ\nˇ\n\n", cx); + assert("loremˇ ipsumˇ ", cx); + assert("loremˇ-ˇipsum", cx); + assert("loremˇ#$@-ˇipsum", cx); + assert("loremˇ_ipsumˇ", cx); + assert(" ˇbcˇΔ", cx); + assert(" abˇ——ˇcd", cx); + } + + #[gpui::test] + fn test_find_boundary(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert( + marked_text: &str, + cx: &mut gpui::AppContext, + is_boundary: impl FnMut(char, char) -> bool, + ) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + find_boundary( + &snapshot, + display_points[0], + FindRange::MultiLine, + is_boundary + ), + display_points[1] + ); + } + + assert("abcˇdef\ngh\nijˇk", cx, |left, right| { + left == 'j' && right == 'k' + }); + assert("abˇcdef\ngh\nˇijk", cx, |left, right| { + left == '\n' && right == 'i' + }); + let mut line_count = 0; + assert("abcˇdef\ngh\nˇijk", cx, |left, _| { + if left == '\n' { + line_count += 1; + line_count == 2 + } else { + false + } + }); + } + + #[gpui::test] + fn test_surrounding_word(cx: &mut gpui::AppContext) { + init_test(cx); + + fn assert(marked_text: &str, cx: &mut gpui::AppContext) { + let (snapshot, display_points) = marked_display_snapshot(marked_text, cx); + assert_eq!( + surrounding_word(&snapshot, display_points[1]), + display_points[0]..display_points[2], + "{}", + marked_text.to_string() + ); + } + + assert("ˇˇloremˇ ipsum", cx); + assert("ˇloˇremˇ ipsum", cx); + assert("ˇloremˇˇ ipsum", cx); + assert("loremˇ ˇ ˇipsum", cx); + assert("lorem\nˇˇˇ\nipsum", cx); + assert("lorem\nˇˇipsumˇ", cx); + assert("loremˇ,ˇˇ ipsum", cx); + assert("ˇloremˇˇ, ipsum", cx); + } + + #[gpui::test] + async fn test_move_up_and_down_with_excerpts(cx: &mut gpui::TestAppContext) { + cx.update(|cx| { + init_test(cx); + }); + + let mut cx = EditorTestContext::new(cx).await; + let editor = cx.editor.clone(); + let window = cx.window.clone(); + cx.update_window(window, |_, cx| { + let text_layout_details = + editor.update(cx, |editor, cx| editor.text_layout_details(cx)); + + let font = font("Helvetica"); + + let buffer = cx + .build_model(|cx| Buffer::new(0, cx.entity_id().as_u64(), "abc\ndefg\nhijkl\nmn")); + let multibuffer = cx.build_model(|cx| { + let mut multibuffer = MultiBuffer::new(0); + multibuffer.push_excerpts( + buffer.clone(), + [ + ExcerptRange { + context: Point::new(0, 0)..Point::new(1, 4), + primary: None, + }, + ExcerptRange { + context: Point::new(2, 0)..Point::new(3, 2), + primary: None, + }, + ], + cx, + ); + multibuffer + }); + let display_map = + cx.build_model(|cx| DisplayMap::new(multibuffer, font, px(14.0), None, 2, 2, cx)); + let snapshot = display_map.update(cx, |map, cx| map.snapshot(cx)); + + assert_eq!(snapshot.text(), "\n\nabc\ndefg\n\n\nhijkl\nmn"); + + let col_2_x = + snapshot.x_for_display_point(DisplayPoint::new(2, 2), &text_layout_details); + + // Can't move up into the first excerpt's header + assert_eq!( + up( + &snapshot, + DisplayPoint::new(2, 2), + SelectionGoal::HorizontalPosition(col_2_x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), + ); + assert_eq!( + up( + &snapshot, + DisplayPoint::new(2, 0), + SelectionGoal::None, + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 0), + SelectionGoal::HorizontalPosition(0.0) + ), + ); + + let col_4_x = + snapshot.x_for_display_point(DisplayPoint::new(3, 4), &text_layout_details); + + // Move up and down within first excerpt + assert_eq!( + up( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_4_x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(2, 3), + SelectionGoal::HorizontalPosition(col_4_x.0) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(2, 3), + SelectionGoal::HorizontalPosition(col_4_x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_4_x.0) + ), + ); + + let col_5_x = + snapshot.x_for_display_point(DisplayPoint::new(6, 5), &text_layout_details); + + // Move up and down across second excerpt's header + assert_eq!( + up( + &snapshot, + DisplayPoint::new(6, 5), + SelectionGoal::HorizontalPosition(col_5_x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_5_x.0) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(3, 4), + SelectionGoal::HorizontalPosition(col_5_x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(6, 5), + SelectionGoal::HorizontalPosition(col_5_x.0) + ), + ); + + let max_point_x = + snapshot.x_for_display_point(DisplayPoint::new(7, 2), &text_layout_details); + + // Can't move down off the end + assert_eq!( + down( + &snapshot, + DisplayPoint::new(7, 0), + SelectionGoal::HorizontalPosition(0.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x.0) + ), + ); + assert_eq!( + down( + &snapshot, + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x.0), + false, + &text_layout_details + ), + ( + DisplayPoint::new(7, 2), + SelectionGoal::HorizontalPosition(max_point_x.0) + ), + ); + }); + } + + fn init_test(cx: &mut gpui::AppContext) { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + language::init(cx); + crate::init(cx); + Project::init_settings(cx); + } +} diff --git a/crates/editor2/src/selections_collection.rs b/crates/editor2/src/selections_collection.rs index 6542ace5fbe057d8d8fb65cfa1f615ebeb3d89c1..8d71916210a6897b4fa649eef2ebbe05a041acb9 100644 --- a/crates/editor2/src/selections_collection.rs +++ b/crates/editor2/src/selections_collection.rs @@ -315,14 +315,11 @@ impl SelectionsCollection { let line = display_map.layout_row(row, &text_layout_details); - dbg!("****START COL****"); let start_col = line.closest_index_for_x(positions.start) as u32; if start_col < line_len || (is_empty && positions.start == line.width) { let start = DisplayPoint::new(row, start_col); - dbg!("****END COL****"); let end_col = line.closest_index_for_x(positions.end) as u32; let end = DisplayPoint::new(row, end_col); - dbg!(start_col, end_col); Some(Selection { id: post_inc(&mut self.next_selection_id), diff --git a/crates/file_finder2/src/file_finder.rs b/crates/file_finder2/src/file_finder.rs index ea00c5a635df0a97e9c3a5e275b844b4b8f5bde9..9938b94edb2892d15f547e95df6ee3192f0f992b 100644 --- a/crates/file_finder2/src/file_finder.rs +++ b/crates/file_finder2/src/file_finder.rs @@ -1256,7 +1256,7 @@ mod tests { // // TODO: without closing, the opened items do not propagate their history changes for some reason // it does work in real app though, only tests do not propagate. - workspace.update(cx, |_, cx| dbg!(cx.focused())); + workspace.update(cx, |_, cx| cx.focused()); let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await; assert!( diff --git a/crates/gpui2/src/app.rs b/crates/gpui2/src/app.rs index 94a7d3be0b8b2cf239d29f1a29d7b6bb0b7d2bbf..fec6f150f6c341f916e0173379aba63bebcc1ffd 100644 --- a/crates/gpui2/src/app.rs +++ b/crates/gpui2/src/app.rs @@ -358,7 +358,7 @@ impl AppContext { { let entity_id = entity.entity_id(); let handle = entity.downgrade(); - self.observers.insert( + let (subscription, activate) = self.observers.insert( entity_id, Box::new(move |cx| { if let Some(handle) = E::upgrade_from(&handle) { @@ -367,7 +367,9 @@ impl AppContext { false } }), - ) + ); + self.defer(move |_| activate()); + subscription } pub fn subscribe( @@ -398,8 +400,7 @@ impl AppContext { { let entity_id = entity.entity_id(); let entity = entity.downgrade(); - - self.event_listeners.insert( + let (subscription, activate) = self.event_listeners.insert( entity_id, ( TypeId::of::(), @@ -412,7 +413,9 @@ impl AppContext { } }), ), - ) + ); + self.defer(move |_| activate()); + subscription } pub fn windows(&self) -> Vec { @@ -873,13 +876,15 @@ impl AppContext { &mut self, mut f: impl FnMut(&mut Self) + 'static, ) -> Subscription { - self.global_observers.insert( + let (subscription, activate) = self.global_observers.insert( TypeId::of::(), Box::new(move |cx| { f(cx); true }), - ) + ); + self.defer(move |_| activate()); + subscription } /// Move the global of the given type to the stack. @@ -903,7 +908,7 @@ impl AppContext { &mut self, on_new: impl 'static + Fn(&mut V, &mut ViewContext), ) -> Subscription { - self.new_view_observers.insert( + let (subscription, activate) = self.new_view_observers.insert( TypeId::of::(), Box::new(move |any_view: AnyView, cx: &mut WindowContext| { any_view @@ -913,7 +918,9 @@ impl AppContext { on_new(view_state, cx); }) }), - ) + ); + activate(); + subscription } pub fn observe_release( @@ -925,13 +932,15 @@ impl AppContext { E: Entity, T: 'static, { - self.release_listeners.insert( + let (subscription, activate) = self.release_listeners.insert( handle.entity_id(), Box::new(move |entity, cx| { let entity = entity.downcast_mut().expect("invalid entity type"); on_release(entity, cx) }), - ) + ); + activate(); + subscription } pub(crate) fn push_text_style(&mut self, text_style: TextStyleRefinement) { @@ -996,13 +1005,15 @@ impl AppContext { where Fut: 'static + Future, { - self.quit_observers.insert( + let (subscription, activate) = self.quit_observers.insert( (), Box::new(move |cx| { let future = on_quit(cx); async move { future.await }.boxed_local() }), - ) + ); + activate(); + subscription } } diff --git a/crates/gpui2/src/app/entity_map.rs b/crates/gpui2/src/app/entity_map.rs index a34582f4f4024c5eeab0363d45896be7eaa2ee95..99d8542eba89cde981f8cbf966d298ec1d8af2af 100644 --- a/crates/gpui2/src/app/entity_map.rs +++ b/crates/gpui2/src/app/entity_map.rs @@ -482,10 +482,6 @@ impl WeakModel { /// Update the entity referenced by this model with the given function if /// the referenced entity still exists. Returns an error if the entity has /// been released. - /// - /// The update function receives a context appropriate for its environment. - /// When updating in an `AppContext`, it receives a `ModelContext`. - /// When updating an a `WindowContext`, it receives a `ViewContext`. pub fn update( &self, cx: &mut C, @@ -501,6 +497,21 @@ impl WeakModel { .map(|this| cx.update_model(&this, update)), ) } + + /// Reads the entity referenced by this model with the given function if + /// the referenced entity still exists. Returns an error if the entity has + /// been released. + pub fn read_with(&self, cx: &C, read: impl FnOnce(&T, &AppContext) -> R) -> Result + where + C: Context, + Result>: crate::Flatten, + { + crate::Flatten::flatten( + self.upgrade() + .ok_or_else(|| anyhow!("entity release")) + .map(|this| cx.read_model(&this, read)), + ) + } } impl Hash for WeakModel { diff --git a/crates/gpui2/src/app/model_context.rs b/crates/gpui2/src/app/model_context.rs index d04f0f22891582c8b90b124ae08756a1a95922c6..26feb2fd1befc604f7bda5b5e5a362a5a3c52dd1 100644 --- a/crates/gpui2/src/app/model_context.rs +++ b/crates/gpui2/src/app/model_context.rs @@ -88,13 +88,15 @@ impl<'a, T: 'static> ModelContext<'a, T> { where T: 'static, { - self.app.release_listeners.insert( + let (subscription, activate) = self.app.release_listeners.insert( self.model_state.entity_id, Box::new(move |this, cx| { let this = this.downcast_mut().expect("invalid entity type"); on_release(this, cx); }), - ) + ); + activate(); + subscription } pub fn observe_release( @@ -109,7 +111,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { { let entity_id = entity.entity_id(); let this = self.weak_model(); - self.app.release_listeners.insert( + let (subscription, activate) = self.app.release_listeners.insert( entity_id, Box::new(move |entity, cx| { let entity = entity.downcast_mut().expect("invalid entity type"); @@ -117,7 +119,9 @@ impl<'a, T: 'static> ModelContext<'a, T> { this.update(cx, |this, cx| on_release(this, entity, cx)); } }), - ) + ); + activate(); + subscription } pub fn observe_global( @@ -128,10 +132,12 @@ impl<'a, T: 'static> ModelContext<'a, T> { T: 'static, { let handle = self.weak_model(); - self.global_observers.insert( + let (subscription, activate) = self.global_observers.insert( TypeId::of::(), Box::new(move |cx| handle.update(cx, |view, cx| f(view, cx)).is_ok()), - ) + ); + self.defer(move |_| activate()); + subscription } pub fn on_app_quit( @@ -143,7 +149,7 @@ impl<'a, T: 'static> ModelContext<'a, T> { T: 'static, { let handle = self.weak_model(); - self.app.quit_observers.insert( + let (subscription, activate) = self.app.quit_observers.insert( (), Box::new(move |cx| { let future = handle.update(cx, |entity, cx| on_quit(entity, cx)).ok(); @@ -154,7 +160,9 @@ impl<'a, T: 'static> ModelContext<'a, T> { } .boxed_local() }), - ) + ); + activate(); + subscription } pub fn notify(&mut self) { diff --git a/crates/gpui2/src/app/test_context.rs b/crates/gpui2/src/app/test_context.rs index 9637720a67e78ba6f735534f924b4028d8d8c75f..a9403de9bce63a62dfedf455d997de34dff4d856 100644 --- a/crates/gpui2/src/app/test_context.rs +++ b/crates/gpui2/src/app/test_context.rs @@ -1,13 +1,13 @@ use crate::{ div, Action, AnyView, AnyWindowHandle, AppCell, AppContext, AsyncAppContext, - BackgroundExecutor, Context, Div, Entity, EventEmitter, ForegroundExecutor, InputEvent, - KeyDownEvent, Keystroke, Model, ModelContext, Render, Result, Task, TestDispatcher, - TestPlatform, TestWindow, View, ViewContext, VisualContext, WindowContext, WindowHandle, - WindowOptions, + BackgroundExecutor, Bounds, Context, Div, Entity, EventEmitter, ForegroundExecutor, InputEvent, + KeyDownEvent, Keystroke, Model, ModelContext, Pixels, PlatformWindow, Point, Render, Result, + Size, Task, TestDispatcher, TestPlatform, TestWindow, TestWindowHandlers, View, ViewContext, + VisualContext, WindowBounds, WindowContext, WindowHandle, WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{Stream, StreamExt}; -use std::{future::Future, ops::Deref, rc::Rc, sync::Arc, time::Duration}; +use std::{future::Future, mem, ops::Deref, rc::Rc, sync::Arc, time::Duration}; #[derive(Clone)] pub struct TestAppContext { @@ -170,6 +170,45 @@ impl TestAppContext { self.test_platform.has_pending_prompt() } + pub fn simulate_window_resize(&self, window_handle: AnyWindowHandle, size: Size) { + let (mut handlers, scale_factor) = self + .app + .borrow_mut() + .update_window(window_handle, |_, cx| { + let platform_window = cx.window.platform_window.as_test().unwrap(); + let scale_factor = platform_window.scale_factor(); + match &mut platform_window.bounds { + WindowBounds::Fullscreen | WindowBounds::Maximized => { + platform_window.bounds = WindowBounds::Fixed(Bounds { + origin: Point::default(), + size: size.map(|pixels| f64::from(pixels).into()), + }); + } + WindowBounds::Fixed(bounds) => { + bounds.size = size.map(|pixels| f64::from(pixels).into()); + } + } + + ( + mem::take(&mut platform_window.handlers.lock().resize), + scale_factor, + ) + }) + .unwrap(); + + for handler in &mut handlers { + handler(size, scale_factor); + } + + self.app + .borrow_mut() + .update_window(window_handle, |_, cx| { + let platform_window = cx.window.platform_window.as_test().unwrap(); + platform_window.handlers.lock().resize = handlers; + }) + .unwrap(); + } + pub fn spawn(&self, f: impl FnOnce(AsyncAppContext) -> Fut) -> Task where Fut: Future + 'static, @@ -343,12 +382,15 @@ impl TestAppContext { use smol::future::FutureExt as _; async { - while notifications.next().await.is_some() { + loop { if model.update(self, &mut predicate) { return Ok(()); } + + if notifications.next().await.is_none() { + bail!("model dropped") + } } - bail!("model dropped") } .race(timer.map(|_| Err(anyhow!("condition timed out")))) .await @@ -502,6 +544,19 @@ impl<'a> VisualTestContext<'a> { self.cx.dispatch_action(self.window, action) } + pub fn window_title(&mut self) -> Option { + self.cx + .update_window(self.window, |_, cx| { + cx.window + .platform_window + .as_test() + .unwrap() + .window_title + .clone() + }) + .unwrap() + } + pub fn simulate_keystrokes(&mut self, keystrokes: &str) { self.cx.simulate_keystrokes(self.window, keystrokes) } @@ -509,6 +564,39 @@ impl<'a> VisualTestContext<'a> { pub fn simulate_input(&mut self, input: &str) { self.cx.simulate_input(self.window, input) } + + pub fn simulate_activation(&mut self) { + self.simulate_window_events(&mut |handlers| { + handlers + .active_status_change + .iter_mut() + .for_each(|f| f(true)); + }) + } + + pub fn simulate_deactivation(&mut self) { + self.simulate_window_events(&mut |handlers| { + handlers + .active_status_change + .iter_mut() + .for_each(|f| f(false)); + }) + } + + fn simulate_window_events(&mut self, f: &mut dyn FnMut(&mut TestWindowHandlers)) { + let handlers = self + .cx + .update_window(self.window, |_, cx| { + cx.window + .platform_window + .as_test() + .unwrap() + .handlers + .clone() + }) + .unwrap(); + f(&mut *handlers.lock()); + } } impl<'a> Context for VisualTestContext<'a> { diff --git a/crates/gpui2/src/elements/canvas.rs b/crates/gpui2/src/elements/canvas.rs new file mode 100644 index 0000000000000000000000000000000000000000..4761b04f3f84abae558038b6830d709deb06532e --- /dev/null +++ b/crates/gpui2/src/elements/canvas.rs @@ -0,0 +1,48 @@ +use crate::{Bounds, Element, IntoElement, Pixels, StyleRefinement, Styled, WindowContext}; + +pub fn canvas(callback: impl 'static + FnOnce(Bounds, &mut WindowContext)) -> Canvas { + Canvas { + paint_callback: Box::new(callback), + style: Default::default(), + } +} + +pub struct Canvas { + paint_callback: Box, &mut WindowContext)>, + style: StyleRefinement, +} + +impl IntoElement for Canvas { + type Element = Self; + + fn element_id(&self) -> Option { + None + } + + fn into_element(self) -> Self::Element { + self + } +} + +impl Element for Canvas { + type State = (); + + fn layout( + &mut self, + _: Option, + cx: &mut WindowContext, + ) -> (crate::LayoutId, Self::State) { + let layout_id = cx.request_layout(&self.style.clone().into(), []); + (layout_id, ()) + } + + fn paint(self, bounds: Bounds, _: &mut (), cx: &mut WindowContext) { + (self.paint_callback)(bounds, cx) + } +} + +impl Styled for Canvas { + fn style(&mut self) -> &mut crate::StyleRefinement { + &mut self.style + } +} diff --git a/crates/gpui2/src/elements/div.rs b/crates/gpui2/src/elements/div.rs index ced0a4767cc988d58ea49dcc6b3f3c7a78b34b4f..ce457fc6931246ee1e2e4d19a4a1639b37998395 100644 --- a/crates/gpui2/src/elements/div.rs +++ b/crates/gpui2/src/elements/div.rs @@ -221,20 +221,6 @@ pub trait InteractiveElement: Sized + Element { /// Add a listener for the given action, fires during the bubble event phase fn on_action(mut self, listener: impl Fn(&A, &mut WindowContext) + 'static) -> Self { - // NOTE: this debug assert has the side-effect of working around - // a bug where a crate consisting only of action definitions does - // not register the actions in debug builds: - // - // https://github.com/rust-lang/rust/issues/47384 - // https://github.com/mmastrac/rust-ctor/issues/280 - // - // if we are relying on this side-effect still, removing the debug_assert! - // likely breaks the command_palette tests. - // debug_assert!( - // A::is_registered(), - // "{:?} is not registered as an action", - // A::qualified_name() - // ); self.interactivity().action_listeners.push(( TypeId::of::(), Box::new(move |action, phase, cx| { @@ -247,6 +233,23 @@ pub trait InteractiveElement: Sized + Element { self } + fn on_boxed_action( + mut self, + action: &Box, + listener: impl Fn(&Box, &mut WindowContext) + 'static, + ) -> Self { + let action = action.boxed_clone(); + self.interactivity().action_listeners.push(( + (*action).type_id(), + Box::new(move |_, phase, cx| { + if phase == DispatchPhase::Bubble { + (listener)(&action, cx) + } + }), + )); + self + } + fn on_key_down( mut self, listener: impl Fn(&KeyDownEvent, &mut WindowContext) + 'static, @@ -866,6 +869,7 @@ impl Interactivity { } if self.hover_style.is_some() + || self.base_style.mouse_cursor.is_some() || cx.active_drag.is_some() && !self.drag_over_styles.is_empty() { let bounds = bounds.intersect(&cx.content_mask().bounds); @@ -992,10 +996,6 @@ impl Interactivity { let interactive_bounds = interactive_bounds.clone(); cx.on_mouse_event(move |event: &MouseMoveEvent, phase, cx| { - if phase != DispatchPhase::Bubble { - return; - } - let is_hovered = interactive_bounds.visibly_contains(&event.position, cx) && pending_mouse_down.borrow().is_none(); if !is_hovered { @@ -1003,6 +1003,10 @@ impl Interactivity { return; } + if phase != DispatchPhase::Bubble { + return; + } + if active_tooltip.borrow().is_none() { let task = cx.spawn({ let active_tooltip = active_tooltip.clone(); diff --git a/crates/gpui2/src/elements/mod.rs b/crates/gpui2/src/elements/mod.rs index 12c57958eaaf1829664ee73500985d05037f9786..e986b0b3eaef37f65c55344a7a14ef234cb8539e 100644 --- a/crates/gpui2/src/elements/mod.rs +++ b/crates/gpui2/src/elements/mod.rs @@ -1,3 +1,4 @@ +mod canvas; mod div; mod img; mod overlay; @@ -5,6 +6,7 @@ mod svg; mod text; mod uniform_list; +pub use canvas::*; pub use div::*; pub use img::*; pub use overlay::*; diff --git a/crates/gpui2/src/elements/text.rs b/crates/gpui2/src/elements/text.rs index d57daca0627081f516658cbc35ca161a4e0d1fc0..d398b1f8fef458c0419f8a90a8c28bef63c446a0 100644 --- a/crates/gpui2/src/elements/text.rs +++ b/crates/gpui2/src/elements/text.rs @@ -1,6 +1,7 @@ use crate::{ - Bounds, DispatchPhase, Element, ElementId, IntoElement, LayoutId, MouseDownEvent, MouseUpEvent, - Pixels, Point, SharedString, Size, TextRun, WhiteSpace, WindowContext, WrappedLine, + Bounds, DispatchPhase, Element, ElementId, HighlightStyle, IntoElement, LayoutId, + MouseDownEvent, MouseUpEvent, Pixels, Point, SharedString, Size, TextRun, TextStyle, + WhiteSpace, WindowContext, WrappedLine, }; use anyhow::anyhow; use parking_lot::{Mutex, MutexGuard}; @@ -87,7 +88,28 @@ impl StyledText { } } - pub fn with_runs(mut self, runs: Vec) -> Self { + pub fn with_highlights( + mut self, + default_style: &TextStyle, + highlights: impl IntoIterator, HighlightStyle)>, + ) -> Self { + let mut runs = Vec::new(); + let mut ix = 0; + for (range, highlight) in highlights { + if ix < range.start { + runs.push(default_style.clone().to_run(range.start - ix)); + } + runs.push( + default_style + .clone() + .highlight(highlight) + .to_run(range.len()), + ); + ix = range.end; + } + if ix < self.text.len() { + runs.push(default_style.to_run(self.text.len() - ix)); + } self.runs = Some(runs); self } @@ -144,7 +166,6 @@ impl TextState { runs: Option>, cx: &mut WindowContext, ) -> LayoutId { - let text_system = cx.text_system().clone(); let text_style = cx.text_style(); let font_size = text_style.font_size.to_pixels(cx.rem_size()); let line_height = text_style @@ -152,18 +173,16 @@ impl TextState { .to_pixels(font_size.into(), cx.rem_size()); let text = SharedString::from(text); - let rem_size = cx.rem_size(); - let runs = if let Some(runs) = runs { runs } else { vec![text_style.to_run(text.len())] }; - let layout_id = cx.request_measured_layout(Default::default(), rem_size, { + let layout_id = cx.request_measured_layout(Default::default(), { let element_state = self.clone(); - move |known_dimensions, available_space| { + move |known_dimensions, available_space, cx| { let wrap_width = if text_style.white_space == WhiteSpace::Normal { known_dimensions.width.or(match available_space.width { crate::AvailableSpace::Definite(x) => Some(x), @@ -181,7 +200,8 @@ impl TextState { } } - let Some(lines) = text_system + let Some(lines) = cx + .text_system() .shape_text( &text, font_size, &runs, wrap_width, // Wrap if we know the width. ) diff --git a/crates/gpui2/src/elements/uniform_list.rs b/crates/gpui2/src/elements/uniform_list.rs index 2d5a46f3d99ef4887714b905f24cfbd29d5410c5..d8f4cc6804788d6f06e379321a79ef50c9fbd4d0 100644 --- a/crates/gpui2/src/elements/uniform_list.rs +++ b/crates/gpui2/src/elements/uniform_list.rs @@ -109,7 +109,6 @@ impl Element for UniformList { cx: &mut WindowContext, ) -> (LayoutId, Self::State) { let max_items = self.item_count; - let rem_size = cx.rem_size(); let item_size = state .as_ref() .map(|s| s.item_size) @@ -120,9 +119,7 @@ impl Element for UniformList { .layout(state.map(|s| s.interactive), cx, |style, cx| { cx.request_measured_layout( style, - rem_size, - move |known_dimensions: Size>, - available_space: Size| { + move |known_dimensions, available_space, _cx| { let desired_height = item_size.height * max_items; let width = known_dimensions diff --git a/crates/gpui2/src/executor.rs b/crates/gpui2/src/executor.rs index cf138a90db1b177e052d79788754d446474ce5be..e01846c404f0781ebe01d4657f624fe2d00b343b 100644 --- a/crates/gpui2/src/executor.rs +++ b/crates/gpui2/src/executor.rs @@ -128,11 +128,19 @@ impl BackgroundExecutor { #[cfg(any(test, feature = "test-support"))] #[track_caller] pub fn block_test(&self, future: impl Future) -> R { - self.block_internal(false, future) + if let Ok(value) = self.block_internal(false, future, usize::MAX) { + value + } else { + unreachable!() + } } pub fn block(&self, future: impl Future) -> R { - self.block_internal(true, future) + if let Ok(value) = self.block_internal(true, future, usize::MAX) { + value + } else { + unreachable!() + } } #[track_caller] @@ -140,7 +148,8 @@ impl BackgroundExecutor { &self, background_only: bool, future: impl Future, - ) -> R { + mut max_ticks: usize, + ) -> Result { pin_mut!(future); let unparker = self.dispatcher.unparker(); let awoken = Arc::new(AtomicBool::new(false)); @@ -156,8 +165,13 @@ impl BackgroundExecutor { loop { match future.as_mut().poll(&mut cx) { - Poll::Ready(result) => return result, + Poll::Ready(result) => return Ok(result), Poll::Pending => { + if max_ticks == 0 { + return Err(()); + } + max_ticks -= 1; + if !self.dispatcher.tick(background_only) { if awoken.swap(false, SeqCst) { continue; @@ -192,16 +206,25 @@ impl BackgroundExecutor { return Err(future); } + #[cfg(any(test, feature = "test-support"))] + let max_ticks = self + .dispatcher + .as_test() + .map_or(usize::MAX, |dispatcher| dispatcher.gen_block_on_ticks()); + #[cfg(not(any(test, feature = "test-support")))] + let max_ticks = usize::MAX; + let mut timer = self.timer(duration).fuse(); + let timeout = async { futures::select_biased! { value = future => Ok(value), _ = timer => Err(()), } }; - match self.block(timeout) { - Ok(value) => Ok(value), - Err(_) => Err(future), + match self.block_internal(true, timeout, max_ticks) { + Ok(Ok(value)) => Ok(value), + _ => Err(future), } } @@ -281,6 +304,11 @@ impl BackgroundExecutor { pub fn is_main_thread(&self) -> bool { self.dispatcher.is_main_thread() } + + #[cfg(any(test, feature = "test-support"))] + pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive) { + self.dispatcher.as_test().unwrap().set_block_on_ticks(range); + } } impl ForegroundExecutor { diff --git a/crates/gpui2/src/geometry.rs b/crates/gpui2/src/geometry.rs index d32c2e849be6151e6a1909405ee96c9ff838cca3..20afd2d288b29bc8953c41c79389b9331a80244b 100644 --- a/crates/gpui2/src/geometry.rs +++ b/crates/gpui2/src/geometry.rs @@ -655,6 +655,20 @@ pub struct Corners { pub bottom_left: T, } +impl Corners +where + T: Clone + Default + Debug, +{ + pub fn all(value: T) -> Self { + Self { + top_left: value.clone(), + top_right: value.clone(), + bottom_right: value.clone(), + bottom_left: value, + } + } +} + impl Corners { pub fn to_pixels(&self, size: Size, rem_size: Pixels) -> Corners { let max = size.width.max(size.height) / 2.; diff --git a/crates/gpui2/src/gpui2.rs b/crates/gpui2/src/gpui2.rs index 984859f1b005f8fa2edd3256de75c1aa3010ce2b..5b88286240e1ef35df7ab4dd5356b7498ff2bb69 100644 --- a/crates/gpui2/src/gpui2.rs +++ b/crates/gpui2/src/gpui2.rs @@ -21,7 +21,7 @@ mod subscription; mod svg_renderer; mod taffy; #[cfg(any(test, feature = "test-support"))] -mod test; +pub mod test; mod text_system; mod util; mod view; diff --git a/crates/gpui2/src/key_dispatch.rs b/crates/gpui2/src/key_dispatch.rs index 5fbf83bfbab8e45320ee856fd7542298abdc9649..4838b1a612ce65ba33c03ac25da878a752f716d3 100644 --- a/crates/gpui2/src/key_dispatch.rs +++ b/crates/gpui2/src/key_dispatch.rs @@ -16,7 +16,7 @@ pub struct DispatchNodeId(usize); pub(crate) struct DispatchTree { node_stack: Vec, - context_stack: Vec, + pub(crate) context_stack: Vec, nodes: Vec, focusable_node_ids: HashMap, keystroke_matchers: HashMap, KeystrokeMatcher>, @@ -163,11 +163,25 @@ impl DispatchTree { actions } - pub fn bindings_for_action(&self, action: &dyn Action) -> Vec { + pub fn bindings_for_action( + &self, + action: &dyn Action, + context_stack: &Vec, + ) -> Vec { self.keymap .lock() .bindings_for_action(action.type_id()) - .filter(|candidate| candidate.action.partial_eq(action)) + .filter(|candidate| { + if !candidate.action.partial_eq(action) { + return false; + } + for i in 1..context_stack.len() { + if candidate.matches_context(&context_stack[0..=i]) { + return true; + } + } + return false; + }) .cloned() .collect() } diff --git a/crates/gpui2/src/platform.rs b/crates/gpui2/src/platform.rs index 3027c05fbd84c27c6f51fe02d5da84f38815d067..651392c9c80eef548fb15a3c2d88d33488740661 100644 --- a/crates/gpui2/src/platform.rs +++ b/crates/gpui2/src/platform.rs @@ -44,7 +44,7 @@ pub(crate) fn current_platform() -> Rc { Rc::new(MacPlatform::new()) } -pub(crate) trait Platform: 'static { +pub trait Platform: 'static { fn background_executor(&self) -> BackgroundExecutor; fn foreground_executor(&self) -> ForegroundExecutor; fn text_system(&self) -> Arc; @@ -128,7 +128,7 @@ impl Debug for DisplayId { unsafe impl Send for DisplayId {} -pub(crate) trait PlatformWindow { +pub trait PlatformWindow { fn bounds(&self) -> WindowBounds; fn content_size(&self) -> Size; fn scale_factor(&self) -> f32; @@ -158,6 +158,11 @@ pub(crate) trait PlatformWindow { fn draw(&self, scene: Scene); fn sprite_atlas(&self) -> Arc; + + #[cfg(any(test, feature = "test-support"))] + fn as_test(&mut self) -> Option<&mut TestWindow> { + None + } } pub trait PlatformDispatcher: Send + Sync { @@ -467,13 +472,27 @@ pub enum PromptLevel { Critical, } +/// The style of the cursor (pointer) #[derive(Copy, Clone, Debug)] pub enum CursorStyle { Arrow, + IBeam, + Crosshair, + ClosedHand, + OpenHand, + PointingHand, + ResizeLeft, + ResizeRight, ResizeLeftRight, + ResizeUp, + ResizeDown, ResizeUpDown, - PointingHand, - IBeam, + DisappearingItem, + IBeamCursorForVerticalLayout, + OperationNotAllowed, + DragLink, + DragCopy, + ContextualMenu, } impl Default for CursorStyle { diff --git a/crates/gpui2/src/platform/mac/platform.rs b/crates/gpui2/src/platform/mac/platform.rs index 7065c02e8755cf226b532a694d34a0b0a436c95b..314f055811c57cde9c654294e2d39ed7f1cc3806 100644 --- a/crates/gpui2/src/platform/mac/platform.rs +++ b/crates/gpui2/src/platform/mac/platform.rs @@ -724,16 +724,35 @@ impl Platform for MacPlatform { } } + /// Match cursor style to one of the styles available + /// in macOS's [NSCursor](https://developer.apple.com/documentation/appkit/nscursor). fn set_cursor_style(&self, style: CursorStyle) { unsafe { let new_cursor: id = match style { CursorStyle::Arrow => msg_send![class!(NSCursor), arrowCursor], - CursorStyle::ResizeLeftRight => { - msg_send![class!(NSCursor), resizeLeftRightCursor] - } - CursorStyle::ResizeUpDown => msg_send![class!(NSCursor), resizeUpDownCursor], - CursorStyle::PointingHand => msg_send![class!(NSCursor), pointingHandCursor], CursorStyle::IBeam => msg_send![class!(NSCursor), IBeamCursor], + CursorStyle::Crosshair => msg_send![class!(NSCursor), crosshairCursor], + CursorStyle::ClosedHand => msg_send![class!(NSCursor), closedHandCursor], + CursorStyle::OpenHand => msg_send![class!(NSCursor), openHandCursor], + CursorStyle::PointingHand => msg_send![class!(NSCursor), pointingHandCursor], + CursorStyle::ResizeLeft => msg_send![class!(NSCursor), resizeLeftCursor], + CursorStyle::ResizeRight => msg_send![class!(NSCursor), resizeRightCursor], + CursorStyle::ResizeLeftRight => msg_send![class!(NSCursor), resizeLeftRightCursor], + CursorStyle::ResizeUp => msg_send![class!(NSCursor), resizeUpCursor], + CursorStyle::ResizeDown => msg_send![class!(NSCursor), resizeDownCursor], + CursorStyle::ResizeUpDown => msg_send![class!(NSCursor), resizeUpDownCursor], + CursorStyle::DisappearingItem => { + msg_send![class!(NSCursor), disappearingItemCursor] + } + CursorStyle::IBeamCursorForVerticalLayout => { + msg_send![class!(NSCursor), IBeamCursorForVerticalLayout] + } + CursorStyle::OperationNotAllowed => { + msg_send![class!(NSCursor), operationNotAllowedCursor] + } + CursorStyle::DragLink => msg_send![class!(NSCursor), dragLinkCursor], + CursorStyle::DragCopy => msg_send![class!(NSCursor), dragCopyCursor], + CursorStyle::ContextualMenu => msg_send![class!(NSCursor), contextualMenuCursor], }; let old_cursor: id = msg_send![class!(NSCursor), currentCursor]; diff --git a/crates/gpui2/src/platform/test/dispatcher.rs b/crates/gpui2/src/platform/test/dispatcher.rs index e77c1c052903f44b2e346af5b3d7a6fb57cda65f..9023627d1e2d777188d1b5bc96f89cabc4fbe903 100644 --- a/crates/gpui2/src/platform/test/dispatcher.rs +++ b/crates/gpui2/src/platform/test/dispatcher.rs @@ -7,6 +7,7 @@ use parking_lot::Mutex; use rand::prelude::*; use std::{ future::Future, + ops::RangeInclusive, pin::Pin, sync::Arc, task::{Context, Poll}, @@ -36,6 +37,7 @@ struct TestDispatcherState { allow_parking: bool, waiting_backtrace: Option, deprioritized_task_labels: HashSet, + block_on_ticks: RangeInclusive, } impl TestDispatcher { @@ -53,6 +55,7 @@ impl TestDispatcher { allow_parking: false, waiting_backtrace: None, deprioritized_task_labels: Default::default(), + block_on_ticks: 0..=1000, }; TestDispatcher { @@ -82,8 +85,8 @@ impl TestDispatcher { } pub fn simulate_random_delay(&self) -> impl 'static + Send + Future { - pub struct YieldNow { - count: usize, + struct YieldNow { + pub(crate) count: usize, } impl Future for YieldNow { @@ -142,6 +145,16 @@ impl TestDispatcher { pub fn rng(&self) -> StdRng { self.state.lock().random.clone() } + + pub fn set_block_on_ticks(&self, range: std::ops::RangeInclusive) { + self.state.lock().block_on_ticks = range; + } + + pub fn gen_block_on_ticks(&self) -> usize { + let mut lock = self.state.lock(); + let block_on_ticks = lock.block_on_ticks.clone(); + lock.random.gen_range(block_on_ticks) + } } impl Clone for TestDispatcher { diff --git a/crates/gpui2/src/platform/test/platform.rs b/crates/gpui2/src/platform/test/platform.rs index df1ed9b2a6dee715fb707b821e0cdba533ac90b2..fa4b6e18c587521d88d8d4a4fd4041952c584f4a 100644 --- a/crates/gpui2/src/platform/test/platform.rs +++ b/crates/gpui2/src/platform/test/platform.rs @@ -1,6 +1,6 @@ use crate::{ - AnyWindowHandle, BackgroundExecutor, CursorStyle, DisplayId, ForegroundExecutor, Platform, - PlatformDisplay, PlatformTextSystem, TestDisplay, TestWindow, WindowOptions, + AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, + Platform, PlatformDisplay, PlatformTextSystem, TestDisplay, TestWindow, WindowOptions, }; use anyhow::{anyhow, Result}; use collections::VecDeque; @@ -20,6 +20,7 @@ pub struct TestPlatform { active_window: Arc>>, active_display: Rc, active_cursor: Mutex, + current_clipboard_item: Mutex>, pub(crate) prompts: RefCell, weak: Weak, } @@ -39,6 +40,7 @@ impl TestPlatform { active_cursor: Default::default(), active_display: Rc::new(TestDisplay::new()), active_window: Default::default(), + current_clipboard_item: Mutex::new(None), weak: weak.clone(), }) } @@ -189,13 +191,9 @@ impl Platform for TestPlatform { unimplemented!() } - fn on_become_active(&self, _callback: Box) { - unimplemented!() - } + fn on_become_active(&self, _callback: Box) {} - fn on_resign_active(&self, _callback: Box) { - unimplemented!() - } + fn on_resign_active(&self, _callback: Box) {} fn on_quit(&self, _callback: Box) {} @@ -240,12 +238,12 @@ impl Platform for TestPlatform { true } - fn write_to_clipboard(&self, _item: crate::ClipboardItem) { - unimplemented!() + fn write_to_clipboard(&self, item: ClipboardItem) { + *self.current_clipboard_item.lock() = Some(item); } - fn read_from_clipboard(&self) -> Option { - unimplemented!() + fn read_from_clipboard(&self) -> Option { + self.current_clipboard_item.lock().clone() } fn write_credentials(&self, _url: &str, _username: &str, _password: &[u8]) -> Result<()> { diff --git a/crates/gpui2/src/platform/test/window.rs b/crates/gpui2/src/platform/test/window.rs index e355c3aa4b5ec0681faad153f24c1f3ca5a4fceb..b1bfebad06745f51899421707d2ead4da57bfcb6 100644 --- a/crates/gpui2/src/platform/test/window.rs +++ b/crates/gpui2/src/platform/test/window.rs @@ -11,19 +11,20 @@ use std::{ }; #[derive(Default)] -struct Handlers { - active_status_change: Vec>, - input: Vec bool>>, - moved: Vec>, - resize: Vec, f32)>>, +pub(crate) struct TestWindowHandlers { + pub(crate) active_status_change: Vec>, + pub(crate) input: Vec bool>>, + pub(crate) moved: Vec>, + pub(crate) resize: Vec, f32)>>, } pub struct TestWindow { - bounds: WindowBounds, + pub(crate) bounds: WindowBounds, current_scene: Mutex>, display: Rc, + pub(crate) window_title: Option, pub(crate) input_handler: Option>>>, - handlers: Mutex, + pub(crate) handlers: Arc>, platform: Weak, sprite_atlas: Arc, } @@ -42,6 +43,7 @@ impl TestWindow { input_handler: None, sprite_atlas: Arc::new(TestAtlas::new()), handlers: Default::default(), + window_title: Default::default(), } } } @@ -100,8 +102,8 @@ impl PlatformWindow for TestWindow { todo!() } - fn set_title(&mut self, _title: &str) { - todo!() + fn set_title(&mut self, title: &str) { + self.window_title = Some(title.to_owned()); } fn set_edited(&mut self, _edited: bool) { @@ -167,6 +169,10 @@ impl PlatformWindow for TestWindow { fn sprite_atlas(&self) -> sync::Arc { self.sprite_atlas.clone() } + + fn as_test(&mut self) -> Option<&mut TestWindow> { + Some(self) + } } pub struct TestAtlasState { diff --git a/crates/gpui2/src/scene.rs b/crates/gpui2/src/scene.rs index 549260560236ffb43179caf6cb8fce5340c10b7a..ca0a50546e0f56b80ca8b6c055fde0ab57f430f9 100644 --- a/crates/gpui2/src/scene.rs +++ b/crates/gpui2/src/scene.rs @@ -198,7 +198,7 @@ impl SceneBuilder { } } -pub(crate) struct Scene { +pub struct Scene { pub shadows: Vec, pub quads: Vec, pub paths: Vec>, @@ -214,7 +214,7 @@ impl Scene { &self.paths } - pub fn batches(&self) -> impl Iterator { + pub(crate) fn batches(&self) -> impl Iterator { BatchIterator { shadows: &self.shadows, shadows_start: 0, diff --git a/crates/gpui2/src/style.rs b/crates/gpui2/src/style.rs index 640538fff0ed204d3af16e24ecd0006d98f8357e..9254eaeb85246253180e6ffc59c3f1f12895ae59 100644 --- a/crates/gpui2/src/style.rs +++ b/crates/gpui2/src/style.rs @@ -208,8 +208,9 @@ impl TextStyle { } } + /// Returns the rounded line height in pixels. pub fn line_height_in_pixels(&self, rem_size: Pixels) -> Pixels { - self.line_height.to_pixels(self.font_size, rem_size) + self.line_height.to_pixels(self.font_size, rem_size).round() } pub fn to_run(&self, len: usize) -> TextRun { diff --git a/crates/gpui2/src/styled.rs b/crates/gpui2/src/styled.rs index 77756154b58e1e0bd1c3bcbe709e0675c3bd6049..346c1a760d6a56105440bfd2a3a788497d103a66 100644 --- a/crates/gpui2/src/styled.rs +++ b/crates/gpui2/src/styled.rs @@ -101,6 +101,125 @@ pub trait Styled: Sized { self } + /// Sets cursor style when hovering over an element to `text`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_text(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::IBeam); + self + } + + /// Sets cursor style when hovering over an element to `move`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_move(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ClosedHand); + self + } + + /// Sets cursor style when hovering over an element to `not-allowed`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_not_allowed(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::OperationNotAllowed); + self + } + + /// Sets cursor style when hovering over an element to `context-menu`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_context_menu(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ContextualMenu); + self + } + + /// Sets cursor style when hovering over an element to `crosshair`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_crosshair(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::Crosshair); + self + } + + /// Sets cursor style when hovering over an element to `vertical-text`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_vertical_text(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::IBeamCursorForVerticalLayout); + self + } + + /// Sets cursor style when hovering over an element to `alias`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_alias(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::DragLink); + self + } + + /// Sets cursor style when hovering over an element to `copy`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_copy(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::DragCopy); + self + } + + /// Sets cursor style when hovering over an element to `no-drop`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_no_drop(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::OperationNotAllowed); + self + } + + /// Sets cursor style when hovering over an element to `grab`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_grab(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::OpenHand); + self + } + + /// Sets cursor style when hovering over an element to `grabbing`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_grabbing(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ClosedHand); + self + } + + /// Sets cursor style when hovering over an element to `col-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_col_resize(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ResizeLeftRight); + self + } + + /// Sets cursor style when hovering over an element to `row-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_row_resize(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ResizeUpDown); + self + } + + /// Sets cursor style when hovering over an element to `n-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_n_resize(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ResizeUp); + self + } + + /// Sets cursor style when hovering over an element to `e-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_e_resize(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ResizeRight); + self + } + + /// Sets cursor style when hovering over an element to `s-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_s_resize(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ResizeDown); + self + } + + /// Sets cursor style when hovering over an element to `w-resize`. + /// [Docs](https://tailwindcss.com/docs/cursor) + fn cursor_w_resize(mut self) -> Self { + self.style().mouse_cursor = Some(CursorStyle::ResizeLeft); + self + } + /// Sets the whitespace of the element to `normal`. /// [Docs](https://tailwindcss.com/docs/whitespace#normal) fn whitespace_normal(mut self) -> Self { diff --git a/crates/gpui2/src/subscription.rs b/crates/gpui2/src/subscription.rs index 7cb023a9074094b27d16b6effa27c68b967f30c8..867c83fcbb31527db4be5d1c912b013a35872290 100644 --- a/crates/gpui2/src/subscription.rs +++ b/crates/gpui2/src/subscription.rs @@ -1,6 +1,6 @@ use collections::{BTreeMap, BTreeSet}; use parking_lot::Mutex; -use std::{fmt::Debug, mem, sync::Arc}; +use std::{cell::Cell, fmt::Debug, mem, rc::Rc, sync::Arc}; use util::post_inc; pub(crate) struct SubscriberSet( @@ -14,11 +14,16 @@ impl Clone for SubscriberSet { } struct SubscriberSetState { - subscribers: BTreeMap>>, + subscribers: BTreeMap>>>, dropped_subscribers: BTreeSet<(EmitterKey, usize)>, next_subscriber_id: usize, } +struct Subscriber { + active: Rc>, + callback: Callback, +} + impl SubscriberSet where EmitterKey: 'static + Ord + Clone + Debug, @@ -32,16 +37,33 @@ where }))) } - pub fn insert(&self, emitter_key: EmitterKey, callback: Callback) -> Subscription { + /// Inserts a new `[Subscription]` for the given `emitter_key`. By default, subscriptions + /// are inert, meaning that they won't be listed when calling `[SubscriberSet::remove]` or `[SubscriberSet::retain]`. + /// This method returns a tuple of a `[Subscription]` and an `impl FnOnce`, and you can use the latter + /// to activate the `[Subscription]`. + #[must_use] + pub fn insert( + &self, + emitter_key: EmitterKey, + callback: Callback, + ) -> (Subscription, impl FnOnce()) { + let active = Rc::new(Cell::new(false)); let mut lock = self.0.lock(); let subscriber_id = post_inc(&mut lock.next_subscriber_id); lock.subscribers .entry(emitter_key.clone()) .or_default() .get_or_insert_with(|| Default::default()) - .insert(subscriber_id, callback); + .insert( + subscriber_id, + Subscriber { + active: active.clone(), + callback, + }, + ); let this = self.0.clone(); - Subscription { + + let subscription = Subscription { unsubscribe: Some(Box::new(move || { let mut lock = this.lock(); let Some(subscribers) = lock.subscribers.get_mut(&emitter_key) else { @@ -63,7 +85,8 @@ where lock.dropped_subscribers .insert((emitter_key, subscriber_id)); })), - } + }; + (subscription, move || active.set(true)) } pub fn remove(&self, emitter: &EmitterKey) -> impl IntoIterator { @@ -73,6 +96,13 @@ where .map(|s| s.into_values()) .into_iter() .flatten() + .filter_map(|subscriber| { + if subscriber.active.get() { + Some(subscriber.callback) + } else { + None + } + }) } /// Call the given callback for each subscriber to the given emitter. @@ -91,7 +121,13 @@ where return; }; - subscribers.retain(|_, callback| f(callback)); + subscribers.retain(|_, subscriber| { + if subscriber.active.get() { + f(&mut subscriber.callback) + } else { + true + } + }); let mut lock = self.0.lock(); // Add any new subscribers that were added while invoking the callback. diff --git a/crates/gpui2/src/taffy.rs b/crates/gpui2/src/taffy.rs index 81a057055a1af794d0d19f0921d7807e09417f70..2bceb1bc139be7af1aedc67827da582f319bfc87 100644 --- a/crates/gpui2/src/taffy.rs +++ b/crates/gpui2/src/taffy.rs @@ -1,4 +1,7 @@ -use super::{AbsoluteLength, Bounds, DefiniteLength, Edges, Length, Pixels, Point, Size, Style}; +use crate::{ + AbsoluteLength, Bounds, DefiniteLength, Edges, Length, Pixels, Point, Size, Style, + WindowContext, +}; use collections::{HashMap, HashSet}; use smallvec::SmallVec; use std::fmt::Debug; @@ -9,13 +12,21 @@ use taffy::{ Taffy, }; -type Measureable = dyn Fn(Size>, Size) -> Size + Send + Sync; - pub struct TaffyLayoutEngine { - taffy: Taffy>, + taffy: Taffy, children_to_parents: HashMap, absolute_layout_bounds: HashMap>, computed_layouts: HashSet, + nodes_to_measure: HashMap< + LayoutId, + Box< + dyn FnMut( + Size>, + Size, + &mut WindowContext, + ) -> Size, + >, + >, } static EXPECT_MESSAGE: &'static str = @@ -28,6 +39,7 @@ impl TaffyLayoutEngine { children_to_parents: HashMap::default(), absolute_layout_bounds: HashMap::default(), computed_layouts: HashSet::default(), + nodes_to_measure: HashMap::default(), } } @@ -36,6 +48,7 @@ impl TaffyLayoutEngine { self.children_to_parents.clear(); self.absolute_layout_bounds.clear(); self.computed_layouts.clear(); + self.nodes_to_measure.clear(); } pub fn request_layout( @@ -65,18 +78,18 @@ impl TaffyLayoutEngine { &mut self, style: Style, rem_size: Pixels, - measure: impl Fn(Size>, Size) -> Size - + Send - + Sync + measure: impl FnMut(Size>, Size, &mut WindowContext) -> Size + 'static, ) -> LayoutId { let style = style.to_taffy(rem_size); - let measurable = Box::new(measure); - self.taffy - .new_leaf_with_context(style, measurable) + let layout_id = self + .taffy + .new_leaf_with_context(style, ()) .expect(EXPECT_MESSAGE) - .into() + .into(); + self.nodes_to_measure.insert(layout_id, Box::new(measure)); + layout_id } // Used to understand performance @@ -126,7 +139,12 @@ impl TaffyLayoutEngine { Ok(edges) } - pub fn compute_layout(&mut self, id: LayoutId, available_space: Size) { + pub fn compute_layout( + &mut self, + id: LayoutId, + available_space: Size, + cx: &mut WindowContext, + ) { // Leaving this here until we have a better instrumentation approach. // println!("Laying out {} children", self.count_all_children(id)?); // println!("Max layout depth: {}", self.max_depth(0, id)?); @@ -159,8 +177,8 @@ impl TaffyLayoutEngine { .compute_layout_with_measure( id.into(), available_space.into(), - |known_dimensions, available_space, _node_id, context| { - let Some(measure) = context else { + |known_dimensions, available_space, node_id, _context| { + let Some(measure) = self.nodes_to_measure.get_mut(&node_id.into()) else { return taffy::geometry::Size::default(); }; @@ -169,10 +187,11 @@ impl TaffyLayoutEngine { height: known_dimensions.height.map(Pixels), }; - measure(known_dimensions, available_space.into()).into() + measure(known_dimensions, available_space.into(), cx).into() }, ) .expect(EXPECT_MESSAGE); + // println!("compute_layout took {:?}", started_at.elapsed()); } diff --git a/crates/gpui2/src/test.rs b/crates/gpui2/src/test.rs index 3f2697f7e3f2d2c6c44165728503b7fa1accf6e0..5a21576fb26178ff67c750ca7ee63652690f1700 100644 --- a/crates/gpui2/src/test.rs +++ b/crates/gpui2/src/test.rs @@ -1,5 +1,7 @@ -use crate::TestDispatcher; +use crate::{Entity, Subscription, TestAppContext, TestDispatcher}; +use futures::StreamExt as _; use rand::prelude::*; +use smol::channel; use std::{ env, panic::{self, RefUnwindSafe}, @@ -49,3 +51,30 @@ pub fn run_test( } } } + +pub struct Observation { + rx: channel::Receiver, + _subscription: Subscription, +} + +impl futures::Stream for Observation { + type Item = T; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.rx.poll_next_unpin(cx) + } +} + +pub fn observe(entity: &impl Entity, cx: &mut TestAppContext) -> Observation<()> { + let (tx, rx) = smol::channel::unbounded(); + let _subscription = cx.update(|cx| { + cx.observe(entity, move |_, _| { + let _ = smol::block_on(tx.send(())); + }) + }); + + Observation { rx, _subscription } +} diff --git a/crates/gpui2/src/text_system.rs b/crates/gpui2/src/text_system.rs index 440789dd472b35c02e1bbf3c2605e7b4c8ae3be3..b3f17bd057e2253fbba8d80439289cdc57f275d3 100644 --- a/crates/gpui2/src/text_system.rs +++ b/crates/gpui2/src/text_system.rs @@ -72,7 +72,7 @@ impl TextSystem { } } - pub fn bounding_box(&self, font_id: FontId, font_size: Pixels) -> Result> { + pub fn bounding_box(&self, font_id: FontId, font_size: Pixels) -> Bounds { self.read_metrics(font_id, |metrics| metrics.bounding_box(font_size)) } @@ -89,9 +89,9 @@ impl TextSystem { let bounds = self .platform_text_system .typographic_bounds(font_id, glyph_id)?; - self.read_metrics(font_id, |metrics| { + Ok(self.read_metrics(font_id, |metrics| { (bounds / metrics.units_per_em as f32 * font_size.0).map(px) - }) + })) } pub fn advance(&self, font_id: FontId, font_size: Pixels, ch: char) -> Result> { @@ -100,28 +100,28 @@ impl TextSystem { .glyph_for_char(font_id, ch) .ok_or_else(|| anyhow!("glyph not found for character '{}'", ch))?; let result = self.platform_text_system.advance(font_id, glyph_id)? - / self.units_per_em(font_id)? as f32; + / self.units_per_em(font_id) as f32; Ok(result * font_size) } - pub fn units_per_em(&self, font_id: FontId) -> Result { + pub fn units_per_em(&self, font_id: FontId) -> u32 { self.read_metrics(font_id, |metrics| metrics.units_per_em as u32) } - pub fn cap_height(&self, font_id: FontId, font_size: Pixels) -> Result { + pub fn cap_height(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.read_metrics(font_id, |metrics| metrics.cap_height(font_size)) } - pub fn x_height(&self, font_id: FontId, font_size: Pixels) -> Result { + pub fn x_height(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.read_metrics(font_id, |metrics| metrics.x_height(font_size)) } - pub fn ascent(&self, font_id: FontId, font_size: Pixels) -> Result { + pub fn ascent(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.read_metrics(font_id, |metrics| metrics.ascent(font_size)) } - pub fn descent(&self, font_id: FontId, font_size: Pixels) -> Result { + pub fn descent(&self, font_id: FontId, font_size: Pixels) -> Pixels { self.read_metrics(font_id, |metrics| metrics.descent(font_size)) } @@ -130,24 +130,24 @@ impl TextSystem { font_id: FontId, font_size: Pixels, line_height: Pixels, - ) -> Result { - let ascent = self.ascent(font_id, font_size)?; - let descent = self.descent(font_id, font_size)?; + ) -> Pixels { + let ascent = self.ascent(font_id, font_size); + let descent = self.descent(font_id, font_size); let padding_top = (line_height - ascent - descent) / 2.; - Ok(padding_top + ascent) + padding_top + ascent } - fn read_metrics(&self, font_id: FontId, read: impl FnOnce(&FontMetrics) -> T) -> Result { + fn read_metrics(&self, font_id: FontId, read: impl FnOnce(&FontMetrics) -> T) -> T { let lock = self.font_metrics.upgradable_read(); if let Some(metrics) = lock.get(&font_id) { - Ok(read(metrics)) + read(metrics) } else { let mut lock = RwLockUpgradableReadGuard::upgrade(lock); let metrics = lock .entry(font_id) .or_insert_with(|| self.platform_text_system.font_metrics(font_id)); - Ok(read(metrics)) + read(metrics) } } diff --git a/crates/gpui2/src/text_system/line.rs b/crates/gpui2/src/text_system/line.rs index 0d15647b88fdfb112b72b150b0500d20ffac8b37..d62bee69c095139114ecf45561287d6ac218da2a 100644 --- a/crates/gpui2/src/text_system/line.rs +++ b/crates/gpui2/src/text_system/line.rs @@ -101,9 +101,7 @@ fn paint_line( let mut glyph_origin = origin; let mut prev_glyph_position = Point::default(); for (run_ix, run) in layout.runs.iter().enumerate() { - let max_glyph_size = text_system - .bounding_box(run.font_id, layout.font_size)? - .size; + let max_glyph_size = text_system.bounding_box(run.font_id, layout.font_size).size; for (glyph_ix, glyph) in run.glyphs.iter().enumerate() { glyph_origin.x += glyph.position.x - prev_glyph_position.x; diff --git a/crates/gpui2/src/view.rs b/crates/gpui2/src/view.rs index f31b0ae753c2a759a776857658c925eb34787dee..280c52df2afad19af029a75e336222eae82aa74e 100644 --- a/crates/gpui2/src/view.rs +++ b/crates/gpui2/src/view.rs @@ -209,9 +209,7 @@ impl AnyView { ) { cx.with_absolute_element_offset(origin, |cx| { let (layout_id, rendered_element) = (self.layout)(self, cx); - cx.window - .layout_engine - .compute_layout(layout_id, available_space); + cx.compute_layout(layout_id, available_space); (self.paint)(self, rendered_element, cx); }) } @@ -240,6 +238,10 @@ impl Element for AnyView { } fn paint(self, _: Bounds, state: &mut Self::State, cx: &mut WindowContext) { + debug_assert!( + state.is_some(), + "state is None. Did you include an AnyView twice in the tree?" + ); (self.paint)(&self, state.take().unwrap(), cx) } } diff --git a/crates/gpui2/src/window.rs b/crates/gpui2/src/window.rs index 76932f28e4a7dbe37434b9d785587b48008b5200..8eb14769bf1e45e30a468e9f32d694201591be86 100644 --- a/crates/gpui2/src/window.rs +++ b/crates/gpui2/src/window.rs @@ -209,7 +209,7 @@ pub struct Window { sprite_atlas: Arc, rem_size: Pixels, viewport_size: Size, - pub(crate) layout_engine: TaffyLayoutEngine, + layout_engine: Option, pub(crate) root_view: Option, pub(crate) element_id_stack: GlobalElementId, pub(crate) previous_frame: Frame, @@ -327,7 +327,7 @@ impl Window { sprite_atlas, rem_size: px(16.), viewport_size: content_size, - layout_engine: TaffyLayoutEngine::new(), + layout_engine: Some(TaffyLayoutEngine::new()), root_view: None, element_id_stack: GlobalElementId::default(), previous_frame: Frame::new(DispatchTree::new(cx.keymap.clone(), cx.actions.clone())), @@ -490,7 +490,7 @@ impl<'a> WindowContext<'a> { let entity_id = entity.entity_id(); let entity = entity.downgrade(); let window_handle = self.window.handle; - self.app.event_listeners.insert( + let (subscription, activate) = self.app.event_listeners.insert( entity_id, ( TypeId::of::(), @@ -508,7 +508,9 @@ impl<'a> WindowContext<'a> { .unwrap_or(false) }), ), - ) + ); + self.app.defer(move |_| activate()); + subscription } /// Create an `AsyncWindowContext`, which has a static lifetime and can be held across @@ -606,9 +608,11 @@ impl<'a> WindowContext<'a> { self.app.layout_id_buffer.extend(children.into_iter()); let rem_size = self.rem_size(); - self.window - .layout_engine - .request_layout(style, rem_size, &self.app.layout_id_buffer) + self.window.layout_engine.as_mut().unwrap().request_layout( + style, + rem_size, + &self.app.layout_id_buffer, + ) } /// Add a node to the layout tree for the current frame. Instead of taking a `Style` and children, @@ -618,22 +622,25 @@ impl<'a> WindowContext<'a> { /// The given closure is invoked at layout time with the known dimensions and available space and /// returns a `Size`. pub fn request_measured_layout< - F: Fn(Size>, Size) -> Size + Send + Sync + 'static, + F: FnMut(Size>, Size, &mut WindowContext) -> Size + + 'static, >( &mut self, style: Style, - rem_size: Pixels, measure: F, ) -> LayoutId { + let rem_size = self.rem_size(); self.window .layout_engine + .as_mut() + .unwrap() .request_measured_layout(style, rem_size, measure) } pub fn compute_layout(&mut self, layout_id: LayoutId, available_space: Size) { - self.window - .layout_engine - .compute_layout(layout_id, available_space) + let mut layout_engine = self.window.layout_engine.take().unwrap(); + layout_engine.compute_layout(layout_id, available_space, self); + self.window.layout_engine = Some(layout_engine); } /// Obtain the bounds computed for the given LayoutId relative to the window. This method should not @@ -643,6 +650,8 @@ impl<'a> WindowContext<'a> { let mut bounds = self .window .layout_engine + .as_mut() + .unwrap() .layout_bounds(layout_id) .map(Into::into); bounds.origin += self.element_offset(); @@ -678,6 +687,10 @@ impl<'a> WindowContext<'a> { self.window.platform_window.zoom(); } + pub fn set_window_title(&mut self, title: &str) { + self.window.platform_window.set_title(title); + } + pub fn display(&self) -> Option> { self.platform .displays() @@ -1189,7 +1202,7 @@ impl<'a> WindowContext<'a> { self.text_system().start_frame(); let window = &mut *self.window; - window.layout_engine.clear(); + window.layout_engine.as_mut().unwrap().clear(); mem::swap(&mut window.previous_frame, &mut window.current_frame); let frame = &mut window.current_frame; @@ -1337,6 +1350,8 @@ impl<'a> WindowContext<'a> { .dispatch_tree .dispatch_path(node_id); + let mut actions: Vec> = Vec::new(); + // Capture phase let mut context_stack: SmallVec<[KeyContext; 16]> = SmallVec::new(); self.propagate_event = true; @@ -1371,22 +1386,26 @@ impl<'a> WindowContext<'a> { let node = self.window.current_frame.dispatch_tree.node(*node_id); if !node.context.is_empty() { if let Some(key_down_event) = event.downcast_ref::() { - if let Some(action) = self + if let Some(found) = self .window .current_frame .dispatch_tree .dispatch_key(&key_down_event.keystroke, &context_stack) { - self.dispatch_action_on_node(*node_id, action); - if !self.propagate_event { - return; - } + actions.push(found.boxed_clone()) } } context_stack.pop(); } } + + for action in actions { + self.dispatch_action_on_node(node_id, action); + if !self.propagate_event { + return; + } + } } } @@ -1414,7 +1433,6 @@ impl<'a> WindowContext<'a> { } } } - // Bubble phase for node_id in dispatch_path.iter().rev() { let node = self.window.current_frame.dispatch_tree.node(*node_id); @@ -1442,10 +1460,12 @@ impl<'a> WindowContext<'a> { f: impl Fn(&mut WindowContext<'_>) + 'static, ) -> Subscription { let window_handle = self.window.handle; - self.global_observers.insert( + let (subscription, activate) = self.global_observers.insert( TypeId::of::(), Box::new(move |cx| window_handle.update(cx, |_, cx| f(cx)).is_ok()), - ) + ); + self.app.defer(move |_| activate()); + subscription } pub fn activate_window(&self) { @@ -1482,9 +1502,30 @@ impl<'a> WindowContext<'a> { pub fn bindings_for_action(&self, action: &dyn Action) -> Vec { self.window - .current_frame + .previous_frame .dispatch_tree - .bindings_for_action(action) + .bindings_for_action( + action, + &self.window.previous_frame.dispatch_tree.context_stack, + ) + } + + pub fn bindings_for_action_in( + &self, + action: &dyn Action, + focus_handle: &FocusHandle, + ) -> Vec { + let dispatch_tree = &self.window.previous_frame.dispatch_tree; + + let Some(node_id) = dispatch_tree.focusable_node_id(focus_handle.id) else { + return vec![]; + }; + let context_stack = dispatch_tree + .dispatch_path(node_id) + .into_iter() + .map(|node_id| dispatch_tree.node(node_id).context.clone()) + .collect(); + dispatch_tree.bindings_for_action(action, &context_stack) } pub fn listener_for( @@ -2085,7 +2126,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { let entity_id = entity.entity_id(); let entity = entity.downgrade(); let window_handle = self.window.handle; - self.app.observers.insert( + let (subscription, activate) = self.app.observers.insert( entity_id, Box::new(move |cx| { window_handle @@ -2099,7 +2140,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { }) .unwrap_or(false) }), - ) + ); + self.app.defer(move |_| activate()); + subscription } pub fn subscribe( @@ -2116,7 +2159,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { let entity_id = entity.entity_id(); let handle = entity.downgrade(); let window_handle = self.window.handle; - self.app.event_listeners.insert( + let (subscription, activate) = self.app.event_listeners.insert( entity_id, ( TypeId::of::(), @@ -2134,7 +2177,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { .unwrap_or(false) }), ), - ) + ); + self.app.defer(move |_| activate()); + subscription } pub fn on_release( @@ -2142,13 +2187,15 @@ impl<'a, V: 'static> ViewContext<'a, V> { on_release: impl FnOnce(&mut V, &mut WindowContext) + 'static, ) -> Subscription { let window_handle = self.window.handle; - self.app.release_listeners.insert( + let (subscription, activate) = self.app.release_listeners.insert( self.view.model.entity_id, Box::new(move |this, cx| { let this = this.downcast_mut().expect("invalid entity type"); let _ = window_handle.update(cx, |_, cx| on_release(this, cx)); }), - ) + ); + activate(); + subscription } pub fn observe_release( @@ -2164,7 +2211,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { let view = self.view().downgrade(); let entity_id = entity.entity_id(); let window_handle = self.window.handle; - self.app.release_listeners.insert( + let (subscription, activate) = self.app.release_listeners.insert( entity_id, Box::new(move |entity, cx| { let entity = entity.downcast_mut().expect("invalid entity type"); @@ -2172,7 +2219,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { view.update(cx, |this, cx| on_release(this, entity, cx)) }); }), - ) + ); + activate(); + subscription } pub fn notify(&mut self) { @@ -2187,10 +2236,12 @@ impl<'a, V: 'static> ViewContext<'a, V> { mut callback: impl FnMut(&mut V, &mut ViewContext) + 'static, ) -> Subscription { let view = self.view.downgrade(); - self.window.bounds_observers.insert( + let (subscription, activate) = self.window.bounds_observers.insert( (), Box::new(move |cx| view.update(cx, |view, cx| callback(view, cx)).is_ok()), - ) + ); + activate(); + subscription } pub fn observe_window_activation( @@ -2198,10 +2249,12 @@ impl<'a, V: 'static> ViewContext<'a, V> { mut callback: impl FnMut(&mut V, &mut ViewContext) + 'static, ) -> Subscription { let view = self.view.downgrade(); - self.window.activation_observers.insert( + let (subscription, activate) = self.window.activation_observers.insert( (), Box::new(move |cx| view.update(cx, |view, cx| callback(view, cx)).is_ok()), - ) + ); + activate(); + subscription } /// Register a listener to be called when the given focus handle receives focus. @@ -2214,7 +2267,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { ) -> Subscription { let view = self.view.downgrade(); let focus_id = handle.id; - self.window.focus_listeners.insert( + let (subscription, activate) = self.window.focus_listeners.insert( (), Box::new(move |event, cx| { view.update(cx, |view, cx| { @@ -2224,7 +2277,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { }) .is_ok() }), - ) + ); + self.app.defer(move |_| activate()); + subscription } /// Register a listener to be called when the given focus handle or one of its descendants receives focus. @@ -2237,7 +2292,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { ) -> Subscription { let view = self.view.downgrade(); let focus_id = handle.id; - self.window.focus_listeners.insert( + let (subscription, activate) = self.window.focus_listeners.insert( (), Box::new(move |event, cx| { view.update(cx, |view, cx| { @@ -2251,7 +2306,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { }) .is_ok() }), - ) + ); + self.app.defer(move |_| activate()); + subscription } /// Register a listener to be called when the given focus handle loses focus. @@ -2264,7 +2321,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { ) -> Subscription { let view = self.view.downgrade(); let focus_id = handle.id; - self.window.focus_listeners.insert( + let (subscription, activate) = self.window.focus_listeners.insert( (), Box::new(move |event, cx| { view.update(cx, |view, cx| { @@ -2274,7 +2331,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { }) .is_ok() }), - ) + ); + self.app.defer(move |_| activate()); + subscription } /// Register a listener to be called when the given focus handle or one of its descendants loses focus. @@ -2287,7 +2346,7 @@ impl<'a, V: 'static> ViewContext<'a, V> { ) -> Subscription { let view = self.view.downgrade(); let focus_id = handle.id; - self.window.focus_listeners.insert( + let (subscription, activate) = self.window.focus_listeners.insert( (), Box::new(move |event, cx| { view.update(cx, |view, cx| { @@ -2301,7 +2360,9 @@ impl<'a, V: 'static> ViewContext<'a, V> { }) .is_ok() }), - ) + ); + self.app.defer(move |_| activate()); + subscription } pub fn spawn( @@ -2332,14 +2393,16 @@ impl<'a, V: 'static> ViewContext<'a, V> { ) -> Subscription { let window_handle = self.window.handle; let view = self.view().downgrade(); - self.global_observers.insert( + let (subscription, activate) = self.global_observers.insert( TypeId::of::(), Box::new(move |cx| { window_handle .update(cx, |_, cx| view.update(cx, |view, cx| f(view, cx)).is_ok()) .unwrap_or(false) }), - ) + ); + self.app.defer(move |_| activate()); + subscription } pub fn on_mouse_event( @@ -2697,6 +2760,7 @@ pub enum ElementId { Integer(usize), Name(SharedString), FocusHandle(FocusId), + NamedInteger(SharedString, usize), } impl ElementId { @@ -2746,3 +2810,9 @@ impl<'a> From<&'a FocusHandle> for ElementId { ElementId::FocusHandle(handle.id) } } + +impl From<(&'static str, EntityId)> for ElementId { + fn from((name, id): (&'static str, EntityId)) -> Self { + ElementId::NamedInteger(name.into(), id.as_u64() as usize) + } +} diff --git a/crates/language2/src/outline.rs b/crates/language2/src/outline.rs index 4bcbdcd27fa7e4290560944e411047097da06425..df1a3c629e75e7695fcf9bd1f6c6a796df2c01f1 100644 --- a/crates/language2/src/outline.rs +++ b/crates/language2/src/outline.rs @@ -81,6 +81,7 @@ impl Outline { let mut prev_item_ix = 0; for mut string_match in matches { let outline_match = &self.items[string_match.candidate_id]; + string_match.string = outline_match.text.clone(); if is_path_query { let prefix_len = self.path_candidate_prefixes[string_match.candidate_id]; diff --git a/crates/language_selector2/Cargo.toml b/crates/language_selector2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..67f0d1e0eec1f8eb9dc943f208c769abe93eb4e4 --- /dev/null +++ b/crates/language_selector2/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "language_selector2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/language_selector.rs" +doctest = false + +[dependencies] +editor = { package = "editor2", path = "../editor2" } +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } +language = { package = "language2", path = "../language2" } +gpui = { package = "gpui2", path = "../gpui2" } +picker = { package = "picker2", path = "../picker2" } +project = { package = "project2", path = "../project2" } +theme = { package = "theme2", path = "../theme2" } +ui = { package = "ui2", path = "../ui2" } +settings = { package = "settings2", path = "../settings2" } +util = { path = "../util" } +workspace = { package = "workspace2", path = "../workspace2" } +anyhow.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/language_selector2/src/active_buffer_language.rs b/crates/language_selector2/src/active_buffer_language.rs new file mode 100644 index 0000000000000000000000000000000000000000..4034cb04297e5ce6ab0db036a776a0edc111ebae --- /dev/null +++ b/crates/language_selector2/src/active_buffer_language.rs @@ -0,0 +1,82 @@ +use editor::Editor; +use gpui::{ + div, Div, IntoElement, ParentElement, Render, Subscription, View, ViewContext, WeakView, +}; +use std::sync::Arc; +use ui::{Button, ButtonCommon, Clickable, Tooltip}; +use workspace::{item::ItemHandle, StatusItemView, Workspace}; + +use crate::LanguageSelector; + +pub struct ActiveBufferLanguage { + active_language: Option>>, + workspace: WeakView, + _observe_active_editor: Option, +} + +impl ActiveBufferLanguage { + pub fn new(workspace: &Workspace) -> Self { + Self { + active_language: None, + workspace: workspace.weak_handle(), + _observe_active_editor: None, + } + } + + fn update_language(&mut self, editor: View, cx: &mut ViewContext) { + self.active_language = Some(None); + + let editor = editor.read(cx); + if let Some((_, buffer, _)) = editor.active_excerpt(cx) { + if let Some(language) = buffer.read(cx).language() { + self.active_language = Some(Some(language.name())); + } + } + + cx.notify(); + } +} + +impl Render for ActiveBufferLanguage { + type Element = Div; + + fn render(&mut self, cx: &mut ViewContext) -> Div { + div().when_some(self.active_language.as_ref(), |el, active_language| { + let active_language_text = if let Some(active_language_text) = active_language { + active_language_text.to_string() + } else { + "Unknown".to_string() + }; + + el.child( + Button::new("change-language", active_language_text) + .on_click(cx.listener(|this, _, cx| { + if let Some(workspace) = this.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + LanguageSelector::toggle(workspace, cx) + }); + } + })) + .tooltip(|cx| Tooltip::text("Select Language", cx)), + ) + }) + } +} + +impl StatusItemView for ActiveBufferLanguage { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn ItemHandle>, + cx: &mut ViewContext, + ) { + if let Some(editor) = active_pane_item.and_then(|item| item.act_as::(cx)) { + self._observe_active_editor = Some(cx.observe(&editor, Self::update_language)); + self.update_language(editor, cx); + } else { + self.active_language = None; + self._observe_active_editor = None; + } + + cx.notify(); + } +} diff --git a/crates/language_selector2/src/language_selector.rs b/crates/language_selector2/src/language_selector.rs new file mode 100644 index 0000000000000000000000000000000000000000..e3970401d4c9201d631e0548ca306f6ca63e7a94 --- /dev/null +++ b/crates/language_selector2/src/language_selector.rs @@ -0,0 +1,232 @@ +mod active_buffer_language; + +pub use active_buffer_language::ActiveBufferLanguage; +use anyhow::anyhow; +use editor::Editor; +use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; +use gpui::{ + actions, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, Model, + ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView, +}; +use language::{Buffer, LanguageRegistry}; +use picker::{Picker, PickerDelegate}; +use project::Project; +use std::sync::Arc; +use ui::{v_stack, HighlightedLabel, ListItem, Selectable}; +use util::ResultExt; +use workspace::Workspace; + +actions!(Toggle); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(LanguageSelector::register).detach(); +} + +pub struct LanguageSelector { + picker: View>, +} + +impl LanguageSelector { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(move |workspace, _: &Toggle, cx| { + Self::toggle(workspace, cx); + }); + } + + fn toggle(workspace: &mut Workspace, cx: &mut ViewContext) -> Option<()> { + let registry = workspace.app_state().languages.clone(); + let (_, buffer, _) = workspace + .active_item(cx)? + .act_as::(cx)? + .read(cx) + .active_excerpt(cx)?; + let project = workspace.project().clone(); + + workspace.toggle_modal(cx, move |cx| { + LanguageSelector::new(buffer, project, registry, cx) + }); + Some(()) + } + + fn new( + buffer: Model, + project: Model, + language_registry: Arc, + cx: &mut ViewContext, + ) -> Self { + let delegate = LanguageSelectorDelegate::new( + cx.view().downgrade(), + buffer, + project, + language_registry, + ); + + let picker = cx.build_view(|cx| Picker::new(delegate, cx)); + Self { picker } + } +} + +impl Render for LanguageSelector { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + v_stack().min_w_96().child(self.picker.clone()) + } +} + +impl FocusableView for LanguageSelector { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for LanguageSelector {} + +pub struct LanguageSelectorDelegate { + language_selector: WeakView, + buffer: Model, + project: Model, + language_registry: Arc, + candidates: Vec, + matches: Vec, + selected_index: usize, +} + +impl LanguageSelectorDelegate { + fn new( + language_selector: WeakView, + buffer: Model, + project: Model, + language_registry: Arc, + ) -> Self { + let candidates = language_registry + .language_names() + .into_iter() + .enumerate() + .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name)) + .collect::>(); + + Self { + language_selector, + buffer, + project, + language_registry, + candidates, + matches: vec![], + selected_index: 0, + } + } +} + +impl PickerDelegate for LanguageSelectorDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self) -> Arc { + "Select a language...".into() + } + + fn match_count(&self) -> usize { + self.matches.len() + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + if let Some(mat) = self.matches.get(self.selected_index) { + let language_name = &self.candidates[mat.candidate_id].string; + let language = self.language_registry.language_for_name(language_name); + let project = self.project.downgrade(); + let buffer = self.buffer.downgrade(); + cx.spawn(|_, mut cx| async move { + let language = language.await?; + let project = project + .upgrade() + .ok_or_else(|| anyhow!("project was dropped"))?; + let buffer = buffer + .upgrade() + .ok_or_else(|| anyhow!("buffer was dropped"))?; + project.update(&mut cx, |project, cx| { + project.set_language_for_buffer(&buffer, language, cx); + }) + }) + .detach_and_log_err(cx); + } + self.dismissed(cx); + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + self.language_selector + .update(cx, |_, cx| cx.emit(DismissEvent)) + .log_err(); + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index(&mut self, ix: usize, _: &mut ViewContext>) { + self.selected_index = ix; + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> gpui::Task<()> { + let background = cx.background_executor().clone(); + let candidates = self.candidates.clone(); + cx.spawn(|this, mut cx| async move { + let matches = if query.is_empty() { + candidates + .into_iter() + .enumerate() + .map(|(index, candidate)| StringMatch { + candidate_id: index, + string: candidate.string, + positions: Vec::new(), + score: 0.0, + }) + .collect() + } else { + match_strings( + &candidates, + &query, + false, + 100, + &Default::default(), + background, + ) + .await + }; + + this.update(&mut cx, |this, cx| { + let delegate = &mut this.delegate; + delegate.matches = matches; + delegate.selected_index = delegate + .selected_index + .min(delegate.matches.len().saturating_sub(1)); + cx.notify(); + }) + .log_err(); + }) + } + + fn render_match( + &self, + ix: usize, + selected: bool, + cx: &mut ViewContext>, + ) -> Option { + let mat = &self.matches[ix]; + let buffer_language_name = self.buffer.read(cx).language().map(|l| l.name()); + let mut label = mat.string.clone(); + if buffer_language_name.as_deref() == Some(mat.string.as_str()) { + label.push_str(" (current)"); + } + + Some( + ListItem::new(ix) + .inset(true) + .selected(selected) + .child(HighlightedLabel::new(label, mat.positions.clone())), + ) + } +} diff --git a/crates/outline2/Cargo.toml b/crates/outline2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7606fc46fe002d993b39a02fd151dee412ea14c7 --- /dev/null +++ b/crates/outline2/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "outline2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/outline.rs" +doctest = false + +[dependencies] +editor = { package = "editor2", path = "../editor2" } +fuzzy = { package = "fuzzy2", path = "../fuzzy2" } +gpui = { package = "gpui2", path = "../gpui2" } +ui = { package = "ui2", path = "../ui2" } +language = { package = "language2", path = "../language2" } +picker = { package = "picker2", path = "../picker2" } +settings = { package = "settings2", path = "../settings2" } +text = { package = "text2", path = "../text2" } +theme = { package = "theme2", path = "../theme2" } +workspace = { package = "workspace2", path = "../workspace2" } +util = { path = "../util" } + +ordered-float.workspace = true +postage.workspace = true +smol.workspace = true + +[dev-dependencies] +editor = { package = "editor2", path = "../editor2", features = ["test-support"] } diff --git a/crates/outline2/src/outline.rs b/crates/outline2/src/outline.rs new file mode 100644 index 0000000000000000000000000000000000000000..8442d6480d4d011cbb745fa8de815118538dbf96 --- /dev/null +++ b/crates/outline2/src/outline.rs @@ -0,0 +1,276 @@ +use editor::{ + display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Anchor, AnchorRangeExt, + DisplayPoint, Editor, ToPoint, +}; +use fuzzy::StringMatch; +use gpui::{ + actions, div, rems, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, + FontWeight, ParentElement, Point, Render, Styled, StyledText, Task, TextStyle, View, + ViewContext, VisualContext, WeakView, WindowContext, +}; +use language::Outline; +use ordered_float::OrderedFloat; +use picker::{Picker, PickerDelegate}; +use std::{ + cmp::{self, Reverse}, + sync::Arc, +}; +use theme::ActiveTheme; +use ui::{v_stack, ListItem, Selectable}; +use util::ResultExt; +use workspace::Workspace; + +actions!(Toggle); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(OutlineView::register).detach(); +} + +pub fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext) { + if let Some(editor) = workspace + .active_item(cx) + .and_then(|item| item.downcast::()) + { + let outline = editor + .read(cx) + .buffer() + .read(cx) + .snapshot(cx) + .outline(Some(&cx.theme().syntax())); + + if let Some(outline) = outline { + workspace.toggle_modal(cx, |cx| OutlineView::new(outline, editor, cx)); + } + } +} + +pub struct OutlineView { + picker: View>, +} + +impl FocusableView for OutlineView { + fn focus_handle(&self, cx: &AppContext) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl EventEmitter for OutlineView {} + +impl Render for OutlineView { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + v_stack().min_w_96().child(self.picker.clone()) + } +} + +impl OutlineView { + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(toggle); + } + + fn new( + outline: Outline, + editor: View, + cx: &mut ViewContext, + ) -> OutlineView { + let delegate = OutlineViewDelegate::new(cx.view().downgrade(), outline, editor, cx); + let picker = cx.build_view(|cx| Picker::new(delegate, cx)); + OutlineView { picker } + } +} + +struct OutlineViewDelegate { + outline_view: WeakView, + active_editor: View, + outline: Outline, + selected_match_index: usize, + prev_scroll_position: Option>, + matches: Vec, + last_query: String, +} + +impl OutlineViewDelegate { + fn new( + outline_view: WeakView, + outline: Outline, + editor: View, + cx: &mut ViewContext, + ) -> Self { + Self { + outline_view, + last_query: Default::default(), + matches: Default::default(), + selected_match_index: 0, + prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))), + active_editor: editor, + outline, + } + } + + fn restore_active_editor(&mut self, cx: &mut WindowContext) { + self.active_editor.update(cx, |editor, cx| { + editor.highlight_rows(None); + if let Some(scroll_position) = self.prev_scroll_position { + editor.set_scroll_position(scroll_position, cx); + } + }) + } + + fn set_selected_index( + &mut self, + ix: usize, + navigate: bool, + cx: &mut ViewContext>, + ) { + self.selected_match_index = ix; + + if navigate && !self.matches.is_empty() { + let selected_match = &self.matches[self.selected_match_index]; + let outline_item = &self.outline.items[selected_match.candidate_id]; + + self.active_editor.update(cx, |active_editor, cx| { + let snapshot = active_editor.snapshot(cx).display_snapshot; + let buffer_snapshot = &snapshot.buffer_snapshot; + let start = outline_item.range.start.to_point(buffer_snapshot); + let end = outline_item.range.end.to_point(buffer_snapshot); + let display_rows = start.to_display_point(&snapshot).row() + ..end.to_display_point(&snapshot).row() + 1; + active_editor.highlight_rows(Some(display_rows)); + active_editor.request_autoscroll(Autoscroll::center(), cx); + }); + } + } +} + +impl PickerDelegate for OutlineViewDelegate { + type ListItem = ListItem; + + fn placeholder_text(&self) -> Arc { + "Search buffer symbols...".into() + } + + 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.set_selected_index(ix, true, cx); + } + + fn update_matches( + &mut self, + query: String, + cx: &mut ViewContext>, + ) -> Task<()> { + let selected_index; + if query.is_empty() { + self.restore_active_editor(cx); + self.matches = self + .outline + .items + .iter() + .enumerate() + .map(|(index, _)| StringMatch { + candidate_id: index, + score: Default::default(), + positions: Default::default(), + string: Default::default(), + }) + .collect(); + + let editor = self.active_editor.read(cx); + let cursor_offset = editor.selections.newest::(cx).head(); + let buffer = editor.buffer().read(cx).snapshot(cx); + selected_index = self + .outline + .items + .iter() + .enumerate() + .map(|(ix, item)| { + let range = item.range.to_offset(&buffer); + let distance_to_closest_endpoint = cmp::min( + (range.start as isize - cursor_offset as isize).abs(), + (range.end as isize - cursor_offset as isize).abs(), + ); + let depth = if range.contains(&cursor_offset) { + Some(item.depth) + } else { + None + }; + (ix, depth, distance_to_closest_endpoint) + }) + .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance))) + .map(|(ix, _, _)| ix) + .unwrap_or(0); + } else { + self.matches = smol::block_on( + self.outline + .search(&query, cx.background_executor().clone()), + ); + selected_index = self + .matches + .iter() + .enumerate() + .max_by_key(|(_, m)| OrderedFloat(m.score)) + .map(|(ix, _)| ix) + .unwrap_or(0); + } + self.last_query = query; + self.set_selected_index(selected_index, !self.last_query.is_empty(), cx); + Task::ready(()) + } + + fn confirm(&mut self, _: bool, cx: &mut ViewContext>) { + self.prev_scroll_position.take(); + + self.active_editor.update(cx, |active_editor, cx| { + if let Some(rows) = active_editor.highlighted_rows() { + let snapshot = active_editor.snapshot(cx).display_snapshot; + let position = DisplayPoint::new(rows.start, 0).to_point(&snapshot); + active_editor.change_selections(Some(Autoscroll::center()), cx, |s| { + s.select_ranges([position..position]) + }); + active_editor.highlight_rows(None); + } + }); + + self.dismissed(cx); + } + + fn dismissed(&mut self, cx: &mut ViewContext>) { + self.outline_view + .update(cx, |_, cx| cx.emit(DismissEvent)) + .log_err(); + self.restore_active_editor(cx); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut ViewContext>, + ) -> Option { + let mat = &self.matches[ix]; + let outline_item = &self.outline.items[mat.candidate_id]; + + let highlights = gpui::combine_highlights( + mat.ranges().map(|range| (range, FontWeight::BOLD.into())), + outline_item.highlight_ranges.iter().cloned(), + ); + + let styled_text = StyledText::new(outline_item.text.clone()) + .with_highlights(&TextStyle::default(), highlights); + + Some( + ListItem::new(ix) + .inset(true) + .selected(selected) + .child(div().pl(rems(outline_item.depth as f32)).child(styled_text)), + ) + } +} diff --git a/crates/picker2/src/picker2.rs b/crates/picker2/src/picker2.rs index 44056dabd16528b7a1aa28ab2e966b05f2ce0b43..89513be8b38b49f973dd7430d16c532405711071 100644 --- a/crates/picker2/src/picker2.rs +++ b/crates/picker2/src/picker2.rs @@ -178,6 +178,15 @@ impl Picker { } cx.notify(); } + + pub fn query(&self, cx: &AppContext) -> String { + self.editor.read(cx).text(cx) + } + + pub fn set_query(&self, query: impl Into>, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.set_text(query, cx)); + } } impl Render for Picker { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 21d64fe91f8509496c7641225863a86dcd2945ce..2e779b71b2c4c2765c2c73745ac6ebd24db44bc9 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -1121,20 +1121,22 @@ impl Project { project_path: impl Into, is_directory: bool, cx: &mut ModelContext, - ) -> Option>> { + ) -> Task>> { let project_path = project_path.into(); - let worktree = self.worktree_for_id(project_path.worktree_id, cx)?; + let Some(worktree) = self.worktree_for_id(project_path.worktree_id, cx) else { + return Task::ready(Ok(None)); + }; if self.is_local() { - Some(worktree.update(cx, |worktree, cx| { + worktree.update(cx, |worktree, cx| { worktree .as_local_mut() .unwrap() .create_entry(project_path.path, is_directory, cx) - })) + }) } else { let client = self.client.clone(); let project_id = self.remote_id().unwrap(); - Some(cx.spawn_weak(|_, mut cx| async move { + cx.spawn_weak(|_, mut cx| async move { let response = client .request(proto::CreateProjectEntry { worktree_id: project_path.worktree_id.to_proto(), @@ -1143,19 +1145,20 @@ impl Project { is_directory, }) .await?; - let entry = response - .entry - .ok_or_else(|| anyhow!("missing entry in response"))?; - worktree - .update(&mut cx, |worktree, cx| { - worktree.as_remote_mut().unwrap().insert_entry( - entry, - response.worktree_scan_id as usize, - cx, - ) - }) - .await - })) + match response.entry { + Some(entry) => worktree + .update(&mut cx, |worktree, cx| { + worktree.as_remote_mut().unwrap().insert_entry( + entry, + response.worktree_scan_id as usize, + cx, + ) + }) + .await + .map(Some), + None => Ok(None), + } + }) } } @@ -1164,8 +1167,10 @@ impl Project { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let worktree = self.worktree_for_entry(entry_id, cx)?; + ) -> Task>> { + let Some(worktree) = self.worktree_for_entry(entry_id, cx) else { + return Task::ready(Ok(None)); + }; let new_path = new_path.into(); if self.is_local() { worktree.update(cx, |worktree, cx| { @@ -1178,7 +1183,7 @@ impl Project { let client = self.client.clone(); let project_id = self.remote_id().unwrap(); - Some(cx.spawn_weak(|_, mut cx| async move { + cx.spawn_weak(|_, mut cx| async move { let response = client .request(proto::CopyProjectEntry { project_id, @@ -1186,19 +1191,20 @@ impl Project { new_path: new_path.to_string_lossy().into(), }) .await?; - let entry = response - .entry - .ok_or_else(|| anyhow!("missing entry in response"))?; - worktree - .update(&mut cx, |worktree, cx| { - worktree.as_remote_mut().unwrap().insert_entry( - entry, - response.worktree_scan_id as usize, - cx, - ) - }) - .await - })) + match response.entry { + Some(entry) => worktree + .update(&mut cx, |worktree, cx| { + worktree.as_remote_mut().unwrap().insert_entry( + entry, + response.worktree_scan_id as usize, + cx, + ) + }) + .await + .map(Some), + None => Ok(None), + } + }) } } @@ -1207,8 +1213,10 @@ impl Project { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let worktree = self.worktree_for_entry(entry_id, cx)?; + ) -> Task>> { + let Some(worktree) = self.worktree_for_entry(entry_id, cx) else { + return Task::ready(Ok(None)); + }; let new_path = new_path.into(); if self.is_local() { worktree.update(cx, |worktree, cx| { @@ -1221,7 +1229,7 @@ impl Project { let client = self.client.clone(); let project_id = self.remote_id().unwrap(); - Some(cx.spawn_weak(|_, mut cx| async move { + cx.spawn_weak(|_, mut cx| async move { let response = client .request(proto::RenameProjectEntry { project_id, @@ -1229,19 +1237,20 @@ impl Project { new_path: new_path.to_string_lossy().into(), }) .await?; - let entry = response - .entry - .ok_or_else(|| anyhow!("missing entry in response"))?; - worktree - .update(&mut cx, |worktree, cx| { - worktree.as_remote_mut().unwrap().insert_entry( - entry, - response.worktree_scan_id as usize, - cx, - ) - }) - .await - })) + match response.entry { + Some(entry) => worktree + .update(&mut cx, |worktree, cx| { + worktree.as_remote_mut().unwrap().insert_entry( + entry, + response.worktree_scan_id as usize, + cx, + ) + }) + .await + .map(Some), + None => Ok(None), + } + }) } } @@ -1658,18 +1667,15 @@ impl Project { pub fn open_path( &mut self, - path: impl Into, + path: ProjectPath, cx: &mut ModelContext, - ) -> Task> { - let task = self.open_buffer(path, cx); + ) -> Task, AnyModelHandle)>> { + let task = self.open_buffer(path.clone(), cx); cx.spawn_weak(|_, cx| async move { let buffer = task.await?; - let project_entry_id = buffer - .read_with(&cx, |buffer, cx| { - File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx)) - }) - .ok_or_else(|| anyhow!("no project entry"))?; - + let project_entry_id = buffer.read_with(&cx, |buffer, cx| { + File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx)) + }); let buffer: &AnyModelHandle = &buffer; Ok((project_entry_id, buffer.clone())) }) @@ -1984,8 +1990,10 @@ impl Project { remote_id, ); - self.local_buffer_ids_by_entry_id - .insert(file.entry_id, remote_id); + if let Some(entry_id) = file.entry_id { + self.local_buffer_ids_by_entry_id + .insert(entry_id, remote_id); + } } } @@ -2440,24 +2448,25 @@ impl Project { return None; }; - match self.local_buffer_ids_by_entry_id.get(&file.entry_id) { - Some(_) => { - return None; - } - None => { - let remote_id = buffer.read(cx).remote_id(); - self.local_buffer_ids_by_entry_id - .insert(file.entry_id, remote_id); - - self.local_buffer_ids_by_path.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - remote_id, - ); + let remote_id = buffer.read(cx).remote_id(); + if let Some(entry_id) = file.entry_id { + match self.local_buffer_ids_by_entry_id.get(&entry_id) { + Some(_) => { + return None; + } + None => { + self.local_buffer_ids_by_entry_id + .insert(entry_id, remote_id); + } } - } + }; + self.local_buffer_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + remote_id, + ); } _ => {} } @@ -5775,11 +5784,6 @@ impl Project { while let Some(ignored_abs_path) = ignored_paths_to_process.pop_front() { - if !query.file_matches(Some(&ignored_abs_path)) - || snapshot.is_path_excluded(&ignored_abs_path) - { - continue; - } if let Some(fs_metadata) = fs .metadata(&ignored_abs_path) .await @@ -5807,6 +5811,13 @@ impl Project { } } } else if !fs_metadata.is_symlink { + if !query.file_matches(Some(&ignored_abs_path)) + || snapshot.is_path_excluded( + ignored_entry.path.to_path_buf(), + ) + { + continue; + } let matches = if let Some(file) = fs .open_sync(&ignored_abs_path) .await @@ -6207,10 +6218,13 @@ impl Project { return; } - let new_file = if let Some(entry) = snapshot.entry_for_id(old_file.entry_id) { + let new_file = if let Some(entry) = old_file + .entry_id + .and_then(|entry_id| snapshot.entry_for_id(entry_id)) + { File { is_local: true, - entry_id: entry.id, + entry_id: Some(entry.id), mtime: entry.mtime, path: entry.path.clone(), worktree: worktree_handle.clone(), @@ -6219,7 +6233,7 @@ impl Project { } else if let Some(entry) = snapshot.entry_for_path(old_file.path().as_ref()) { File { is_local: true, - entry_id: entry.id, + entry_id: Some(entry.id), mtime: entry.mtime, path: entry.path.clone(), worktree: worktree_handle.clone(), @@ -6249,10 +6263,12 @@ impl Project { ); } - if new_file.entry_id != *entry_id { + if new_file.entry_id != Some(*entry_id) { self.local_buffer_ids_by_entry_id.remove(entry_id); - self.local_buffer_ids_by_entry_id - .insert(new_file.entry_id, buffer_id); + if let Some(entry_id) = new_file.entry_id { + self.local_buffer_ids_by_entry_id + .insert(entry_id, buffer_id); + } } if new_file != *old_file { @@ -6815,7 +6831,7 @@ impl Project { }) .await?; Ok(proto::ProjectEntryResponse { - entry: Some((&entry).into()), + entry: entry.as_ref().map(|e| e.into()), worktree_scan_id: worktree_scan_id as u64, }) } @@ -6839,11 +6855,10 @@ impl Project { .as_local_mut() .unwrap() .rename_entry(entry_id, new_path, cx) - .ok_or_else(|| anyhow!("invalid entry")) - })? + }) .await?; Ok(proto::ProjectEntryResponse { - entry: Some((&entry).into()), + entry: entry.as_ref().map(|e| e.into()), worktree_scan_id: worktree_scan_id as u64, }) } @@ -6867,11 +6882,10 @@ impl Project { .as_local_mut() .unwrap() .copy_entry(entry_id, new_path, cx) - .ok_or_else(|| anyhow!("invalid entry")) - })? + }) .await?; Ok(proto::ProjectEntryResponse { - entry: Some((&entry).into()), + entry: entry.as_ref().map(|e| e.into()), worktree_scan_id: worktree_scan_id as u64, }) } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 5d061b868fb37dd730d09ea184fd3f7a91d447be..4fe6e1699b763cca151a7d8bc2e6a7b901c12c6b 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -4050,6 +4050,94 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex ); } +#[gpui::test] +async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background()); + fs.insert_tree( + "/dir", + json!({ + ".git": {}, + ".gitignore": "**/target\n/node_modules\n", + "target": { + "index.txt": "index_key:index_value" + }, + "node_modules": { + "eslint": { + "index.ts": "const eslint_key = 'eslint value'", + "package.json": r#"{ "some_key": "some value" }"#, + }, + "prettier": { + "index.ts": "const prettier_key = 'prettier value'", + "package.json": r#"{ "other_key": "other value" }"#, + }, + }, + "package.json": r#"{ "main_key": "main value" }"#, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + let query = "key"; + assert_eq!( + search( + &project, + SearchQuery::text(query, false, false, false, Vec::new(), Vec::new()).unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([("package.json".to_string(), vec![8..11])]), + "Only one non-ignored file should have the query" + ); + + assert_eq!( + search( + &project, + SearchQuery::text(query, false, false, true, Vec::new(), Vec::new()).unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("package.json".to_string(), vec![8..11]), + ("target/index.txt".to_string(), vec![6..9]), + ( + "node_modules/prettier/package.json".to_string(), + vec![9..12] + ), + ("node_modules/prettier/index.ts".to_string(), vec![15..18]), + ("node_modules/eslint/index.ts".to_string(), vec![13..16]), + ("node_modules/eslint/package.json".to_string(), vec![8..11]), + ]), + "Unrestricted search with ignored directories should find every file with the query" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + query, + false, + false, + true, + vec![PathMatcher::new("node_modules/prettier/**").unwrap()], + vec![PathMatcher::new("*.ts").unwrap()], + ) + .unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([( + "node_modules/prettier/package.json".to_string(), + vec![9..12] + )]), + "With search including ignored prettier directory and excluding TS files, only one file should be found" + ); +} + #[test] fn test_glob_literal_prefix() { assert_eq!(glob_literal_prefix("**/*.js"), ""); diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index c673440326e82630bd34c8117665b3f3cc092b69..bfbc537b27e92821a02e401ccf05a7cd013fb2b7 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -371,15 +371,25 @@ impl SearchQuery { pub fn file_matches(&self, file_path: Option<&Path>) -> bool { match file_path { Some(file_path) => { - !self - .files_to_exclude() - .iter() - .any(|exclude_glob| exclude_glob.is_match(file_path)) - && (self.files_to_include().is_empty() + let mut path = file_path.to_path_buf(); + loop { + if self + .files_to_exclude() + .iter() + .any(|exclude_glob| exclude_glob.is_match(&path)) + { + return false; + } else if self.files_to_include().is_empty() || self .files_to_include() .iter() - .any(|include_glob| include_glob.is_match(file_path))) + .any(|include_glob| include_glob.is_match(&path)) + { + return true; + } else if !path.pop() { + return false; + } + } } None => self.files_to_include().is_empty(), } diff --git a/crates/project/src/worktree.rs b/crates/project/src/worktree.rs index d5a046ba0dc4303c0acc95219c4846fb34136cec..c721d127add1344ee22df0a4224413c60c3fc9b2 100644 --- a/crates/project/src/worktree.rs +++ b/crates/project/src/worktree.rs @@ -960,8 +960,6 @@ impl LocalWorktree { cx.spawn(|this, cx| async move { let text = fs.load(&abs_path).await?; - let entry = entry.await?; - let mut index_task = None; let snapshot = this.read_with(&cx, |this, _| this.as_local().unwrap().snapshot()); if let Some(repo) = snapshot.repository_for_path(&path) { @@ -981,18 +979,43 @@ impl LocalWorktree { None }; - Ok(( - File { - entry_id: entry.id, - worktree: this, - path: entry.path, - mtime: entry.mtime, - is_local: true, - is_deleted: false, - }, - text, - diff_base, - )) + match entry.await? { + Some(entry) => Ok(( + File { + entry_id: Some(entry.id), + worktree: this, + path: entry.path, + mtime: entry.mtime, + is_local: true, + is_deleted: false, + }, + text, + diff_base, + )), + None => { + let metadata = fs + .metadata(&abs_path) + .await + .with_context(|| { + format!("Loading metadata for excluded file {abs_path:?}") + })? + .with_context(|| { + format!("Excluded file {abs_path:?} got removed during loading") + })?; + Ok(( + File { + entry_id: None, + worktree: this, + path, + mtime: metadata.mtime, + is_local: true, + is_deleted: false, + }, + text, + diff_base, + )) + } + } }) } @@ -1013,17 +1036,37 @@ impl LocalWorktree { let text = buffer.as_rope().clone(); let fingerprint = text.fingerprint(); let version = buffer.version(); - let save = self.write_file(path, text, buffer.line_ending(), cx); + let save = self.write_file(path.as_ref(), text, buffer.line_ending(), cx); + let fs = Arc::clone(&self.fs); + let abs_path = self.absolutize(&path); cx.as_mut().spawn(|mut cx| async move { let entry = save.await?; + let (entry_id, mtime, path) = match entry { + Some(entry) => (Some(entry.id), entry.mtime, entry.path), + None => { + let metadata = fs + .metadata(&abs_path) + .await + .with_context(|| { + format!( + "Fetching metadata after saving the excluded buffer {abs_path:?}" + ) + })? + .with_context(|| { + format!("Excluded buffer {path:?} got removed during saving") + })?; + (None, metadata.mtime, path) + } + }; + if has_changed_file { let new_file = Arc::new(File { - entry_id: entry.id, + entry_id, worktree: handle, - path: entry.path, - mtime: entry.mtime, + path, + mtime, is_local: true, is_deleted: false, }); @@ -1049,13 +1092,13 @@ impl LocalWorktree { project_id, buffer_id, version: serialize_version(&version), - mtime: Some(entry.mtime.into()), + mtime: Some(mtime.into()), fingerprint: serialize_fingerprint(fingerprint), })?; } buffer_handle.update(&mut cx, |buffer, cx| { - buffer.did_save(version.clone(), fingerprint, entry.mtime, cx); + buffer.did_save(version.clone(), fingerprint, mtime, cx); }); Ok(()) @@ -1080,7 +1123,7 @@ impl LocalWorktree { path: impl Into>, is_dir: bool, cx: &mut ModelContext, - ) -> Task> { + ) -> Task>> { let path = path.into(); let lowest_ancestor = self.lowest_ancestor(&path); let abs_path = self.absolutize(&path); @@ -1097,7 +1140,7 @@ impl LocalWorktree { cx.spawn(|this, mut cx| async move { write.await?; let (result, refreshes) = this.update(&mut cx, |this, cx| { - let mut refreshes = Vec::>>::new(); + let mut refreshes = Vec::new(); let refresh_paths = path.strip_prefix(&lowest_ancestor).unwrap(); for refresh_path in refresh_paths.ancestors() { if refresh_path == Path::new("") { @@ -1124,14 +1167,14 @@ impl LocalWorktree { }) } - pub fn write_file( + pub(crate) fn write_file( &self, path: impl Into>, text: Rope, line_ending: LineEnding, cx: &mut ModelContext, - ) -> Task> { - let path = path.into(); + ) -> Task>> { + let path: Arc = path.into(); let abs_path = self.absolutize(&path); let fs = self.fs.clone(); let write = cx @@ -1190,8 +1233,11 @@ impl LocalWorktree { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let old_path = self.entry_for_id(entry_id)?.path.clone(); + ) -> Task>> { + let old_path = match self.entry_for_id(entry_id) { + Some(entry) => entry.path.clone(), + None => return Task::ready(Ok(None)), + }; let new_path = new_path.into(); let abs_old_path = self.absolutize(&old_path); let abs_new_path = self.absolutize(&new_path); @@ -1201,7 +1247,7 @@ impl LocalWorktree { .await }); - Some(cx.spawn(|this, mut cx| async move { + cx.spawn(|this, mut cx| async move { rename.await?; this.update(&mut cx, |this, cx| { this.as_local_mut() @@ -1209,7 +1255,7 @@ impl LocalWorktree { .refresh_entry(new_path.clone(), Some(old_path), cx) }) .await - })) + }) } pub fn copy_entry( @@ -1217,8 +1263,11 @@ impl LocalWorktree { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let old_path = self.entry_for_id(entry_id)?.path.clone(); + ) -> Task>> { + let old_path = match self.entry_for_id(entry_id) { + Some(entry) => entry.path.clone(), + None => return Task::ready(Ok(None)), + }; let new_path = new_path.into(); let abs_old_path = self.absolutize(&old_path); let abs_new_path = self.absolutize(&new_path); @@ -1233,7 +1282,7 @@ impl LocalWorktree { .await }); - Some(cx.spawn(|this, mut cx| async move { + cx.spawn(|this, mut cx| async move { copy.await?; this.update(&mut cx, |this, cx| { this.as_local_mut() @@ -1241,7 +1290,7 @@ impl LocalWorktree { .refresh_entry(new_path.clone(), None, cx) }) .await - })) + }) } pub fn expand_entry( @@ -1277,7 +1326,10 @@ impl LocalWorktree { path: Arc, old_path: Option>, cx: &mut ModelContext, - ) -> Task> { + ) -> Task>> { + if self.is_path_excluded(path.to_path_buf()) { + return Task::ready(Ok(None)); + } let paths = if let Some(old_path) = old_path.as_ref() { vec![old_path.clone(), path.clone()] } else { @@ -1286,13 +1338,15 @@ impl LocalWorktree { let mut refresh = self.refresh_entries_for_paths(paths); cx.spawn_weak(move |this, mut cx| async move { refresh.recv().await; - this.upgrade(&cx) + let new_entry = this + .upgrade(&cx) .ok_or_else(|| anyhow!("worktree was dropped"))? .update(&mut cx, |this, _| { this.entry_for_path(path) .cloned() .ok_or_else(|| anyhow!("failed to read path after update")) - }) + })?; + Ok(Some(new_entry)) }) } @@ -2226,10 +2280,19 @@ impl LocalSnapshot { paths } - pub fn is_path_excluded(&self, abs_path: &Path) -> bool { - self.file_scan_exclusions - .iter() - .any(|exclude_matcher| exclude_matcher.is_match(abs_path)) + pub fn is_path_excluded(&self, mut path: PathBuf) -> bool { + loop { + if self + .file_scan_exclusions + .iter() + .any(|exclude_matcher| exclude_matcher.is_match(&path)) + { + return true; + } + if !path.pop() { + return false; + } + } } } @@ -2458,8 +2521,7 @@ impl BackgroundScannerState { ids_to_preserve.insert(work_directory_id); } else { let git_dir_abs_path = snapshot.abs_path().join(&entry.git_dir_path); - let git_dir_excluded = snapshot.is_path_excluded(&entry.git_dir_path) - || snapshot.is_path_excluded(&git_dir_abs_path); + let git_dir_excluded = snapshot.is_path_excluded(entry.git_dir_path.to_path_buf()); if git_dir_excluded && !matches!(smol::block_on(fs.metadata(&git_dir_abs_path)), Ok(None)) { @@ -2666,7 +2728,7 @@ pub struct File { pub worktree: ModelHandle, pub path: Arc, pub mtime: SystemTime, - pub(crate) entry_id: ProjectEntryId, + pub(crate) entry_id: Option, pub(crate) is_local: bool, pub(crate) is_deleted: bool, } @@ -2735,7 +2797,7 @@ impl language::File for File { fn to_proto(&self) -> rpc::proto::File { rpc::proto::File { worktree_id: self.worktree.id() as u64, - entry_id: self.entry_id.to_proto(), + entry_id: self.entry_id.map(|id| id.to_proto()), path: self.path.to_string_lossy().into(), mtime: Some(self.mtime.into()), is_deleted: self.is_deleted, @@ -2793,7 +2855,7 @@ impl File { worktree, path: entry.path.clone(), mtime: entry.mtime, - entry_id: entry.id, + entry_id: Some(entry.id), is_local: true, is_deleted: false, }) @@ -2818,7 +2880,7 @@ impl File { worktree, path: Path::new(&proto.path).into(), mtime: proto.mtime.ok_or_else(|| anyhow!("no timestamp"))?.into(), - entry_id: ProjectEntryId::from_proto(proto.entry_id), + entry_id: proto.entry_id.map(ProjectEntryId::from_proto), is_local: false, is_deleted: proto.is_deleted, }) @@ -2836,7 +2898,7 @@ impl File { if self.is_deleted { None } else { - Some(self.entry_id) + self.entry_id } } } @@ -3338,16 +3400,7 @@ impl BackgroundScanner { return false; } - // FS events may come for files which parent directory is excluded, need to check ignore those. - let mut path_to_test = abs_path.clone(); - let mut excluded_file_event = snapshot.is_path_excluded(abs_path) - || snapshot.is_path_excluded(&relative_path); - while !excluded_file_event && path_to_test.pop() { - if snapshot.is_path_excluded(&path_to_test) { - excluded_file_event = true; - } - } - if excluded_file_event { + if snapshot.is_path_excluded(relative_path.to_path_buf()) { if !is_git_related { log::debug!("ignoring FS event for excluded path {relative_path:?}"); } @@ -3531,7 +3584,7 @@ impl BackgroundScanner { let state = self.state.lock(); let snapshot = &state.snapshot; root_abs_path = snapshot.abs_path().clone(); - if snapshot.is_path_excluded(&job.abs_path) { + if snapshot.is_path_excluded(job.path.to_path_buf()) { log::error!("skipping excluded directory {:?}", job.path); return Ok(()); } @@ -3603,8 +3656,8 @@ impl BackgroundScanner { { let mut state = self.state.lock(); - if state.snapshot.is_path_excluded(&child_abs_path) { - let relative_path = job.path.join(child_name); + let relative_path = job.path.join(child_name); + if state.snapshot.is_path_excluded(relative_path.clone()) { log::debug!("skipping excluded child entry {relative_path:?}"); state.remove_path(&relative_path); continue; diff --git a/crates/project/src/worktree_tests.rs b/crates/project/src/worktree_tests.rs index b4cf162d8f1e61dbf317b33917428ce79468bdea..e8865873277ecab09e0414529c299559f2bce3c1 100644 --- a/crates/project/src/worktree_tests.rs +++ b/crates/project/src/worktree_tests.rs @@ -1052,11 +1052,12 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { &[ ".git/HEAD", ".git/foo", + "node_modules", "node_modules/.DS_Store", "node_modules/prettier", "node_modules/prettier/package.json", ], - &["target", "node_modules"], + &["target"], &[ ".DS_Store", "src/.DS_Store", @@ -1106,6 +1107,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { ".git/HEAD", ".git/foo", ".git/new_file", + "node_modules", "node_modules/.DS_Store", "node_modules/prettier", "node_modules/prettier/package.json", @@ -1114,7 +1116,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { "build_output/new_file", "test_output/new_file", ], - &["target", "node_modules", "test_output"], + &["target", "test_output"], &[ ".DS_Store", "src/.DS_Store", @@ -1174,6 +1176,7 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { .create_entry("a/e".as_ref(), true, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_dir()); @@ -1222,6 +1225,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("a/b/c/d.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1257,6 +1261,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("a/b/c/d.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1275,6 +1280,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("a/b/c/e.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1291,6 +1297,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("d/e/f/g.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1616,14 +1623,14 @@ fn randomly_mutate_worktree( entry.id.0, new_path ); - let task = worktree.rename_entry(entry.id, new_path, cx).unwrap(); + let task = worktree.rename_entry(entry.id, new_path, cx); cx.foreground().spawn(async move { - task.await?; + task.await?.unwrap(); Ok(()) }) } _ => { - let task = if entry.is_dir() { + if entry.is_dir() { let child_path = entry.path.join(random_filename(rng)); let is_dir = rng.gen_bool(0.3); log::info!( @@ -1631,15 +1638,20 @@ fn randomly_mutate_worktree( if is_dir { "dir" } else { "file" }, child_path, ); - worktree.create_entry(child_path, is_dir, cx) + let task = worktree.create_entry(child_path, is_dir, cx); + cx.foreground().spawn(async move { + task.await?; + Ok(()) + }) } else { log::info!("overwriting file {:?} ({})", entry.path, entry.id.0); - worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx) - }; - cx.foreground().spawn(async move { - task.await?; - Ok(()) - }) + let task = + worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx); + cx.foreground().spawn(async move { + task.await?; + Ok(()) + }) + } } } } diff --git a/crates/project2/src/project2.rs b/crates/project2/src/project2.rs index 12940dd2c427f8872fd95c9a1c66a5535738463e..243f896b0f14035e399f382b4b68e2f16ffd7438 100644 --- a/crates/project2/src/project2.rs +++ b/crates/project2/src/project2.rs @@ -1151,20 +1151,22 @@ impl Project { project_path: impl Into, is_directory: bool, cx: &mut ModelContext, - ) -> Option>> { + ) -> Task>> { let project_path = project_path.into(); - let worktree = self.worktree_for_id(project_path.worktree_id, cx)?; + let Some(worktree) = self.worktree_for_id(project_path.worktree_id, cx) else { + return Task::ready(Ok(None)); + }; if self.is_local() { - Some(worktree.update(cx, |worktree, cx| { + worktree.update(cx, |worktree, cx| { worktree .as_local_mut() .unwrap() .create_entry(project_path.path, is_directory, cx) - })) + }) } else { let client = self.client.clone(); let project_id = self.remote_id().unwrap(); - Some(cx.spawn(move |_, mut cx| async move { + cx.spawn(move |_, mut cx| async move { let response = client .request(proto::CreateProjectEntry { worktree_id: project_path.worktree_id.to_proto(), @@ -1173,19 +1175,20 @@ impl Project { is_directory, }) .await?; - let entry = response - .entry - .ok_or_else(|| anyhow!("missing entry in response"))?; - worktree - .update(&mut cx, |worktree, cx| { - worktree.as_remote_mut().unwrap().insert_entry( - entry, - response.worktree_scan_id as usize, - cx, - ) - })? - .await - })) + match response.entry { + Some(entry) => worktree + .update(&mut cx, |worktree, cx| { + worktree.as_remote_mut().unwrap().insert_entry( + entry, + response.worktree_scan_id as usize, + cx, + ) + })? + .await + .map(Some), + None => Ok(None), + } + }) } } @@ -1194,8 +1197,10 @@ impl Project { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let worktree = self.worktree_for_entry(entry_id, cx)?; + ) -> Task>> { + let Some(worktree) = self.worktree_for_entry(entry_id, cx) else { + return Task::ready(Ok(None)); + }; let new_path = new_path.into(); if self.is_local() { worktree.update(cx, |worktree, cx| { @@ -1208,7 +1213,7 @@ impl Project { let client = self.client.clone(); let project_id = self.remote_id().unwrap(); - Some(cx.spawn(move |_, mut cx| async move { + cx.spawn(move |_, mut cx| async move { let response = client .request(proto::CopyProjectEntry { project_id, @@ -1216,19 +1221,20 @@ impl Project { new_path: new_path.to_string_lossy().into(), }) .await?; - let entry = response - .entry - .ok_or_else(|| anyhow!("missing entry in response"))?; - worktree - .update(&mut cx, |worktree, cx| { - worktree.as_remote_mut().unwrap().insert_entry( - entry, - response.worktree_scan_id as usize, - cx, - ) - })? - .await - })) + match response.entry { + Some(entry) => worktree + .update(&mut cx, |worktree, cx| { + worktree.as_remote_mut().unwrap().insert_entry( + entry, + response.worktree_scan_id as usize, + cx, + ) + })? + .await + .map(Some), + None => Ok(None), + } + }) } } @@ -1237,8 +1243,10 @@ impl Project { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let worktree = self.worktree_for_entry(entry_id, cx)?; + ) -> Task>> { + let Some(worktree) = self.worktree_for_entry(entry_id, cx) else { + return Task::ready(Ok(None)); + }; let new_path = new_path.into(); if self.is_local() { worktree.update(cx, |worktree, cx| { @@ -1251,7 +1259,7 @@ impl Project { let client = self.client.clone(); let project_id = self.remote_id().unwrap(); - Some(cx.spawn(move |_, mut cx| async move { + cx.spawn(move |_, mut cx| async move { let response = client .request(proto::RenameProjectEntry { project_id, @@ -1259,19 +1267,20 @@ impl Project { new_path: new_path.to_string_lossy().into(), }) .await?; - let entry = response - .entry - .ok_or_else(|| anyhow!("missing entry in response"))?; - worktree - .update(&mut cx, |worktree, cx| { - worktree.as_remote_mut().unwrap().insert_entry( - entry, - response.worktree_scan_id as usize, - cx, - ) - })? - .await - })) + match response.entry { + Some(entry) => worktree + .update(&mut cx, |worktree, cx| { + worktree.as_remote_mut().unwrap().insert_entry( + entry, + response.worktree_scan_id as usize, + cx, + ) + })? + .await + .map(Some), + None => Ok(None), + } + }) } } @@ -1688,17 +1697,15 @@ impl Project { pub fn open_path( &mut self, - path: impl Into, + path: ProjectPath, cx: &mut ModelContext, - ) -> Task> { - let task = self.open_buffer(path, cx); - cx.spawn(move |_, mut cx| async move { + ) -> Task, AnyModel)>> { + let task = self.open_buffer(path.clone(), cx); + cx.spawn(move |_, cx| async move { let buffer = task.await?; - let project_entry_id = buffer - .update(&mut cx, |buffer, cx| { - File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx)) - })? - .ok_or_else(|| anyhow!("no project entry"))?; + let project_entry_id = buffer.read_with(&cx, |buffer, cx| { + File::from_dyn(buffer.file()).and_then(|file| file.project_entry_id(cx)) + })?; let buffer: &AnyModel = &buffer; Ok((project_entry_id, buffer.clone())) @@ -2017,8 +2024,10 @@ impl Project { remote_id, ); - self.local_buffer_ids_by_entry_id - .insert(file.entry_id, remote_id); + if let Some(entry_id) = file.entry_id { + self.local_buffer_ids_by_entry_id + .insert(entry_id, remote_id); + } } } @@ -2473,24 +2482,25 @@ impl Project { return None; }; - match self.local_buffer_ids_by_entry_id.get(&file.entry_id) { - Some(_) => { - return None; - } - None => { - let remote_id = buffer.read(cx).remote_id(); - self.local_buffer_ids_by_entry_id - .insert(file.entry_id, remote_id); - - self.local_buffer_ids_by_path.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - remote_id, - ); + let remote_id = buffer.read(cx).remote_id(); + if let Some(entry_id) = file.entry_id { + match self.local_buffer_ids_by_entry_id.get(&entry_id) { + Some(_) => { + return None; + } + None => { + self.local_buffer_ids_by_entry_id + .insert(entry_id, remote_id); + } } - } + }; + self.local_buffer_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + remote_id, + ); } _ => {} } @@ -5844,11 +5854,6 @@ impl Project { while let Some(ignored_abs_path) = ignored_paths_to_process.pop_front() { - if !query.file_matches(Some(&ignored_abs_path)) - || snapshot.is_path_excluded(&ignored_abs_path) - { - continue; - } if let Some(fs_metadata) = fs .metadata(&ignored_abs_path) .await @@ -5876,6 +5881,13 @@ impl Project { } } } else if !fs_metadata.is_symlink { + if !query.file_matches(Some(&ignored_abs_path)) + || snapshot.is_path_excluded( + ignored_entry.path.to_path_buf(), + ) + { + continue; + } let matches = if let Some(file) = fs .open_sync(&ignored_abs_path) .await @@ -6277,10 +6289,13 @@ impl Project { return; } - let new_file = if let Some(entry) = snapshot.entry_for_id(old_file.entry_id) { + let new_file = if let Some(entry) = old_file + .entry_id + .and_then(|entry_id| snapshot.entry_for_id(entry_id)) + { File { is_local: true, - entry_id: entry.id, + entry_id: Some(entry.id), mtime: entry.mtime, path: entry.path.clone(), worktree: worktree_handle.clone(), @@ -6289,7 +6304,7 @@ impl Project { } else if let Some(entry) = snapshot.entry_for_path(old_file.path().as_ref()) { File { is_local: true, - entry_id: entry.id, + entry_id: Some(entry.id), mtime: entry.mtime, path: entry.path.clone(), worktree: worktree_handle.clone(), @@ -6319,10 +6334,12 @@ impl Project { ); } - if new_file.entry_id != *entry_id { + if new_file.entry_id != Some(*entry_id) { self.local_buffer_ids_by_entry_id.remove(entry_id); - self.local_buffer_ids_by_entry_id - .insert(new_file.entry_id, buffer_id); + if let Some(entry_id) = new_file.entry_id { + self.local_buffer_ids_by_entry_id + .insert(entry_id, buffer_id); + } } if new_file != *old_file { @@ -6889,7 +6906,7 @@ impl Project { })? .await?; Ok(proto::ProjectEntryResponse { - entry: Some((&entry).into()), + entry: entry.as_ref().map(|e| e.into()), worktree_scan_id: worktree_scan_id as u64, }) } @@ -6913,11 +6930,10 @@ impl Project { .as_local_mut() .unwrap() .rename_entry(entry_id, new_path, cx) - .ok_or_else(|| anyhow!("invalid entry")) - })?? + })? .await?; Ok(proto::ProjectEntryResponse { - entry: Some((&entry).into()), + entry: entry.as_ref().map(|e| e.into()), worktree_scan_id: worktree_scan_id as u64, }) } @@ -6941,11 +6957,10 @@ impl Project { .as_local_mut() .unwrap() .copy_entry(entry_id, new_path, cx) - .ok_or_else(|| anyhow!("invalid entry")) - })?? + })? .await?; Ok(proto::ProjectEntryResponse { - entry: Some((&entry).into()), + entry: entry.as_ref().map(|e| e.into()), worktree_scan_id: worktree_scan_id as u64, }) } diff --git a/crates/project2/src/project_tests.rs b/crates/project2/src/project_tests.rs index 4dfb8004e3e644309b89ee31d99f5ac07e05f4b3..8f41c75fb4de0415089c1ce66c30cb93278c079c 100644 --- a/crates/project2/src/project_tests.rs +++ b/crates/project2/src/project_tests.rs @@ -4182,6 +4182,94 @@ async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContex ); } +#[gpui::test] +async fn test_search_in_gitignored_dirs(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/dir", + json!({ + ".git": {}, + ".gitignore": "**/target\n/node_modules\n", + "target": { + "index.txt": "index_key:index_value" + }, + "node_modules": { + "eslint": { + "index.ts": "const eslint_key = 'eslint value'", + "package.json": r#"{ "some_key": "some value" }"#, + }, + "prettier": { + "index.ts": "const prettier_key = 'prettier value'", + "package.json": r#"{ "other_key": "other value" }"#, + }, + }, + "package.json": r#"{ "main_key": "main value" }"#, + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + let query = "key"; + assert_eq!( + search( + &project, + SearchQuery::text(query, false, false, false, Vec::new(), Vec::new()).unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([("package.json".to_string(), vec![8..11])]), + "Only one non-ignored file should have the query" + ); + + assert_eq!( + search( + &project, + SearchQuery::text(query, false, false, true, Vec::new(), Vec::new()).unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([ + ("package.json".to_string(), vec![8..11]), + ("target/index.txt".to_string(), vec![6..9]), + ( + "node_modules/prettier/package.json".to_string(), + vec![9..12] + ), + ("node_modules/prettier/index.ts".to_string(), vec![15..18]), + ("node_modules/eslint/index.ts".to_string(), vec![13..16]), + ("node_modules/eslint/package.json".to_string(), vec![8..11]), + ]), + "Unrestricted search with ignored directories should find every file with the query" + ); + + assert_eq!( + search( + &project, + SearchQuery::text( + query, + false, + false, + true, + vec![PathMatcher::new("node_modules/prettier/**").unwrap()], + vec![PathMatcher::new("*.ts").unwrap()], + ) + .unwrap(), + cx + ) + .await + .unwrap(), + HashMap::from_iter([( + "node_modules/prettier/package.json".to_string(), + vec![9..12] + )]), + "With search including ignored prettier directory and excluding TS files, only one file should be found" + ); +} + #[test] fn test_glob_literal_prefix() { assert_eq!(glob_literal_prefix("**/*.js"), ""); diff --git a/crates/project2/src/search.rs b/crates/project2/src/search.rs index c673440326e82630bd34c8117665b3f3cc092b69..bfbc537b27e92821a02e401ccf05a7cd013fb2b7 100644 --- a/crates/project2/src/search.rs +++ b/crates/project2/src/search.rs @@ -371,15 +371,25 @@ impl SearchQuery { pub fn file_matches(&self, file_path: Option<&Path>) -> bool { match file_path { Some(file_path) => { - !self - .files_to_exclude() - .iter() - .any(|exclude_glob| exclude_glob.is_match(file_path)) - && (self.files_to_include().is_empty() + let mut path = file_path.to_path_buf(); + loop { + if self + .files_to_exclude() + .iter() + .any(|exclude_glob| exclude_glob.is_match(&path)) + { + return false; + } else if self.files_to_include().is_empty() || self .files_to_include() .iter() - .any(|include_glob| include_glob.is_match(file_path))) + .any(|include_glob| include_glob.is_match(&path)) + { + return true; + } else if !path.pop() { + return false; + } + } } None => self.files_to_include().is_empty(), } diff --git a/crates/project2/src/worktree.rs b/crates/project2/src/worktree.rs index e424375220c1c7aa2292d9a4762d7581ea67222e..a5cb322cb5f52beb71c1b4200f58f098c2f6d88a 100644 --- a/crates/project2/src/worktree.rs +++ b/crates/project2/src/worktree.rs @@ -958,8 +958,6 @@ impl LocalWorktree { cx.spawn(|this, mut cx| async move { let text = fs.load(&abs_path).await?; - let entry = entry.await?; - let mut index_task = None; let snapshot = this.update(&mut cx, |this, _| this.as_local().unwrap().snapshot())?; if let Some(repo) = snapshot.repository_for_path(&path) { @@ -982,18 +980,43 @@ impl LocalWorktree { let worktree = this .upgrade() .ok_or_else(|| anyhow!("worktree was dropped"))?; - Ok(( - File { - entry_id: entry.id, - worktree, - path: entry.path, - mtime: entry.mtime, - is_local: true, - is_deleted: false, - }, - text, - diff_base, - )) + match entry.await? { + Some(entry) => Ok(( + File { + entry_id: Some(entry.id), + worktree, + path: entry.path, + mtime: entry.mtime, + is_local: true, + is_deleted: false, + }, + text, + diff_base, + )), + None => { + let metadata = fs + .metadata(&abs_path) + .await + .with_context(|| { + format!("Loading metadata for excluded file {abs_path:?}") + })? + .with_context(|| { + format!("Excluded file {abs_path:?} got removed during loading") + })?; + Ok(( + File { + entry_id: None, + worktree, + path, + mtime: metadata.mtime, + is_local: true, + is_deleted: false, + }, + text, + diff_base, + )) + } + } }) } @@ -1013,18 +1036,38 @@ impl LocalWorktree { let text = buffer.as_rope().clone(); let fingerprint = text.fingerprint(); let version = buffer.version(); - let save = self.write_file(path, text, buffer.line_ending(), cx); + let save = self.write_file(path.as_ref(), text, buffer.line_ending(), cx); + let fs = Arc::clone(&self.fs); + let abs_path = self.absolutize(&path); cx.spawn(move |this, mut cx| async move { let entry = save.await?; let this = this.upgrade().context("worktree dropped")?; + let (entry_id, mtime, path) = match entry { + Some(entry) => (Some(entry.id), entry.mtime, entry.path), + None => { + let metadata = fs + .metadata(&abs_path) + .await + .with_context(|| { + format!( + "Fetching metadata after saving the excluded buffer {abs_path:?}" + ) + })? + .with_context(|| { + format!("Excluded buffer {path:?} got removed during saving") + })?; + (None, metadata.mtime, path) + } + }; + if has_changed_file { let new_file = Arc::new(File { - entry_id: entry.id, + entry_id, worktree: this, - path: entry.path, - mtime: entry.mtime, + path, + mtime, is_local: true, is_deleted: false, }); @@ -1050,13 +1093,13 @@ impl LocalWorktree { project_id, buffer_id, version: serialize_version(&version), - mtime: Some(entry.mtime.into()), + mtime: Some(mtime.into()), fingerprint: serialize_fingerprint(fingerprint), })?; } buffer_handle.update(&mut cx, |buffer, cx| { - buffer.did_save(version.clone(), fingerprint, entry.mtime, cx); + buffer.did_save(version.clone(), fingerprint, mtime, cx); })?; Ok(()) @@ -1081,7 +1124,7 @@ impl LocalWorktree { path: impl Into>, is_dir: bool, cx: &mut ModelContext, - ) -> Task> { + ) -> Task>> { let path = path.into(); let lowest_ancestor = self.lowest_ancestor(&path); let abs_path = self.absolutize(&path); @@ -1098,7 +1141,7 @@ impl LocalWorktree { cx.spawn(|this, mut cx| async move { write.await?; let (result, refreshes) = this.update(&mut cx, |this, cx| { - let mut refreshes = Vec::>>::new(); + let mut refreshes = Vec::new(); let refresh_paths = path.strip_prefix(&lowest_ancestor).unwrap(); for refresh_path in refresh_paths.ancestors() { if refresh_path == Path::new("") { @@ -1125,14 +1168,14 @@ impl LocalWorktree { }) } - pub fn write_file( + pub(crate) fn write_file( &self, path: impl Into>, text: Rope, line_ending: LineEnding, cx: &mut ModelContext, - ) -> Task> { - let path = path.into(); + ) -> Task>> { + let path: Arc = path.into(); let abs_path = self.absolutize(&path); let fs = self.fs.clone(); let write = cx @@ -1191,8 +1234,11 @@ impl LocalWorktree { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let old_path = self.entry_for_id(entry_id)?.path.clone(); + ) -> Task>> { + let old_path = match self.entry_for_id(entry_id) { + Some(entry) => entry.path.clone(), + None => return Task::ready(Ok(None)), + }; let new_path = new_path.into(); let abs_old_path = self.absolutize(&old_path); let abs_new_path = self.absolutize(&new_path); @@ -1202,7 +1248,7 @@ impl LocalWorktree { .await }); - Some(cx.spawn(|this, mut cx| async move { + cx.spawn(|this, mut cx| async move { rename.await?; this.update(&mut cx, |this, cx| { this.as_local_mut() @@ -1210,7 +1256,7 @@ impl LocalWorktree { .refresh_entry(new_path.clone(), Some(old_path), cx) })? .await - })) + }) } pub fn copy_entry( @@ -1218,8 +1264,11 @@ impl LocalWorktree { entry_id: ProjectEntryId, new_path: impl Into>, cx: &mut ModelContext, - ) -> Option>> { - let old_path = self.entry_for_id(entry_id)?.path.clone(); + ) -> Task>> { + let old_path = match self.entry_for_id(entry_id) { + Some(entry) => entry.path.clone(), + None => return Task::ready(Ok(None)), + }; let new_path = new_path.into(); let abs_old_path = self.absolutize(&old_path); let abs_new_path = self.absolutize(&new_path); @@ -1234,7 +1283,7 @@ impl LocalWorktree { .await }); - Some(cx.spawn(|this, mut cx| async move { + cx.spawn(|this, mut cx| async move { copy.await?; this.update(&mut cx, |this, cx| { this.as_local_mut() @@ -1242,7 +1291,7 @@ impl LocalWorktree { .refresh_entry(new_path.clone(), None, cx) })? .await - })) + }) } pub fn expand_entry( @@ -1278,7 +1327,10 @@ impl LocalWorktree { path: Arc, old_path: Option>, cx: &mut ModelContext, - ) -> Task> { + ) -> Task>> { + if self.is_path_excluded(path.to_path_buf()) { + return Task::ready(Ok(None)); + } let paths = if let Some(old_path) = old_path.as_ref() { vec![old_path.clone(), path.clone()] } else { @@ -1287,11 +1339,12 @@ impl LocalWorktree { let mut refresh = self.refresh_entries_for_paths(paths); cx.spawn(move |this, mut cx| async move { refresh.recv().await; - this.update(&mut cx, |this, _| { + let new_entry = this.update(&mut cx, |this, _| { this.entry_for_path(path) .cloned() .ok_or_else(|| anyhow!("failed to read path after update")) - })? + })??; + Ok(Some(new_entry)) }) } @@ -2222,10 +2275,19 @@ impl LocalSnapshot { paths } - pub fn is_path_excluded(&self, abs_path: &Path) -> bool { - self.file_scan_exclusions - .iter() - .any(|exclude_matcher| exclude_matcher.is_match(abs_path)) + pub fn is_path_excluded(&self, mut path: PathBuf) -> bool { + loop { + if self + .file_scan_exclusions + .iter() + .any(|exclude_matcher| exclude_matcher.is_match(&path)) + { + return true; + } + if !path.pop() { + return false; + } + } } } @@ -2455,8 +2517,7 @@ impl BackgroundScannerState { ids_to_preserve.insert(work_directory_id); } else { let git_dir_abs_path = snapshot.abs_path().join(&entry.git_dir_path); - let git_dir_excluded = snapshot.is_path_excluded(&entry.git_dir_path) - || snapshot.is_path_excluded(&git_dir_abs_path); + let git_dir_excluded = snapshot.is_path_excluded(entry.git_dir_path.to_path_buf()); if git_dir_excluded && !matches!(smol::block_on(fs.metadata(&git_dir_abs_path)), Ok(None)) { @@ -2663,7 +2724,7 @@ pub struct File { pub worktree: Model, pub path: Arc, pub mtime: SystemTime, - pub(crate) entry_id: ProjectEntryId, + pub(crate) entry_id: Option, pub(crate) is_local: bool, pub(crate) is_deleted: bool, } @@ -2732,7 +2793,7 @@ impl language::File for File { fn to_proto(&self) -> rpc::proto::File { rpc::proto::File { worktree_id: self.worktree.entity_id().as_u64(), - entry_id: self.entry_id.to_proto(), + entry_id: self.entry_id.map(|id| id.to_proto()), path: self.path.to_string_lossy().into(), mtime: Some(self.mtime.into()), is_deleted: self.is_deleted, @@ -2790,7 +2851,7 @@ impl File { worktree, path: entry.path.clone(), mtime: entry.mtime, - entry_id: entry.id, + entry_id: Some(entry.id), is_local: true, is_deleted: false, }) @@ -2815,7 +2876,7 @@ impl File { worktree, path: Path::new(&proto.path).into(), mtime: proto.mtime.ok_or_else(|| anyhow!("no timestamp"))?.into(), - entry_id: ProjectEntryId::from_proto(proto.entry_id), + entry_id: proto.entry_id.map(ProjectEntryId::from_proto), is_local: false, is_deleted: proto.is_deleted, }) @@ -2833,7 +2894,7 @@ impl File { if self.is_deleted { None } else { - Some(self.entry_id) + self.entry_id } } } @@ -3329,16 +3390,7 @@ impl BackgroundScanner { return false; } - // FS events may come for files which parent directory is excluded, need to check ignore those. - let mut path_to_test = abs_path.clone(); - let mut excluded_file_event = snapshot.is_path_excluded(abs_path) - || snapshot.is_path_excluded(&relative_path); - while !excluded_file_event && path_to_test.pop() { - if snapshot.is_path_excluded(&path_to_test) { - excluded_file_event = true; - } - } - if excluded_file_event { + if snapshot.is_path_excluded(relative_path.to_path_buf()) { if !is_git_related { log::debug!("ignoring FS event for excluded path {relative_path:?}"); } @@ -3522,7 +3574,7 @@ impl BackgroundScanner { let state = self.state.lock(); let snapshot = &state.snapshot; root_abs_path = snapshot.abs_path().clone(); - if snapshot.is_path_excluded(&job.abs_path) { + if snapshot.is_path_excluded(job.path.to_path_buf()) { log::error!("skipping excluded directory {:?}", job.path); return Ok(()); } @@ -3593,9 +3645,9 @@ impl BackgroundScanner { } { + let relative_path = job.path.join(child_name); let mut state = self.state.lock(); - if state.snapshot.is_path_excluded(&child_abs_path) { - let relative_path = job.path.join(child_name); + if state.snapshot.is_path_excluded(relative_path.clone()) { log::debug!("skipping excluded child entry {relative_path:?}"); state.remove_path(&relative_path); continue; diff --git a/crates/project2/src/worktree_tests.rs b/crates/project2/src/worktree_tests.rs index 501a5f736f1934807984d90357593058ef1cde68..fbf8b74d624672c6e62b2a98d96ee8764b500957 100644 --- a/crates/project2/src/worktree_tests.rs +++ b/crates/project2/src/worktree_tests.rs @@ -1055,11 +1055,12 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { &[ ".git/HEAD", ".git/foo", + "node_modules", "node_modules/.DS_Store", "node_modules/prettier", "node_modules/prettier/package.json", ], - &["target", "node_modules"], + &["target"], &[ ".DS_Store", "src/.DS_Store", @@ -1109,6 +1110,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { ".git/HEAD", ".git/foo", ".git/new_file", + "node_modules", "node_modules/.DS_Store", "node_modules/prettier", "node_modules/prettier/package.json", @@ -1117,7 +1119,7 @@ async fn test_fs_events_in_exclusions(cx: &mut TestAppContext) { "build_output/new_file", "test_output/new_file", ], - &["target", "node_modules", "test_output"], + &["target", "test_output"], &[ ".DS_Store", "src/.DS_Store", @@ -1177,6 +1179,7 @@ async fn test_create_directory_during_initial_scan(cx: &mut TestAppContext) { .create_entry("a/e".as_ref(), true, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_dir()); @@ -1226,6 +1229,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("a/b/c/d.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1261,6 +1265,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("a/b/c/d.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1279,6 +1284,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("a/b/c/e.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1295,6 +1301,7 @@ async fn test_create_dir_all_on_create_entry(cx: &mut TestAppContext) { .create_entry("d/e/f/g.txt".as_ref(), false, cx) }) .await + .unwrap() .unwrap(); assert!(entry.is_file()); @@ -1620,14 +1627,14 @@ fn randomly_mutate_worktree( entry.id.0, new_path ); - let task = worktree.rename_entry(entry.id, new_path, cx).unwrap(); + let task = worktree.rename_entry(entry.id, new_path, cx); cx.background_executor().spawn(async move { - task.await?; + task.await?.unwrap(); Ok(()) }) } _ => { - let task = if entry.is_dir() { + if entry.is_dir() { let child_path = entry.path.join(random_filename(rng)); let is_dir = rng.gen_bool(0.3); log::info!( @@ -1635,15 +1642,20 @@ fn randomly_mutate_worktree( if is_dir { "dir" } else { "file" }, child_path, ); - worktree.create_entry(child_path, is_dir, cx) + let task = worktree.create_entry(child_path, is_dir, cx); + cx.background_executor().spawn(async move { + task.await?; + Ok(()) + }) } else { log::info!("overwriting file {:?} ({})", entry.path, entry.id.0); - worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx) - }; - cx.background_executor().spawn(async move { - task.await?; - Ok(()) - }) + let task = + worktree.write_file(entry.path.clone(), "".into(), Default::default(), cx); + cx.background_executor().spawn(async move { + task.await?; + Ok(()) + }) + } } } } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index eb124bfca28840f4b99a3b022abbbee33611fc0e..c37d38804151585ca99bdc909cddbbbf0633f141 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -621,7 +621,7 @@ impl ProjectPanel { edited_entry_id = NEW_ENTRY_ID; edit_task = self.project.update(cx, |project, cx| { project.create_entry((worktree_id, &new_path), is_dir, cx) - })?; + }); } else { let new_path = if let Some(parent) = entry.path.clone().parent() { parent.join(&filename) @@ -635,7 +635,7 @@ impl ProjectPanel { edited_entry_id = entry.id; edit_task = self.project.update(cx, |project, cx| { project.rename_entry(entry.id, new_path.as_path(), cx) - })?; + }); }; edit_state.processing_filename = Some(filename); @@ -648,21 +648,22 @@ impl ProjectPanel { cx.notify(); })?; - let new_entry = new_entry?; - this.update(&mut cx, |this, cx| { - if let Some(selection) = &mut this.selection { - if selection.entry_id == edited_entry_id { - selection.worktree_id = worktree_id; - selection.entry_id = new_entry.id; - this.expand_to_selection(cx); + if let Some(new_entry) = new_entry? { + this.update(&mut cx, |this, cx| { + if let Some(selection) = &mut this.selection { + if selection.entry_id == edited_entry_id { + selection.worktree_id = worktree_id; + selection.entry_id = new_entry.id; + this.expand_to_selection(cx); + } } - } - this.update_visible_entries(None, cx); - if is_new_entry && !is_dir { - this.open_entry(new_entry.id, true, cx); - } - cx.notify(); - })?; + this.update_visible_entries(None, cx); + if is_new_entry && !is_dir { + this.open_entry(new_entry.id, true, cx); + } + cx.notify(); + })?; + } Ok(()) })) } @@ -935,15 +936,17 @@ impl ProjectPanel { } if clipboard_entry.is_cut() { - if let Some(task) = self.project.update(cx, |project, cx| { - project.rename_entry(clipboard_entry.entry_id(), new_path, cx) - }) { - task.detach_and_log_err(cx) - } - } else if let Some(task) = self.project.update(cx, |project, cx| { - project.copy_entry(clipboard_entry.entry_id(), new_path, cx) - }) { - task.detach_and_log_err(cx) + self.project + .update(cx, |project, cx| { + project.rename_entry(clipboard_entry.entry_id(), new_path, cx) + }) + .detach_and_log_err(cx) + } else { + self.project + .update(cx, |project, cx| { + project.copy_entry(clipboard_entry.entry_id(), new_path, cx) + }) + .detach_and_log_err(cx) } } None @@ -1026,7 +1029,7 @@ impl ProjectPanel { let mut new_path = destination_path.to_path_buf(); new_path.push(entry_path.path.file_name()?); if new_path != entry_path.path.as_ref() { - let task = project.rename_entry(entry_to_move, new_path, cx)?; + let task = project.rename_entry(entry_to_move, new_path, cx); cx.foreground().spawn(task).detach_and_log_err(cx); } @@ -1627,9 +1630,21 @@ impl View for ProjectPanel { } } - fn update_keymap_context(&self, keymap: &mut KeymapContext, _: &AppContext) { + fn update_keymap_context(&self, keymap: &mut KeymapContext, cx: &AppContext) { Self::reset_to_default_keymap_context(keymap); keymap.add_identifier("menu"); + + if let Some(window) = cx.active_window() { + window.read_with(cx, |cx| { + let identifier = if self.filename_editor.is_focused(cx) { + "editing" + } else { + "not_editing" + }; + + keymap.add_identifier(identifier); + }); + } } fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext) { diff --git a/crates/project_panel2/src/project_panel.rs b/crates/project_panel2/src/project_panel.rs index fd0473358e54876462218ab24a4633e4fcb50531..81a7c779ca2380e62890f652ddd893660b6abc5f 100644 --- a/crates/project_panel2/src/project_panel.rs +++ b/crates/project_panel2/src/project_panel.rs @@ -10,9 +10,9 @@ use anyhow::{anyhow, Result}; use gpui::{ actions, div, overlay, px, uniform_list, Action, AppContext, AssetSource, AsyncWindowContext, ClipboardItem, DismissEvent, Div, EventEmitter, FocusHandle, Focusable, FocusableView, - InteractiveElement, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, - PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, View, - ViewContext, VisualContext as _, WeakView, WindowContext, + InteractiveElement, KeyContext, Model, MouseButton, MouseDownEvent, ParentElement, Pixels, + Point, PromptLevel, Render, Stateful, Styled, Subscription, Task, UniformListScrollHandle, + View, ViewContext, VisualContext as _, WeakView, WindowContext, }; use menu::{Confirm, SelectNext, SelectPrev}; use project::{ @@ -397,7 +397,6 @@ impl ProjectPanel { menu = menu.action( "Add Folder to Project", Box::new(workspace::AddFolderToProject), - cx, ); if is_root { menu = menu.entry( @@ -412,35 +411,35 @@ impl ProjectPanel { } menu = menu - .action("New File", Box::new(NewFile), cx) - .action("New Folder", Box::new(NewDirectory), cx) + .action("New File", Box::new(NewFile)) + .action("New Folder", Box::new(NewDirectory)) .separator() - .action("Cut", Box::new(Cut), cx) - .action("Copy", Box::new(Copy), cx); + .action("Cut", Box::new(Cut)) + .action("Copy", Box::new(Copy)); if let Some(clipboard_entry) = self.clipboard_entry { if clipboard_entry.worktree_id() == worktree_id { - menu = menu.action("Paste", Box::new(Paste), cx); + menu = menu.action("Paste", Box::new(Paste)); } } menu = menu .separator() - .action("Copy Path", Box::new(CopyPath), cx) - .action("Copy Relative Path", Box::new(CopyRelativePath), cx) + .action("Copy Path", Box::new(CopyPath)) + .action("Copy Relative Path", Box::new(CopyRelativePath)) .separator() - .action("Reveal in Finder", Box::new(RevealInFinder), cx); + .action("Reveal in Finder", Box::new(RevealInFinder)); if is_dir { menu = menu - .action("Open in Terminal", Box::new(OpenInTerminal), cx) - .action("Search Inside", Box::new(NewSearchInDirectory), cx) + .action("Open in Terminal", Box::new(OpenInTerminal)) + .action("Search Inside", Box::new(NewSearchInDirectory)) } - menu = menu.separator().action("Rename", Box::new(Rename), cx); + menu = menu.separator().action("Rename", Box::new(Rename)); if !is_root { - menu = menu.action("Delete", Box::new(Delete), cx); + menu = menu.action("Delete", Box::new(Delete)); } menu @@ -611,7 +610,7 @@ impl ProjectPanel { edited_entry_id = NEW_ENTRY_ID; edit_task = self.project.update(cx, |project, cx| { project.create_entry((worktree_id, &new_path), is_dir, cx) - })?; + }); } else { let new_path = if let Some(parent) = entry.path.clone().parent() { parent.join(&filename) @@ -625,7 +624,7 @@ impl ProjectPanel { edited_entry_id = entry.id; edit_task = self.project.update(cx, |project, cx| { project.rename_entry(entry.id, new_path.as_path(), cx) - })?; + }); }; edit_state.processing_filename = Some(filename); @@ -638,21 +637,22 @@ impl ProjectPanel { cx.notify(); })?; - let new_entry = new_entry?; - this.update(&mut cx, |this, cx| { - if let Some(selection) = &mut this.selection { - if selection.entry_id == edited_entry_id { - selection.worktree_id = worktree_id; - selection.entry_id = new_entry.id; - this.expand_to_selection(cx); + if let Some(new_entry) = new_entry? { + this.update(&mut cx, |this, cx| { + if let Some(selection) = &mut this.selection { + if selection.entry_id == edited_entry_id { + selection.worktree_id = worktree_id; + selection.entry_id = new_entry.id; + this.expand_to_selection(cx); + } } - } - this.update_visible_entries(None, cx); - if is_new_entry && !is_dir { - this.open_entry(new_entry.id, true, cx); - } - cx.notify(); - })?; + this.update_visible_entries(None, cx); + if is_new_entry && !is_dir { + this.open_entry(new_entry.id, true, cx); + } + cx.notify(); + })?; + } Ok(()) })) } @@ -932,15 +932,17 @@ impl ProjectPanel { } if clipboard_entry.is_cut() { - if let Some(task) = self.project.update(cx, |project, cx| { - project.rename_entry(clipboard_entry.entry_id(), new_path, cx) - }) { - task.detach_and_log_err(cx); - } - } else if let Some(task) = self.project.update(cx, |project, cx| { - project.copy_entry(clipboard_entry.entry_id(), new_path, cx) - }) { - task.detach_and_log_err(cx); + self.project + .update(cx, |project, cx| { + project.rename_entry(clipboard_entry.entry_id(), new_path, cx) + }) + .detach_and_log_err(cx) + } else { + self.project + .update(cx, |project, cx| { + project.copy_entry(clipboard_entry.entry_id(), new_path, cx) + }) + .detach_and_log_err(cx) } Some(()) @@ -1026,7 +1028,7 @@ impl ProjectPanel { // let mut new_path = destination_path.to_path_buf(); // new_path.push(entry_path.path.file_name()?); // if new_path != entry_path.path.as_ref() { - // let task = project.rename_entry(entry_to_move, new_path, cx)?; + // let task = project.rename_entry(entry_to_move, new_path, cx); // cx.foreground_executor().spawn(task).detach_and_log_err(cx); // } @@ -1420,6 +1422,22 @@ impl ProjectPanel { // ); // }) } + + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { + let mut dispatch_context = KeyContext::default(); + dispatch_context.add("ProjectPanel"); + dispatch_context.add("menu"); + + let identifier = if self.filename_editor.focus_handle(cx).is_focused(cx) { + "editing" + } else { + "not_editing" + }; + + dispatch_context.add(identifier); + + dispatch_context + } } impl Render for ProjectPanel { @@ -1433,7 +1451,7 @@ impl Render for ProjectPanel { .id("project-panel") .size_full() .relative() - .key_context("ProjectPanel") + .key_context(self.dispatch_context(cx)) .on_action(cx.listener(Self::select_next)) .on_action(cx.listener(Self::select_prev)) .on_action(cx.listener(Self::expand_selected_entry)) diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index a6d27fa57d4a0a9a063f4f0a30b634207ef8ac63..611514aacb44f9445674f2dca0b947eda8088ee3 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -430,7 +430,7 @@ message ExpandProjectEntryResponse { } message ProjectEntryResponse { - Entry entry = 1; + optional Entry entry = 1; uint64 worktree_scan_id = 2; } @@ -1357,7 +1357,7 @@ message User { message File { uint64 worktree_id = 1; - uint64 entry_id = 2; + optional uint64 entry_id = 2; string path = 3; Timestamp mtime = 4; bool is_deleted = 5; diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 6f35bf64bc23c116c67000eabc29be07f5d6da8c..da0880377fb7ef4e381587a08c4ac3e0a9a2e4dc 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -9,4 +9,4 @@ pub use notification::*; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 66; +pub const PROTOCOL_VERSION: u32 = 67; diff --git a/crates/rpc2/proto/zed.proto b/crates/rpc2/proto/zed.proto index a6d27fa57d4a0a9a063f4f0a30b634207ef8ac63..611514aacb44f9445674f2dca0b947eda8088ee3 100644 --- a/crates/rpc2/proto/zed.proto +++ b/crates/rpc2/proto/zed.proto @@ -430,7 +430,7 @@ message ExpandProjectEntryResponse { } message ProjectEntryResponse { - Entry entry = 1; + optional Entry entry = 1; uint64 worktree_scan_id = 2; } @@ -1357,7 +1357,7 @@ message User { message File { uint64 worktree_id = 1; - uint64 entry_id = 2; + optional uint64 entry_id = 2; string path = 3; Timestamp mtime = 4; bool is_deleted = 5; diff --git a/crates/rpc2/src/rpc.rs b/crates/rpc2/src/rpc.rs index 4bf90669b236ae301cfbf9e2ecb4d179211df17d..da0880377fb7ef4e381587a08c4ac3e0a9a2e4dc 100644 --- a/crates/rpc2/src/rpc.rs +++ b/crates/rpc2/src/rpc.rs @@ -9,4 +9,4 @@ pub use notification::*; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 64; +pub const PROTOCOL_VERSION: u32 = 67; diff --git a/crates/semantic_index2/Cargo.toml b/crates/semantic_index2/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..65ffb05ca5c8bc9f2d06a864f4c22acec3f34594 --- /dev/null +++ b/crates/semantic_index2/Cargo.toml @@ -0,0 +1,69 @@ +[package] +name = "semantic_index2" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/semantic_index.rs" +doctest = false + +[dependencies] +ai = { package = "ai2", path = "../ai2" } +collections = { path = "../collections" } +gpui = { package = "gpui2", path = "../gpui2" } +language = { package = "language2", path = "../language2" } +project = { package = "project2", path = "../project2" } +workspace = { package = "workspace2", path = "../workspace2" } +util = { path = "../util" } +rpc = { package = "rpc2", path = "../rpc2" } +settings = { package = "settings2", path = "../settings2" } +anyhow.workspace = true +postage.workspace = true +futures.workspace = true +ordered-float.workspace = true +smol.workspace = true +rusqlite.workspace = true +log.workspace = true +tree-sitter.workspace = true +lazy_static.workspace = true +serde.workspace = true +serde_json.workspace = true +async-trait.workspace = true +tiktoken-rs.workspace = true +parking_lot.workspace = true +rand.workspace = true +schemars.workspace = true +globset.workspace = true +sha1 = "0.10.5" +ndarray = { version = "0.15.0" } + +[dev-dependencies] +ai = { package = "ai2", path = "../ai2", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +gpui = { package = "gpui2", path = "../gpui2", features = ["test-support"] } +language = { package = "language2", path = "../language2", features = ["test-support"] } +project = { package = "project2", path = "../project2", features = ["test-support"] } +rpc = { package = "rpc2", path = "../rpc2", features = ["test-support"] } +workspace = { package = "workspace2", path = "../workspace2", features = ["test-support"] } +settings = { package = "settings2", path = "../settings2", features = ["test-support"]} +rust-embed = { version = "8.0", features = ["include-exclude"] } +client = { package = "client2", path = "../client2" } +node_runtime = { path = "../node_runtime"} + +pretty_assertions.workspace = true +rand.workspace = true +unindent.workspace = true +tempdir.workspace = true +ctor.workspace = true +env_logger.workspace = true + +tree-sitter-typescript.workspace = true +tree-sitter-json.workspace = true +tree-sitter-rust.workspace = true +tree-sitter-toml.workspace = true +tree-sitter-cpp.workspace = true +tree-sitter-elixir.workspace = true +tree-sitter-lua.workspace = true +tree-sitter-ruby.workspace = true +tree-sitter-php.workspace = true diff --git a/crates/semantic_index2/README.md b/crates/semantic_index2/README.md new file mode 100644 index 0000000000000000000000000000000000000000..85f83af121ed96a51ac84165c19cda3cd8aff7d4 --- /dev/null +++ b/crates/semantic_index2/README.md @@ -0,0 +1,20 @@ + +# Semantic Index + +## Evaluation + +### Metrics + +nDCG@k: +- "The value of NDCG is determined by comparing the relevance of the items returned by the search engine to the relevance of the item that a hypothetical "ideal" search engine would return. +- "The relevance of result is represented by a score (also known as a 'grade') that is assigned to the search query. The scores of these results are then discounted based on their position in the search results -- did they get recommended first or last?" + +MRR@k: +- "Mean reciprocal rank quantifies the rank of the first relevant item found in teh recommendation list." + +MAP@k: +- "Mean average precision averages the precision@k metric at each relevant item position in the recommendation list. + +Resources: +- [Evaluating recommendation metrics](https://www.shaped.ai/blog/evaluating-recommendation-systems-map-mmr-ndcg) +- [Math Walkthrough](https://towardsdatascience.com/demystifying-ndcg-bee3be58cfe0) diff --git a/crates/semantic_index2/eval/gpt-engineer.json b/crates/semantic_index2/eval/gpt-engineer.json new file mode 100644 index 0000000000000000000000000000000000000000..d008cc65d13b0c6a718beb57ece2393bb999029c --- /dev/null +++ b/crates/semantic_index2/eval/gpt-engineer.json @@ -0,0 +1,114 @@ +{ + "repo": "https://github.com/AntonOsika/gpt-engineer.git", + "commit": "7735a6445bae3611c62f521e6464c67c957f87c2", + "assertions": [ + { + "query": "How do I contribute to this project?", + "matches": [ + ".github/CONTRIBUTING.md:1", + "ROADMAP.md:48" + ] + }, + { + "query": "What version of the openai package is active?", + "matches": [ + "pyproject.toml:14" + ] + }, + { + "query": "Ask user for clarification", + "matches": [ + "gpt_engineer/steps.py:69" + ] + }, + { + "query": "generate tests for python code", + "matches": [ + "gpt_engineer/steps.py:153" + ] + }, + { + "query": "get item from database based on key", + "matches": [ + "gpt_engineer/db.py:42", + "gpt_engineer/db.py:68" + ] + }, + { + "query": "prompt user to select files", + "matches": [ + "gpt_engineer/file_selector.py:171", + "gpt_engineer/file_selector.py:306", + "gpt_engineer/file_selector.py:289", + "gpt_engineer/file_selector.py:234" + ] + }, + { + "query": "send to rudderstack", + "matches": [ + "gpt_engineer/collect.py:11", + "gpt_engineer/collect.py:38" + ] + }, + { + "query": "parse code blocks from chat messages", + "matches": [ + "gpt_engineer/chat_to_files.py:10", + "docs/intro/chat_parsing.md:1" + ] + }, + { + "query": "how do I use the docker cli?", + "matches": [ + "docker/README.md:1" + ] + }, + { + "query": "ask the user if the code ran successfully?", + "matches": [ + "gpt_engineer/learning.py:54" + ] + }, + { + "query": "how is consent granted by the user?", + "matches": [ + "gpt_engineer/learning.py:107", + "gpt_engineer/learning.py:130", + "gpt_engineer/learning.py:152" + ] + }, + { + "query": "what are all the different steps the agent can take?", + "matches": [ + "docs/intro/steps_module.md:1", + "gpt_engineer/steps.py:391" + ] + }, + { + "query": "ask the user for clarification?", + "matches": [ + "gpt_engineer/steps.py:69" + ] + }, + { + "query": "what models are available?", + "matches": [ + "gpt_engineer/ai.py:315", + "gpt_engineer/ai.py:341", + "docs/open-models.md:1" + ] + }, + { + "query": "what is the current focus of the project?", + "matches": [ + "ROADMAP.md:11" + ] + }, + { + "query": "does the agent know how to fix code?", + "matches": [ + "gpt_engineer/steps.py:367" + ] + } + ] +} diff --git a/crates/semantic_index2/eval/tree-sitter.json b/crates/semantic_index2/eval/tree-sitter.json new file mode 100644 index 0000000000000000000000000000000000000000..d3dcc86937d723e8a00e2b2bfc91e86163c1aac3 --- /dev/null +++ b/crates/semantic_index2/eval/tree-sitter.json @@ -0,0 +1,104 @@ +{ + "repo": "https://github.com/tree-sitter/tree-sitter.git", + "commit": "46af27796a76c72d8466627d499f2bca4af958ee", + "assertions": [ + { + "query": "What attributes are available for the tags configuration struct?", + "matches": [ + "tags/src/lib.rs:24" + ] + }, + { + "query": "create a new tag configuration", + "matches": [ + "tags/src/lib.rs:119" + ] + }, + { + "query": "generate tags based on config", + "matches": [ + "tags/src/lib.rs:261" + ] + }, + { + "query": "match on ts quantifier in rust", + "matches": [ + "lib/binding_rust/lib.rs:139" + ] + }, + { + "query": "cli command to generate tags", + "matches": [ + "cli/src/tags.rs:10" + ] + }, + { + "query": "what version of the tree-sitter-tags package is active?", + "matches": [ + "tags/Cargo.toml:4" + ] + }, + { + "query": "Insert a new parse state", + "matches": [ + "cli/src/generate/build_tables/build_parse_table.rs:153" + ] + }, + { + "query": "Handle conflict when numerous actions occur on the same symbol", + "matches": [ + "cli/src/generate/build_tables/build_parse_table.rs:363", + "cli/src/generate/build_tables/build_parse_table.rs:442" + ] + }, + { + "query": "Match based on associativity of actions", + "matches": [ + "cri/src/generate/build_tables/build_parse_table.rs:542" + ] + }, + { + "query": "Format token set display", + "matches": [ + "cli/src/generate/build_tables/item.rs:246" + ] + }, + { + "query": "extract choices from rule", + "matches": [ + "cli/src/generate/prepare_grammar/flatten_grammar.rs:124" + ] + }, + { + "query": "How do we identify if a symbol is being used?", + "matches": [ + "cli/src/generate/prepare_grammar/flatten_grammar.rs:175" + ] + }, + { + "query": "How do we launch the playground?", + "matches": [ + "cli/src/playground.rs:46" + ] + }, + { + "query": "How do we test treesitter query matches in rust?", + "matches": [ + "cli/src/query_testing.rs:152", + "cli/src/tests/query_test.rs:781", + "cli/src/tests/query_test.rs:2163", + "cli/src/tests/query_test.rs:3781", + "cli/src/tests/query_test.rs:887" + ] + }, + { + "query": "What does the CLI do?", + "matches": [ + "cli/README.md:10", + "cli/loader/README.md:3", + "docs/section-5-implementation.md:14", + "docs/section-5-implementation.md:18" + ] + } + ] +} diff --git a/crates/semantic_index2/src/db.rs b/crates/semantic_index2/src/db.rs new file mode 100644 index 0000000000000000000000000000000000000000..f34baeaaae1373f4186e604689ea66df2094925d --- /dev/null +++ b/crates/semantic_index2/src/db.rs @@ -0,0 +1,603 @@ +use crate::{ + parsing::{Span, SpanDigest}, + SEMANTIC_INDEX_VERSION, +}; +use ai::embedding::Embedding; +use anyhow::{anyhow, Context, Result}; +use collections::HashMap; +use futures::channel::oneshot; +use gpui::BackgroundExecutor; +use ndarray::{Array1, Array2}; +use ordered_float::OrderedFloat; +use project::Fs; +use rpc::proto::Timestamp; +use rusqlite::params; +use rusqlite::types::Value; +use std::{ + future::Future, + ops::Range, + path::{Path, PathBuf}, + rc::Rc, + sync::Arc, + time::SystemTime, +}; +use util::{paths::PathMatcher, TryFutureExt}; + +pub fn argsort(data: &[T]) -> Vec { + let mut indices = (0..data.len()).collect::>(); + indices.sort_by_key(|&i| &data[i]); + indices.reverse(); + indices +} + +#[derive(Debug)] +pub struct FileRecord { + pub id: usize, + pub relative_path: String, + pub mtime: Timestamp, +} + +#[derive(Clone)] +pub struct VectorDatabase { + path: Arc, + transactions: + smol::channel::Sender>, +} + +impl VectorDatabase { + pub async fn new( + fs: Arc, + path: Arc, + executor: BackgroundExecutor, + ) -> Result { + if let Some(db_directory) = path.parent() { + fs.create_dir(db_directory).await?; + } + + let (transactions_tx, transactions_rx) = smol::channel::unbounded::< + Box, + >(); + executor + .spawn({ + let path = path.clone(); + async move { + let mut connection = rusqlite::Connection::open(&path)?; + + connection.pragma_update(None, "journal_mode", "wal")?; + connection.pragma_update(None, "synchronous", "normal")?; + connection.pragma_update(None, "cache_size", 1000000)?; + connection.pragma_update(None, "temp_store", "MEMORY")?; + + while let Ok(transaction) = transactions_rx.recv().await { + transaction(&mut connection); + } + + anyhow::Ok(()) + } + .log_err() + }) + .detach(); + let this = Self { + transactions: transactions_tx, + path, + }; + this.initialize_database().await?; + Ok(this) + } + + pub fn path(&self) -> &Arc { + &self.path + } + + fn transact(&self, f: F) -> impl Future> + where + F: 'static + Send + FnOnce(&rusqlite::Transaction) -> Result, + T: 'static + Send, + { + let (tx, rx) = oneshot::channel(); + let transactions = self.transactions.clone(); + async move { + if transactions + .send(Box::new(|connection| { + let result = connection + .transaction() + .map_err(|err| anyhow!(err)) + .and_then(|transaction| { + let result = f(&transaction)?; + transaction.commit()?; + Ok(result) + }); + let _ = tx.send(result); + })) + .await + .is_err() + { + return Err(anyhow!("connection was dropped"))?; + } + rx.await? + } + } + + fn initialize_database(&self) -> impl Future> { + self.transact(|db| { + rusqlite::vtab::array::load_module(&db)?; + + // Delete existing tables, if SEMANTIC_INDEX_VERSION is bumped + let version_query = db.prepare("SELECT version from semantic_index_config"); + let version = version_query + .and_then(|mut query| query.query_row([], |row| Ok(row.get::<_, i64>(0)?))); + if version.map_or(false, |version| version == SEMANTIC_INDEX_VERSION as i64) { + log::trace!("vector database schema up to date"); + return Ok(()); + } + + log::trace!("vector database schema out of date. updating..."); + // We renamed the `documents` table to `spans`, so we want to drop + // `documents` without recreating it if it exists. + db.execute("DROP TABLE IF EXISTS documents", []) + .context("failed to drop 'documents' table")?; + db.execute("DROP TABLE IF EXISTS spans", []) + .context("failed to drop 'spans' table")?; + db.execute("DROP TABLE IF EXISTS files", []) + .context("failed to drop 'files' table")?; + db.execute("DROP TABLE IF EXISTS worktrees", []) + .context("failed to drop 'worktrees' table")?; + db.execute("DROP TABLE IF EXISTS semantic_index_config", []) + .context("failed to drop 'semantic_index_config' table")?; + + // Initialize Vector Databasing Tables + db.execute( + "CREATE TABLE semantic_index_config ( + version INTEGER NOT NULL + )", + [], + )?; + + db.execute( + "INSERT INTO semantic_index_config (version) VALUES (?1)", + params![SEMANTIC_INDEX_VERSION], + )?; + + db.execute( + "CREATE TABLE worktrees ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + absolute_path VARCHAR NOT NULL + ); + CREATE UNIQUE INDEX worktrees_absolute_path ON worktrees (absolute_path); + ", + [], + )?; + + db.execute( + "CREATE TABLE files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + worktree_id INTEGER NOT NULL, + relative_path VARCHAR NOT NULL, + mtime_seconds INTEGER NOT NULL, + mtime_nanos INTEGER NOT NULL, + FOREIGN KEY(worktree_id) REFERENCES worktrees(id) ON DELETE CASCADE + )", + [], + )?; + + db.execute( + "CREATE UNIQUE INDEX files_worktree_id_and_relative_path ON files (worktree_id, relative_path)", + [], + )?; + + db.execute( + "CREATE TABLE spans ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + file_id INTEGER NOT NULL, + start_byte INTEGER NOT NULL, + end_byte INTEGER NOT NULL, + name VARCHAR NOT NULL, + embedding BLOB NOT NULL, + digest BLOB NOT NULL, + FOREIGN KEY(file_id) REFERENCES files(id) ON DELETE CASCADE + )", + [], + )?; + db.execute( + "CREATE INDEX spans_digest ON spans (digest)", + [], + )?; + + log::trace!("vector database initialized with updated schema."); + Ok(()) + }) + } + + pub fn delete_file( + &self, + worktree_id: i64, + delete_path: Arc, + ) -> impl Future> { + self.transact(move |db| { + db.execute( + "DELETE FROM files WHERE worktree_id = ?1 AND relative_path = ?2", + params![worktree_id, delete_path.to_str()], + )?; + Ok(()) + }) + } + + pub fn insert_file( + &self, + worktree_id: i64, + path: Arc, + mtime: SystemTime, + spans: Vec, + ) -> impl Future> { + self.transact(move |db| { + // Return the existing ID, if both the file and mtime match + let mtime = Timestamp::from(mtime); + + db.execute( + " + REPLACE INTO files + (worktree_id, relative_path, mtime_seconds, mtime_nanos) + VALUES (?1, ?2, ?3, ?4) + ", + params![worktree_id, path.to_str(), mtime.seconds, mtime.nanos], + )?; + + let file_id = db.last_insert_rowid(); + + let mut query = db.prepare( + " + INSERT INTO spans + (file_id, start_byte, end_byte, name, embedding, digest) + VALUES (?1, ?2, ?3, ?4, ?5, ?6) + ", + )?; + + for span in spans { + query.execute(params![ + file_id, + span.range.start.to_string(), + span.range.end.to_string(), + span.name, + span.embedding, + span.digest + ])?; + } + + Ok(()) + }) + } + + pub fn worktree_previously_indexed( + &self, + worktree_root_path: &Path, + ) -> impl Future> { + let worktree_root_path = worktree_root_path.to_string_lossy().into_owned(); + self.transact(move |db| { + let mut worktree_query = + db.prepare("SELECT id FROM worktrees WHERE absolute_path = ?1")?; + let worktree_id = worktree_query + .query_row(params![worktree_root_path], |row| Ok(row.get::<_, i64>(0)?)); + + if worktree_id.is_ok() { + return Ok(true); + } else { + return Ok(false); + } + }) + } + + pub fn embeddings_for_digests( + &self, + digests: Vec, + ) -> impl Future>> { + self.transact(move |db| { + let mut query = db.prepare( + " + SELECT digest, embedding + FROM spans + WHERE digest IN rarray(?) + ", + )?; + let mut embeddings_by_digest = HashMap::default(); + let digests = Rc::new( + digests + .into_iter() + .map(|p| Value::Blob(p.0.to_vec())) + .collect::>(), + ); + let rows = query.query_map(params![digests], |row| { + Ok((row.get::<_, SpanDigest>(0)?, row.get::<_, Embedding>(1)?)) + })?; + + for row in rows { + if let Ok(row) = row { + embeddings_by_digest.insert(row.0, row.1); + } + } + + Ok(embeddings_by_digest) + }) + } + + pub fn embeddings_for_files( + &self, + worktree_id_file_paths: HashMap>>, + ) -> impl Future>> { + self.transact(move |db| { + let mut query = db.prepare( + " + SELECT digest, embedding + FROM spans + LEFT JOIN files ON files.id = spans.file_id + WHERE files.worktree_id = ? AND files.relative_path IN rarray(?) + ", + )?; + let mut embeddings_by_digest = HashMap::default(); + for (worktree_id, file_paths) in worktree_id_file_paths { + let file_paths = Rc::new( + file_paths + .into_iter() + .map(|p| Value::Text(p.to_string_lossy().into_owned())) + .collect::>(), + ); + let rows = query.query_map(params![worktree_id, file_paths], |row| { + Ok((row.get::<_, SpanDigest>(0)?, row.get::<_, Embedding>(1)?)) + })?; + + for row in rows { + if let Ok(row) = row { + embeddings_by_digest.insert(row.0, row.1); + } + } + } + + Ok(embeddings_by_digest) + }) + } + + pub fn find_or_create_worktree( + &self, + worktree_root_path: Arc, + ) -> impl Future> { + self.transact(move |db| { + let mut worktree_query = + db.prepare("SELECT id FROM worktrees WHERE absolute_path = ?1")?; + let worktree_id = worktree_query + .query_row(params![worktree_root_path.to_string_lossy()], |row| { + Ok(row.get::<_, i64>(0)?) + }); + + if worktree_id.is_ok() { + return Ok(worktree_id?); + } + + // If worktree_id is Err, insert new worktree + db.execute( + "INSERT into worktrees (absolute_path) VALUES (?1)", + params![worktree_root_path.to_string_lossy()], + )?; + Ok(db.last_insert_rowid()) + }) + } + + pub fn get_file_mtimes( + &self, + worktree_id: i64, + ) -> impl Future>> { + self.transact(move |db| { + let mut statement = db.prepare( + " + SELECT relative_path, mtime_seconds, mtime_nanos + FROM files + WHERE worktree_id = ?1 + ORDER BY relative_path", + )?; + let mut result: HashMap = HashMap::default(); + for row in statement.query_map(params![worktree_id], |row| { + Ok(( + row.get::<_, String>(0)?.into(), + Timestamp { + seconds: row.get(1)?, + nanos: row.get(2)?, + } + .into(), + )) + })? { + let row = row?; + result.insert(row.0, row.1); + } + Ok(result) + }) + } + + pub fn top_k_search( + &self, + query_embedding: &Embedding, + limit: usize, + file_ids: &[i64], + ) -> impl Future)>>> { + let file_ids = file_ids.to_vec(); + let query = query_embedding.clone().0; + let query = Array1::from_vec(query); + self.transact(move |db| { + let mut query_statement = db.prepare( + " + SELECT + id, embedding + FROM + spans + WHERE + file_id IN rarray(?) + ", + )?; + + let deserialized_rows = query_statement + .query_map(params![ids_to_sql(&file_ids)], |row| { + Ok((row.get::<_, usize>(0)?, row.get::<_, Embedding>(1)?)) + })? + .filter_map(|row| row.ok()) + .collect::>(); + + if deserialized_rows.len() == 0 { + return Ok(Vec::new()); + } + + // Get Length of Embeddings Returned + let embedding_len = deserialized_rows[0].1 .0.len(); + + let batch_n = 1000; + let mut batches = Vec::new(); + let mut batch_ids = Vec::new(); + let mut batch_embeddings: Vec = Vec::new(); + deserialized_rows.iter().for_each(|(id, embedding)| { + batch_ids.push(id); + batch_embeddings.extend(&embedding.0); + + if batch_ids.len() == batch_n { + let embeddings = std::mem::take(&mut batch_embeddings); + let ids = std::mem::take(&mut batch_ids); + let array = + Array2::from_shape_vec((ids.len(), embedding_len.clone()), embeddings); + match array { + Ok(array) => { + batches.push((ids, array)); + } + Err(err) => log::error!("Failed to deserialize to ndarray: {:?}", err), + } + } + }); + + if batch_ids.len() > 0 { + let array = Array2::from_shape_vec( + (batch_ids.len(), embedding_len), + batch_embeddings.clone(), + ); + match array { + Ok(array) => { + batches.push((batch_ids.clone(), array)); + } + Err(err) => log::error!("Failed to deserialize to ndarray: {:?}", err), + } + } + + let mut ids: Vec = Vec::new(); + let mut results = Vec::new(); + for (batch_ids, array) in batches { + let scores = array + .dot(&query.t()) + .to_vec() + .iter() + .map(|score| OrderedFloat(*score)) + .collect::>>(); + results.extend(scores); + ids.extend(batch_ids); + } + + let sorted_idx = argsort(&results); + let mut sorted_results = Vec::new(); + let last_idx = limit.min(sorted_idx.len()); + for idx in &sorted_idx[0..last_idx] { + sorted_results.push((ids[*idx] as i64, results[*idx])) + } + + Ok(sorted_results) + }) + } + + pub fn retrieve_included_file_ids( + &self, + worktree_ids: &[i64], + includes: &[PathMatcher], + excludes: &[PathMatcher], + ) -> impl Future>> { + let worktree_ids = worktree_ids.to_vec(); + let includes = includes.to_vec(); + let excludes = excludes.to_vec(); + self.transact(move |db| { + let mut file_query = db.prepare( + " + SELECT + id, relative_path + FROM + files + WHERE + worktree_id IN rarray(?) + ", + )?; + + let mut file_ids = Vec::::new(); + let mut rows = file_query.query([ids_to_sql(&worktree_ids)])?; + + while let Some(row) = rows.next()? { + let file_id = row.get(0)?; + let relative_path = row.get_ref(1)?.as_str()?; + let included = + includes.is_empty() || includes.iter().any(|glob| glob.is_match(relative_path)); + let excluded = excludes.iter().any(|glob| glob.is_match(relative_path)); + if included && !excluded { + file_ids.push(file_id); + } + } + + anyhow::Ok(file_ids) + }) + } + + pub fn spans_for_ids( + &self, + ids: &[i64], + ) -> impl Future)>>> { + let ids = ids.to_vec(); + self.transact(move |db| { + let mut statement = db.prepare( + " + SELECT + spans.id, + files.worktree_id, + files.relative_path, + spans.start_byte, + spans.end_byte + FROM + spans, files + WHERE + spans.file_id = files.id AND + spans.id in rarray(?) + ", + )?; + + let result_iter = statement.query_map(params![ids_to_sql(&ids)], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?.into(), + row.get(3)?..row.get(4)?, + )) + })?; + + let mut values_by_id = HashMap::)>::default(); + for row in result_iter { + let (id, worktree_id, path, range) = row?; + values_by_id.insert(id, (worktree_id, path, range)); + } + + let mut results = Vec::with_capacity(ids.len()); + for id in &ids { + let value = values_by_id + .remove(id) + .ok_or(anyhow!("missing span id {}", id))?; + results.push(value); + } + + Ok(results) + }) + } +} + +fn ids_to_sql(ids: &[i64]) -> Rc> { + Rc::new( + ids.iter() + .copied() + .map(|v| rusqlite::types::Value::from(v)) + .collect::>(), + ) +} diff --git a/crates/semantic_index2/src/embedding_queue.rs b/crates/semantic_index2/src/embedding_queue.rs new file mode 100644 index 0000000000000000000000000000000000000000..a2371a1196b59834c0d5fcc034f3b0a364e4d38b --- /dev/null +++ b/crates/semantic_index2/src/embedding_queue.rs @@ -0,0 +1,169 @@ +use crate::{parsing::Span, JobHandle}; +use ai::embedding::EmbeddingProvider; +use gpui::BackgroundExecutor; +use parking_lot::Mutex; +use smol::channel; +use std::{mem, ops::Range, path::Path, sync::Arc, time::SystemTime}; + +#[derive(Clone)] +pub struct FileToEmbed { + pub worktree_id: i64, + pub path: Arc, + pub mtime: SystemTime, + pub spans: Vec, + pub job_handle: JobHandle, +} + +impl std::fmt::Debug for FileToEmbed { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FileToEmbed") + .field("worktree_id", &self.worktree_id) + .field("path", &self.path) + .field("mtime", &self.mtime) + .field("spans", &self.spans) + .finish_non_exhaustive() + } +} + +impl PartialEq for FileToEmbed { + fn eq(&self, other: &Self) -> bool { + self.worktree_id == other.worktree_id + && self.path == other.path + && self.mtime == other.mtime + && self.spans == other.spans + } +} + +pub struct EmbeddingQueue { + embedding_provider: Arc, + pending_batch: Vec, + executor: BackgroundExecutor, + pending_batch_token_count: usize, + finished_files_tx: channel::Sender, + finished_files_rx: channel::Receiver, +} + +#[derive(Clone)] +pub struct FileFragmentToEmbed { + file: Arc>, + span_range: Range, +} + +impl EmbeddingQueue { + pub fn new( + embedding_provider: Arc, + executor: BackgroundExecutor, + ) -> Self { + let (finished_files_tx, finished_files_rx) = channel::unbounded(); + Self { + embedding_provider, + executor, + pending_batch: Vec::new(), + pending_batch_token_count: 0, + finished_files_tx, + finished_files_rx, + } + } + + pub fn push(&mut self, file: FileToEmbed) { + if file.spans.is_empty() { + self.finished_files_tx.try_send(file).unwrap(); + return; + } + + let file = Arc::new(Mutex::new(file)); + + self.pending_batch.push(FileFragmentToEmbed { + file: file.clone(), + span_range: 0..0, + }); + + let mut fragment_range = &mut self.pending_batch.last_mut().unwrap().span_range; + for (ix, span) in file.lock().spans.iter().enumerate() { + let span_token_count = if span.embedding.is_none() { + span.token_count + } else { + 0 + }; + + let next_token_count = self.pending_batch_token_count + span_token_count; + if next_token_count > self.embedding_provider.max_tokens_per_batch() { + let range_end = fragment_range.end; + self.flush(); + self.pending_batch.push(FileFragmentToEmbed { + file: file.clone(), + span_range: range_end..range_end, + }); + fragment_range = &mut self.pending_batch.last_mut().unwrap().span_range; + } + + fragment_range.end = ix + 1; + self.pending_batch_token_count += span_token_count; + } + } + + pub fn flush(&mut self) { + let batch = mem::take(&mut self.pending_batch); + self.pending_batch_token_count = 0; + if batch.is_empty() { + return; + } + + let finished_files_tx = self.finished_files_tx.clone(); + let embedding_provider = self.embedding_provider.clone(); + + self.executor + .spawn(async move { + let mut spans = Vec::new(); + for fragment in &batch { + let file = fragment.file.lock(); + spans.extend( + file.spans[fragment.span_range.clone()] + .iter() + .filter(|d| d.embedding.is_none()) + .map(|d| d.content.clone()), + ); + } + + // If spans is 0, just send the fragment to the finished files if its the last one. + if spans.is_empty() { + for fragment in batch.clone() { + if let Some(file) = Arc::into_inner(fragment.file) { + finished_files_tx.try_send(file.into_inner()).unwrap(); + } + } + return; + }; + + match embedding_provider.embed_batch(spans).await { + Ok(embeddings) => { + let mut embeddings = embeddings.into_iter(); + for fragment in batch { + for span in &mut fragment.file.lock().spans[fragment.span_range.clone()] + .iter_mut() + .filter(|d| d.embedding.is_none()) + { + if let Some(embedding) = embeddings.next() { + span.embedding = Some(embedding); + } else { + log::error!("number of embeddings != number of documents"); + } + } + + if let Some(file) = Arc::into_inner(fragment.file) { + finished_files_tx.try_send(file.into_inner()).unwrap(); + } + } + } + Err(error) => { + log::error!("{:?}", error); + } + } + }) + .detach(); + } + + pub fn finished_files(&self) -> channel::Receiver { + self.finished_files_rx.clone() + } +} diff --git a/crates/semantic_index2/src/parsing.rs b/crates/semantic_index2/src/parsing.rs new file mode 100644 index 0000000000000000000000000000000000000000..cb15ca453b2c0640739bd44a95482ca527b8d91b --- /dev/null +++ b/crates/semantic_index2/src/parsing.rs @@ -0,0 +1,414 @@ +use ai::{ + embedding::{Embedding, EmbeddingProvider}, + models::TruncationDirection, +}; +use anyhow::{anyhow, Result}; +use language::{Grammar, Language}; +use rusqlite::{ + types::{FromSql, FromSqlResult, ToSqlOutput, ValueRef}, + ToSql, +}; +use sha1::{Digest, Sha1}; +use std::{ + borrow::Cow, + cmp::{self, Reverse}, + collections::HashSet, + ops::Range, + path::Path, + sync::Arc, +}; +use tree_sitter::{Parser, QueryCursor}; + +#[derive(Debug, PartialEq, Eq, Clone, Hash)] +pub struct SpanDigest(pub [u8; 20]); + +impl FromSql for SpanDigest { + fn column_result(value: ValueRef) -> FromSqlResult { + let blob = value.as_blob()?; + let bytes = + blob.try_into() + .map_err(|_| rusqlite::types::FromSqlError::InvalidBlobSize { + expected_size: 20, + blob_size: blob.len(), + })?; + return Ok(SpanDigest(bytes)); + } +} + +impl ToSql for SpanDigest { + fn to_sql(&self) -> rusqlite::Result { + self.0.to_sql() + } +} + +impl From<&'_ str> for SpanDigest { + fn from(value: &'_ str) -> Self { + let mut sha1 = Sha1::new(); + sha1.update(value); + Self(sha1.finalize().into()) + } +} + +#[derive(Debug, PartialEq, Clone)] +pub struct Span { + pub name: String, + pub range: Range, + pub content: String, + pub embedding: Option, + pub digest: SpanDigest, + pub token_count: usize, +} + +const CODE_CONTEXT_TEMPLATE: &str = + "The below code snippet is from file ''\n\n```\n\n```"; +const ENTIRE_FILE_TEMPLATE: &str = + "The below snippet is from file ''\n\n```\n\n```"; +const MARKDOWN_CONTEXT_TEMPLATE: &str = "The below file contents is from file ''\n\n"; +pub const PARSEABLE_ENTIRE_FILE_TYPES: &[&str] = &[ + "TOML", "YAML", "CSS", "HEEX", "ERB", "SVELTE", "HTML", "Scheme", +]; + +pub struct CodeContextRetriever { + pub parser: Parser, + pub cursor: QueryCursor, + pub embedding_provider: Arc, +} + +// Every match has an item, this represents the fundamental treesitter symbol and anchors the search +// Every match has one or more 'name' captures. These indicate the display range of the item for deduplication. +// If there are preceeding comments, we track this with a context capture +// If there is a piece that should be collapsed in hierarchical queries, we capture it with a collapse capture +// If there is a piece that should be kept inside a collapsed node, we capture it with a keep capture +#[derive(Debug, Clone)] +pub struct CodeContextMatch { + pub start_col: usize, + pub item_range: Option>, + pub name_range: Option>, + pub context_ranges: Vec>, + pub collapse_ranges: Vec>, +} + +impl CodeContextRetriever { + pub fn new(embedding_provider: Arc) -> Self { + Self { + parser: Parser::new(), + cursor: QueryCursor::new(), + embedding_provider, + } + } + + fn parse_entire_file( + &self, + relative_path: Option<&Path>, + language_name: Arc, + content: &str, + ) -> Result> { + let document_span = ENTIRE_FILE_TEMPLATE + .replace( + "", + &relative_path.map_or(Cow::Borrowed("untitled"), |path| path.to_string_lossy()), + ) + .replace("", language_name.as_ref()) + .replace("", &content); + let digest = SpanDigest::from(document_span.as_str()); + let model = self.embedding_provider.base_model(); + let document_span = model.truncate( + &document_span, + model.capacity()?, + ai::models::TruncationDirection::End, + )?; + let token_count = model.count_tokens(&document_span)?; + + Ok(vec![Span { + range: 0..content.len(), + content: document_span, + embedding: Default::default(), + name: language_name.to_string(), + digest, + token_count, + }]) + } + + fn parse_markdown_file( + &self, + relative_path: Option<&Path>, + content: &str, + ) -> Result> { + let document_span = MARKDOWN_CONTEXT_TEMPLATE + .replace( + "", + &relative_path.map_or(Cow::Borrowed("untitled"), |path| path.to_string_lossy()), + ) + .replace("", &content); + let digest = SpanDigest::from(document_span.as_str()); + + let model = self.embedding_provider.base_model(); + let document_span = model.truncate( + &document_span, + model.capacity()?, + ai::models::TruncationDirection::End, + )?; + let token_count = model.count_tokens(&document_span)?; + + Ok(vec![Span { + range: 0..content.len(), + content: document_span, + embedding: None, + name: "Markdown".to_string(), + digest, + token_count, + }]) + } + + fn get_matches_in_file( + &mut self, + content: &str, + grammar: &Arc, + ) -> Result> { + let embedding_config = grammar + .embedding_config + .as_ref() + .ok_or_else(|| anyhow!("no embedding queries"))?; + self.parser.set_language(grammar.ts_language).unwrap(); + + let tree = self + .parser + .parse(&content, None) + .ok_or_else(|| anyhow!("parsing failed"))?; + + let mut captures: Vec = Vec::new(); + let mut collapse_ranges: Vec> = Vec::new(); + let mut keep_ranges: Vec> = Vec::new(); + for mat in self.cursor.matches( + &embedding_config.query, + tree.root_node(), + content.as_bytes(), + ) { + let mut start_col = 0; + let mut item_range: Option> = None; + let mut name_range: Option> = None; + let mut context_ranges: Vec> = Vec::new(); + collapse_ranges.clear(); + keep_ranges.clear(); + for capture in mat.captures { + if capture.index == embedding_config.item_capture_ix { + item_range = Some(capture.node.byte_range()); + start_col = capture.node.start_position().column; + } else if Some(capture.index) == embedding_config.name_capture_ix { + name_range = Some(capture.node.byte_range()); + } else if Some(capture.index) == embedding_config.context_capture_ix { + context_ranges.push(capture.node.byte_range()); + } else if Some(capture.index) == embedding_config.collapse_capture_ix { + collapse_ranges.push(capture.node.byte_range()); + } else if Some(capture.index) == embedding_config.keep_capture_ix { + keep_ranges.push(capture.node.byte_range()); + } + } + + captures.push(CodeContextMatch { + start_col, + item_range, + name_range, + context_ranges, + collapse_ranges: subtract_ranges(&collapse_ranges, &keep_ranges), + }); + } + Ok(captures) + } + + pub fn parse_file_with_template( + &mut self, + relative_path: Option<&Path>, + content: &str, + language: Arc, + ) -> Result> { + let language_name = language.name(); + + if PARSEABLE_ENTIRE_FILE_TYPES.contains(&language_name.as_ref()) { + return self.parse_entire_file(relative_path, language_name, &content); + } else if ["Markdown", "Plain Text"].contains(&language_name.as_ref()) { + return self.parse_markdown_file(relative_path, &content); + } + + let mut spans = self.parse_file(content, language)?; + for span in &mut spans { + let document_content = CODE_CONTEXT_TEMPLATE + .replace( + "", + &relative_path.map_or(Cow::Borrowed("untitled"), |path| path.to_string_lossy()), + ) + .replace("", language_name.as_ref()) + .replace("item", &span.content); + + let model = self.embedding_provider.base_model(); + let document_content = model.truncate( + &document_content, + model.capacity()?, + TruncationDirection::End, + )?; + let token_count = model.count_tokens(&document_content)?; + + span.content = document_content; + span.token_count = token_count; + } + Ok(spans) + } + + pub fn parse_file(&mut self, content: &str, language: Arc) -> Result> { + let grammar = language + .grammar() + .ok_or_else(|| anyhow!("no grammar for language"))?; + + // Iterate through query matches + let matches = self.get_matches_in_file(content, grammar)?; + + let language_scope = language.default_scope(); + let placeholder = language_scope.collapsed_placeholder(); + + let mut spans = Vec::new(); + let mut collapsed_ranges_within = Vec::new(); + let mut parsed_name_ranges = HashSet::new(); + for (i, context_match) in matches.iter().enumerate() { + // Items which are collapsible but not embeddable have no item range + let item_range = if let Some(item_range) = context_match.item_range.clone() { + item_range + } else { + continue; + }; + + // Checks for deduplication + let name; + if let Some(name_range) = context_match.name_range.clone() { + name = content + .get(name_range.clone()) + .map_or(String::new(), |s| s.to_string()); + if parsed_name_ranges.contains(&name_range) { + continue; + } + parsed_name_ranges.insert(name_range); + } else { + name = String::new(); + } + + collapsed_ranges_within.clear(); + 'outer: for remaining_match in &matches[(i + 1)..] { + for collapsed_range in &remaining_match.collapse_ranges { + if item_range.start <= collapsed_range.start + && item_range.end >= collapsed_range.end + { + collapsed_ranges_within.push(collapsed_range.clone()); + } else { + break 'outer; + } + } + } + + collapsed_ranges_within.sort_by_key(|r| (r.start, Reverse(r.end))); + + let mut span_content = String::new(); + for context_range in &context_match.context_ranges { + add_content_from_range( + &mut span_content, + content, + context_range.clone(), + context_match.start_col, + ); + span_content.push_str("\n"); + } + + let mut offset = item_range.start; + for collapsed_range in &collapsed_ranges_within { + if collapsed_range.start > offset { + add_content_from_range( + &mut span_content, + content, + offset..collapsed_range.start, + context_match.start_col, + ); + offset = collapsed_range.start; + } + + if collapsed_range.end > offset { + span_content.push_str(placeholder); + offset = collapsed_range.end; + } + } + + if offset < item_range.end { + add_content_from_range( + &mut span_content, + content, + offset..item_range.end, + context_match.start_col, + ); + } + + let sha1 = SpanDigest::from(span_content.as_str()); + spans.push(Span { + name, + content: span_content, + range: item_range.clone(), + embedding: None, + digest: sha1, + token_count: 0, + }) + } + + return Ok(spans); + } +} + +pub(crate) fn subtract_ranges( + ranges: &[Range], + ranges_to_subtract: &[Range], +) -> Vec> { + let mut result = Vec::new(); + + let mut ranges_to_subtract = ranges_to_subtract.iter().peekable(); + + for range in ranges { + let mut offset = range.start; + + while offset < range.end { + if let Some(range_to_subtract) = ranges_to_subtract.peek() { + if offset < range_to_subtract.start { + let next_offset = cmp::min(range_to_subtract.start, range.end); + result.push(offset..next_offset); + offset = next_offset; + } else { + let next_offset = cmp::min(range_to_subtract.end, range.end); + offset = next_offset; + } + + if offset >= range_to_subtract.end { + ranges_to_subtract.next(); + } + } else { + result.push(offset..range.end); + offset = range.end; + } + } + } + + result +} + +fn add_content_from_range( + output: &mut String, + content: &str, + range: Range, + start_col: usize, +) { + for mut line in content.get(range.clone()).unwrap_or("").lines() { + for _ in 0..start_col { + if line.starts_with(' ') { + line = &line[1..]; + } else { + break; + } + } + output.push_str(line); + output.push('\n'); + } + output.pop(); +} diff --git a/crates/semantic_index2/src/semantic_index.rs b/crates/semantic_index2/src/semantic_index.rs new file mode 100644 index 0000000000000000000000000000000000000000..0b207b0bf68b3c504050d62c7b60a99d5dbb5804 --- /dev/null +++ b/crates/semantic_index2/src/semantic_index.rs @@ -0,0 +1,1280 @@ +mod db; +mod embedding_queue; +mod parsing; +pub mod semantic_index_settings; + +#[cfg(test)] +mod semantic_index_tests; + +use crate::semantic_index_settings::SemanticIndexSettings; +use ai::embedding::{Embedding, EmbeddingProvider}; +use ai::providers::open_ai::OpenAIEmbeddingProvider; +use anyhow::{anyhow, Context as _, Result}; +use collections::{BTreeMap, HashMap, HashSet}; +use db::VectorDatabase; +use embedding_queue::{EmbeddingQueue, FileToEmbed}; +use futures::{future, FutureExt, StreamExt}; +use gpui::{ + AppContext, AsyncAppContext, BorrowWindow, Context, Model, ModelContext, Task, ViewContext, + WeakModel, +}; +use language::{Anchor, Bias, Buffer, Language, LanguageRegistry}; +use lazy_static::lazy_static; +use ordered_float::OrderedFloat; +use parking_lot::Mutex; +use parsing::{CodeContextRetriever, Span, SpanDigest, PARSEABLE_ENTIRE_FILE_TYPES}; +use postage::watch; +use project::{Fs, PathChange, Project, ProjectEntryId, Worktree, WorktreeId}; +use settings::Settings; +use smol::channel; +use std::{ + cmp::Reverse, + env, + future::Future, + mem, + ops::Range, + path::{Path, PathBuf}, + sync::{Arc, Weak}, + time::{Duration, Instant, SystemTime}, +}; +use util::paths::PathMatcher; +use util::{channel::RELEASE_CHANNEL_NAME, http::HttpClient, paths::EMBEDDINGS_DIR, ResultExt}; +use workspace::Workspace; + +const SEMANTIC_INDEX_VERSION: usize = 11; +const BACKGROUND_INDEXING_DELAY: Duration = Duration::from_secs(5 * 60); +const EMBEDDING_QUEUE_FLUSH_TIMEOUT: Duration = Duration::from_millis(250); + +lazy_static! { + static ref OPENAI_API_KEY: Option = env::var("OPENAI_API_KEY").ok(); +} + +pub fn init( + fs: Arc, + http_client: Arc, + language_registry: Arc, + cx: &mut AppContext, +) { + SemanticIndexSettings::register(cx); + + let db_file_path = EMBEDDINGS_DIR + .join(Path::new(RELEASE_CHANNEL_NAME.as_str())) + .join("embeddings_db"); + + cx.observe_new_views( + |workspace: &mut Workspace, cx: &mut ViewContext| { + let Some(semantic_index) = SemanticIndex::global(cx) else { + return; + }; + let project = workspace.project().clone(); + + if project.read(cx).is_local() { + cx.app_mut() + .spawn(|mut cx| async move { + let previously_indexed = semantic_index + .update(&mut cx, |index, cx| { + index.project_previously_indexed(&project, cx) + })? + .await?; + if previously_indexed { + semantic_index + .update(&mut cx, |index, cx| index.index_project(project, cx))? + .await?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + }, + ) + .detach(); + + cx.spawn(move |cx| async move { + let semantic_index = SemanticIndex::new( + fs, + db_file_path, + Arc::new(OpenAIEmbeddingProvider::new( + http_client, + cx.background_executor().clone(), + )), + language_registry, + cx.clone(), + ) + .await?; + + cx.update(|cx| cx.set_global(semantic_index.clone()))?; + + anyhow::Ok(()) + }) + .detach(); +} + +#[derive(Copy, Clone, Debug)] +pub enum SemanticIndexStatus { + NotAuthenticated, + NotIndexed, + Indexed, + Indexing { + remaining_files: usize, + rate_limit_expiry: Option, + }, +} + +pub struct SemanticIndex { + fs: Arc, + db: VectorDatabase, + embedding_provider: Arc, + language_registry: Arc, + parsing_files_tx: channel::Sender<(Arc>, PendingFile)>, + _embedding_task: Task<()>, + _parsing_files_tasks: Vec>, + projects: HashMap, ProjectState>, +} + +struct ProjectState { + worktrees: HashMap, + pending_file_count_rx: watch::Receiver, + pending_file_count_tx: Arc>>, + pending_index: usize, + _subscription: gpui::Subscription, + _observe_pending_file_count: Task<()>, +} + +enum WorktreeState { + Registering(RegisteringWorktreeState), + Registered(RegisteredWorktreeState), +} + +impl WorktreeState { + fn is_registered(&self) -> bool { + matches!(self, Self::Registered(_)) + } + + fn paths_changed( + &mut self, + changes: Arc<[(Arc, ProjectEntryId, PathChange)]>, + worktree: &Worktree, + ) { + let changed_paths = match self { + Self::Registering(state) => &mut state.changed_paths, + Self::Registered(state) => &mut state.changed_paths, + }; + + for (path, entry_id, change) in changes.iter() { + let Some(entry) = worktree.entry_for_id(*entry_id) else { + continue; + }; + if entry.is_ignored || entry.is_symlink || entry.is_external || entry.is_dir() { + continue; + } + changed_paths.insert( + path.clone(), + ChangedPathInfo { + mtime: entry.mtime, + is_deleted: *change == PathChange::Removed, + }, + ); + } + } +} + +struct RegisteringWorktreeState { + changed_paths: BTreeMap, ChangedPathInfo>, + done_rx: watch::Receiver>, + _registration: Task<()>, +} + +impl RegisteringWorktreeState { + fn done(&self) -> impl Future { + let mut done_rx = self.done_rx.clone(); + async move { + while let Some(result) = done_rx.next().await { + if result.is_some() { + break; + } + } + } + } +} + +struct RegisteredWorktreeState { + db_id: i64, + changed_paths: BTreeMap, ChangedPathInfo>, +} + +struct ChangedPathInfo { + mtime: SystemTime, + is_deleted: bool, +} + +#[derive(Clone)] +pub struct JobHandle { + /// The outer Arc is here to count the clones of a JobHandle instance; + /// when the last handle to a given job is dropped, we decrement a counter (just once). + tx: Arc>>>, +} + +impl JobHandle { + fn new(tx: &Arc>>) -> Self { + *tx.lock().borrow_mut() += 1; + Self { + tx: Arc::new(Arc::downgrade(&tx)), + } + } +} + +impl ProjectState { + fn new(subscription: gpui::Subscription, cx: &mut ModelContext) -> Self { + let (pending_file_count_tx, pending_file_count_rx) = watch::channel_with(0); + let pending_file_count_tx = Arc::new(Mutex::new(pending_file_count_tx)); + Self { + worktrees: Default::default(), + pending_file_count_rx: pending_file_count_rx.clone(), + pending_file_count_tx, + pending_index: 0, + _subscription: subscription, + _observe_pending_file_count: cx.spawn({ + let mut pending_file_count_rx = pending_file_count_rx.clone(); + |this, mut cx| async move { + while let Some(_) = pending_file_count_rx.next().await { + if this.update(&mut cx, |_, cx| cx.notify()).is_err() { + break; + } + } + } + }), + } + } + + fn worktree_id_for_db_id(&self, id: i64) -> Option { + self.worktrees + .iter() + .find_map(|(worktree_id, worktree_state)| match worktree_state { + WorktreeState::Registered(state) if state.db_id == id => Some(*worktree_id), + _ => None, + }) + } +} + +#[derive(Clone)] +pub struct PendingFile { + worktree_db_id: i64, + relative_path: Arc, + absolute_path: PathBuf, + language: Option>, + modified_time: SystemTime, + job_handle: JobHandle, +} + +#[derive(Clone)] +pub struct SearchResult { + pub buffer: Model, + pub range: Range, + pub similarity: OrderedFloat, +} + +impl SemanticIndex { + pub fn global(cx: &mut AppContext) -> Option> { + if cx.has_global::>() { + Some(cx.global::>().clone()) + } else { + None + } + } + + pub fn authenticate(&mut self, cx: &mut AppContext) -> bool { + if !self.embedding_provider.has_credentials() { + self.embedding_provider.retrieve_credentials(cx); + } else { + return true; + } + + self.embedding_provider.has_credentials() + } + + pub fn is_authenticated(&self) -> bool { + self.embedding_provider.has_credentials() + } + + pub fn enabled(cx: &AppContext) -> bool { + SemanticIndexSettings::get_global(cx).enabled + } + + pub fn status(&self, project: &Model) -> SemanticIndexStatus { + if !self.is_authenticated() { + return SemanticIndexStatus::NotAuthenticated; + } + + if let Some(project_state) = self.projects.get(&project.downgrade()) { + if project_state + .worktrees + .values() + .all(|worktree| worktree.is_registered()) + && project_state.pending_index == 0 + { + SemanticIndexStatus::Indexed + } else { + SemanticIndexStatus::Indexing { + remaining_files: project_state.pending_file_count_rx.borrow().clone(), + rate_limit_expiry: self.embedding_provider.rate_limit_expiration(), + } + } + } else { + SemanticIndexStatus::NotIndexed + } + } + + pub async fn new( + fs: Arc, + database_path: PathBuf, + embedding_provider: Arc, + language_registry: Arc, + mut cx: AsyncAppContext, + ) -> Result> { + let t0 = Instant::now(); + let database_path = Arc::from(database_path); + let db = VectorDatabase::new(fs.clone(), database_path, cx.background_executor().clone()) + .await?; + + log::trace!( + "db initialization took {:?} milliseconds", + t0.elapsed().as_millis() + ); + + cx.build_model(|cx| { + let t0 = Instant::now(); + let embedding_queue = + EmbeddingQueue::new(embedding_provider.clone(), cx.background_executor().clone()); + let _embedding_task = cx.background_executor().spawn({ + let embedded_files = embedding_queue.finished_files(); + let db = db.clone(); + async move { + while let Ok(file) = embedded_files.recv().await { + db.insert_file(file.worktree_id, file.path, file.mtime, file.spans) + .await + .log_err(); + } + } + }); + + // Parse files into embeddable spans. + let (parsing_files_tx, parsing_files_rx) = + channel::unbounded::<(Arc>, PendingFile)>(); + let embedding_queue = Arc::new(Mutex::new(embedding_queue)); + let mut _parsing_files_tasks = Vec::new(); + for _ in 0..cx.background_executor().num_cpus() { + let fs = fs.clone(); + let mut parsing_files_rx = parsing_files_rx.clone(); + let embedding_provider = embedding_provider.clone(); + let embedding_queue = embedding_queue.clone(); + let background = cx.background_executor().clone(); + _parsing_files_tasks.push(cx.background_executor().spawn(async move { + let mut retriever = CodeContextRetriever::new(embedding_provider.clone()); + loop { + let mut timer = background.timer(EMBEDDING_QUEUE_FLUSH_TIMEOUT).fuse(); + let mut next_file_to_parse = parsing_files_rx.next().fuse(); + futures::select_biased! { + next_file_to_parse = next_file_to_parse => { + if let Some((embeddings_for_digest, pending_file)) = next_file_to_parse { + Self::parse_file( + &fs, + pending_file, + &mut retriever, + &embedding_queue, + &embeddings_for_digest, + ) + .await + } else { + break; + } + }, + _ = timer => { + embedding_queue.lock().flush(); + } + } + } + })); + } + + log::trace!( + "semantic index task initialization took {:?} milliseconds", + t0.elapsed().as_millis() + ); + Self { + fs, + db, + embedding_provider, + language_registry, + parsing_files_tx, + _embedding_task, + _parsing_files_tasks, + projects: Default::default(), + } + }) + } + + async fn parse_file( + fs: &Arc, + pending_file: PendingFile, + retriever: &mut CodeContextRetriever, + embedding_queue: &Arc>, + embeddings_for_digest: &HashMap, + ) { + let Some(language) = pending_file.language else { + return; + }; + + if let Some(content) = fs.load(&pending_file.absolute_path).await.log_err() { + if let Some(mut spans) = retriever + .parse_file_with_template(Some(&pending_file.relative_path), &content, language) + .log_err() + { + log::trace!( + "parsed path {:?}: {} spans", + pending_file.relative_path, + spans.len() + ); + + for span in &mut spans { + if let Some(embedding) = embeddings_for_digest.get(&span.digest) { + span.embedding = Some(embedding.to_owned()); + } + } + + embedding_queue.lock().push(FileToEmbed { + worktree_id: pending_file.worktree_db_id, + path: pending_file.relative_path, + mtime: pending_file.modified_time, + job_handle: pending_file.job_handle, + spans, + }); + } + } + } + + pub fn project_previously_indexed( + &mut self, + project: &Model, + cx: &mut ModelContext, + ) -> Task> { + let worktrees_indexed_previously = project + .read(cx) + .worktrees() + .map(|worktree| { + self.db + .worktree_previously_indexed(&worktree.read(cx).abs_path()) + }) + .collect::>(); + cx.spawn(|_, _cx| async move { + let worktree_indexed_previously = + futures::future::join_all(worktrees_indexed_previously).await; + + Ok(worktree_indexed_previously + .iter() + .filter(|worktree| worktree.is_ok()) + .all(|v| v.as_ref().log_err().is_some_and(|v| v.to_owned()))) + }) + } + + fn project_entries_changed( + &mut self, + project: Model, + worktree_id: WorktreeId, + changes: Arc<[(Arc, ProjectEntryId, PathChange)]>, + cx: &mut ModelContext, + ) { + let Some(worktree) = project.read(cx).worktree_for_id(worktree_id.clone(), cx) else { + return; + }; + let project = project.downgrade(); + let Some(project_state) = self.projects.get_mut(&project) else { + return; + }; + + let worktree = worktree.read(cx); + let worktree_state = + if let Some(worktree_state) = project_state.worktrees.get_mut(&worktree_id) { + worktree_state + } else { + return; + }; + worktree_state.paths_changed(changes, worktree); + if let WorktreeState::Registered(_) = worktree_state { + cx.spawn(|this, mut cx| async move { + cx.background_executor() + .timer(BACKGROUND_INDEXING_DELAY) + .await; + if let Some((this, project)) = this.upgrade().zip(project.upgrade()) { + this.update(&mut cx, |this, cx| { + this.index_project(project, cx).detach_and_log_err(cx) + })?; + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + } + } + + fn register_worktree( + &mut self, + project: Model, + worktree: Model, + cx: &mut ModelContext, + ) { + let project = project.downgrade(); + let project_state = if let Some(project_state) = self.projects.get_mut(&project) { + project_state + } else { + return; + }; + let worktree = if let Some(worktree) = worktree.read(cx).as_local() { + worktree + } else { + return; + }; + let worktree_abs_path = worktree.abs_path().clone(); + let scan_complete = worktree.scan_complete(); + let worktree_id = worktree.id(); + let db = self.db.clone(); + let language_registry = self.language_registry.clone(); + let (mut done_tx, done_rx) = watch::channel(); + let registration = cx.spawn(|this, mut cx| { + async move { + let register = async { + scan_complete.await; + let db_id = db.find_or_create_worktree(worktree_abs_path).await?; + let mut file_mtimes = db.get_file_mtimes(db_id).await?; + let worktree = if let Some(project) = project.upgrade() { + project + .read_with(&cx, |project, cx| project.worktree_for_id(worktree_id, cx)) + .ok() + .flatten() + .context("worktree not found")? + } else { + return anyhow::Ok(()); + }; + let worktree = worktree.read_with(&cx, |worktree, _| worktree.snapshot())?; + let mut changed_paths = cx + .background_executor() + .spawn(async move { + let mut changed_paths = BTreeMap::new(); + for file in worktree.files(false, 0) { + let absolute_path = worktree.absolutize(&file.path); + + if file.is_external || file.is_ignored || file.is_symlink { + continue; + } + + if let Ok(language) = language_registry + .language_for_file(&absolute_path, None) + .await + { + // Test if file is valid parseable file + if !PARSEABLE_ENTIRE_FILE_TYPES + .contains(&language.name().as_ref()) + && &language.name().as_ref() != &"Markdown" + && language + .grammar() + .and_then(|grammar| grammar.embedding_config.as_ref()) + .is_none() + { + continue; + } + + let stored_mtime = file_mtimes.remove(&file.path.to_path_buf()); + let already_stored = stored_mtime + .map_or(false, |existing_mtime| { + existing_mtime == file.mtime + }); + + if !already_stored { + changed_paths.insert( + file.path.clone(), + ChangedPathInfo { + mtime: file.mtime, + is_deleted: false, + }, + ); + } + } + } + + // Clean up entries from database that are no longer in the worktree. + for (path, mtime) in file_mtimes { + changed_paths.insert( + path.into(), + ChangedPathInfo { + mtime, + is_deleted: true, + }, + ); + } + + anyhow::Ok(changed_paths) + }) + .await?; + this.update(&mut cx, |this, cx| { + let project_state = this + .projects + .get_mut(&project) + .context("project not registered")?; + let project = project.upgrade().context("project was dropped")?; + + if let Some(WorktreeState::Registering(state)) = + project_state.worktrees.remove(&worktree_id) + { + changed_paths.extend(state.changed_paths); + } + project_state.worktrees.insert( + worktree_id, + WorktreeState::Registered(RegisteredWorktreeState { + db_id, + changed_paths, + }), + ); + this.index_project(project, cx).detach_and_log_err(cx); + + anyhow::Ok(()) + })??; + + anyhow::Ok(()) + }; + + if register.await.log_err().is_none() { + // Stop tracking this worktree if the registration failed. + this.update(&mut cx, |this, _| { + this.projects.get_mut(&project).map(|project_state| { + project_state.worktrees.remove(&worktree_id); + }); + }) + .ok(); + } + + *done_tx.borrow_mut() = Some(()); + } + }); + project_state.worktrees.insert( + worktree_id, + WorktreeState::Registering(RegisteringWorktreeState { + changed_paths: Default::default(), + done_rx, + _registration: registration, + }), + ); + } + + fn project_worktrees_changed(&mut self, project: Model, cx: &mut ModelContext) { + let project_state = if let Some(project_state) = self.projects.get_mut(&project.downgrade()) + { + project_state + } else { + return; + }; + + let mut worktrees = project + .read(cx) + .worktrees() + .filter(|worktree| worktree.read(cx).is_local()) + .collect::>(); + let worktree_ids = worktrees + .iter() + .map(|worktree| worktree.read(cx).id()) + .collect::>(); + + // Remove worktrees that are no longer present + project_state + .worktrees + .retain(|worktree_id, _| worktree_ids.contains(worktree_id)); + + // Register new worktrees + worktrees.retain(|worktree| { + let worktree_id = worktree.read(cx).id(); + !project_state.worktrees.contains_key(&worktree_id) + }); + for worktree in worktrees { + self.register_worktree(project.clone(), worktree, cx); + } + } + + pub fn pending_file_count(&self, project: &Model) -> Option> { + Some( + self.projects + .get(&project.downgrade())? + .pending_file_count_rx + .clone(), + ) + } + + pub fn search_project( + &mut self, + project: Model, + query: String, + limit: usize, + includes: Vec, + excludes: Vec, + cx: &mut ModelContext, + ) -> Task>> { + if query.is_empty() { + return Task::ready(Ok(Vec::new())); + } + + let index = self.index_project(project.clone(), cx); + let embedding_provider = self.embedding_provider.clone(); + + cx.spawn(|this, mut cx| async move { + index.await?; + let t0 = Instant::now(); + + let query = embedding_provider + .embed_batch(vec![query]) + .await? + .pop() + .context("could not embed query")?; + log::trace!("Embedding Search Query: {:?}ms", t0.elapsed().as_millis()); + + let search_start = Instant::now(); + let modified_buffer_results = this.update(&mut cx, |this, cx| { + this.search_modified_buffers( + &project, + query.clone(), + limit, + &includes, + &excludes, + cx, + ) + })?; + let file_results = this.update(&mut cx, |this, cx| { + this.search_files(project, query, limit, includes, excludes, cx) + })?; + let (modified_buffer_results, file_results) = + futures::join!(modified_buffer_results, file_results); + + // Weave together the results from modified buffers and files. + let mut results = Vec::new(); + let mut modified_buffers = HashSet::default(); + for result in modified_buffer_results.log_err().unwrap_or_default() { + modified_buffers.insert(result.buffer.clone()); + results.push(result); + } + for result in file_results.log_err().unwrap_or_default() { + if !modified_buffers.contains(&result.buffer) { + results.push(result); + } + } + results.sort_by_key(|result| Reverse(result.similarity)); + results.truncate(limit); + log::trace!("Semantic search took {:?}", search_start.elapsed()); + Ok(results) + }) + } + + pub fn search_files( + &mut self, + project: Model, + query: Embedding, + limit: usize, + includes: Vec, + excludes: Vec, + cx: &mut ModelContext, + ) -> Task>> { + let db_path = self.db.path().clone(); + let fs = self.fs.clone(); + cx.spawn(|this, mut cx| async move { + let database = VectorDatabase::new( + fs.clone(), + db_path.clone(), + cx.background_executor().clone(), + ) + .await?; + + let worktree_db_ids = this.read_with(&cx, |this, _| { + let project_state = this + .projects + .get(&project.downgrade()) + .context("project was not indexed")?; + let worktree_db_ids = project_state + .worktrees + .values() + .filter_map(|worktree| { + if let WorktreeState::Registered(worktree) = worktree { + Some(worktree.db_id) + } else { + None + } + }) + .collect::>(); + anyhow::Ok(worktree_db_ids) + })??; + + let file_ids = database + .retrieve_included_file_ids(&worktree_db_ids, &includes, &excludes) + .await?; + + let batch_n = cx.background_executor().num_cpus(); + let ids_len = file_ids.clone().len(); + let minimum_batch_size = 50; + + let batch_size = { + let size = ids_len / batch_n; + if size < minimum_batch_size { + minimum_batch_size + } else { + size + } + }; + + let mut batch_results = Vec::new(); + for batch in file_ids.chunks(batch_size) { + let batch = batch.into_iter().map(|v| *v).collect::>(); + let limit = limit.clone(); + let fs = fs.clone(); + let db_path = db_path.clone(); + let query = query.clone(); + if let Some(db) = + VectorDatabase::new(fs, db_path.clone(), cx.background_executor().clone()) + .await + .log_err() + { + batch_results.push(async move { + db.top_k_search(&query, limit, batch.as_slice()).await + }); + } + } + + let batch_results = futures::future::join_all(batch_results).await; + + let mut results = Vec::new(); + for batch_result in batch_results { + if batch_result.is_ok() { + for (id, similarity) in batch_result.unwrap() { + let ix = match results + .binary_search_by_key(&Reverse(similarity), |(_, s)| Reverse(*s)) + { + Ok(ix) => ix, + Err(ix) => ix, + }; + + results.insert(ix, (id, similarity)); + results.truncate(limit); + } + } + } + + let ids = results.iter().map(|(id, _)| *id).collect::>(); + let scores = results + .into_iter() + .map(|(_, score)| score) + .collect::>(); + let spans = database.spans_for_ids(ids.as_slice()).await?; + + let mut tasks = Vec::new(); + let mut ranges = Vec::new(); + let weak_project = project.downgrade(); + project.update(&mut cx, |project, cx| { + let this = this.upgrade().context("index was dropped")?; + for (worktree_db_id, file_path, byte_range) in spans { + let project_state = + if let Some(state) = this.read(cx).projects.get(&weak_project) { + state + } else { + return Err(anyhow!("project not added")); + }; + if let Some(worktree_id) = project_state.worktree_id_for_db_id(worktree_db_id) { + tasks.push(project.open_buffer((worktree_id, file_path), cx)); + ranges.push(byte_range); + } + } + + Ok(()) + })??; + + let buffers = futures::future::join_all(tasks).await; + Ok(buffers + .into_iter() + .zip(ranges) + .zip(scores) + .filter_map(|((buffer, range), similarity)| { + let buffer = buffer.log_err()?; + let range = buffer + .read_with(&cx, |buffer, _| { + let start = buffer.clip_offset(range.start, Bias::Left); + let end = buffer.clip_offset(range.end, Bias::Right); + buffer.anchor_before(start)..buffer.anchor_after(end) + }) + .log_err()?; + Some(SearchResult { + buffer, + range, + similarity, + }) + }) + .collect()) + }) + } + + fn search_modified_buffers( + &self, + project: &Model, + query: Embedding, + limit: usize, + includes: &[PathMatcher], + excludes: &[PathMatcher], + cx: &mut ModelContext, + ) -> Task>> { + let modified_buffers = project + .read(cx) + .opened_buffers() + .into_iter() + .filter_map(|buffer_handle| { + let buffer = buffer_handle.read(cx); + let snapshot = buffer.snapshot(); + let excluded = snapshot.resolve_file_path(cx, false).map_or(false, |path| { + excludes.iter().any(|matcher| matcher.is_match(&path)) + }); + + let included = if includes.len() == 0 { + true + } else { + snapshot.resolve_file_path(cx, false).map_or(false, |path| { + includes.iter().any(|matcher| matcher.is_match(&path)) + }) + }; + + if buffer.is_dirty() && !excluded && included { + Some((buffer_handle, snapshot)) + } else { + None + } + }) + .collect::>(); + + let embedding_provider = self.embedding_provider.clone(); + let fs = self.fs.clone(); + let db_path = self.db.path().clone(); + let background = cx.background_executor().clone(); + cx.background_executor().spawn(async move { + let db = VectorDatabase::new(fs, db_path.clone(), background).await?; + let mut results = Vec::::new(); + + let mut retriever = CodeContextRetriever::new(embedding_provider.clone()); + for (buffer, snapshot) in modified_buffers { + let language = snapshot + .language_at(0) + .cloned() + .unwrap_or_else(|| language::PLAIN_TEXT.clone()); + let mut spans = retriever + .parse_file_with_template(None, &snapshot.text(), language) + .log_err() + .unwrap_or_default(); + if Self::embed_spans(&mut spans, embedding_provider.as_ref(), &db) + .await + .log_err() + .is_some() + { + for span in spans { + let similarity = span.embedding.unwrap().similarity(&query); + let ix = match results + .binary_search_by_key(&Reverse(similarity), |result| { + Reverse(result.similarity) + }) { + Ok(ix) => ix, + Err(ix) => ix, + }; + + let range = { + let start = snapshot.clip_offset(span.range.start, Bias::Left); + let end = snapshot.clip_offset(span.range.end, Bias::Right); + snapshot.anchor_before(start)..snapshot.anchor_after(end) + }; + + results.insert( + ix, + SearchResult { + buffer: buffer.clone(), + range, + similarity, + }, + ); + results.truncate(limit); + } + } + } + + Ok(results) + }) + } + + pub fn index_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if !self.is_authenticated() { + if !self.authenticate(cx) { + return Task::ready(Err(anyhow!("user is not authenticated"))); + } + } + + if !self.projects.contains_key(&project.downgrade()) { + let subscription = cx.subscribe(&project, |this, project, event, cx| match event { + project::Event::WorktreeAdded | project::Event::WorktreeRemoved(_) => { + this.project_worktrees_changed(project.clone(), cx); + } + project::Event::WorktreeUpdatedEntries(worktree_id, changes) => { + this.project_entries_changed(project, *worktree_id, changes.clone(), cx); + } + _ => {} + }); + let project_state = ProjectState::new(subscription, cx); + self.projects.insert(project.downgrade(), project_state); + self.project_worktrees_changed(project.clone(), cx); + } + let project_state = self.projects.get_mut(&project.downgrade()).unwrap(); + project_state.pending_index += 1; + cx.notify(); + + let mut pending_file_count_rx = project_state.pending_file_count_rx.clone(); + let db = self.db.clone(); + let language_registry = self.language_registry.clone(); + let parsing_files_tx = self.parsing_files_tx.clone(); + let worktree_registration = self.wait_for_worktree_registration(&project, cx); + + cx.spawn(|this, mut cx| async move { + worktree_registration.await?; + + let mut pending_files = Vec::new(); + let mut files_to_delete = Vec::new(); + this.update(&mut cx, |this, cx| { + let project_state = this + .projects + .get_mut(&project.downgrade()) + .context("project was dropped")?; + let pending_file_count_tx = &project_state.pending_file_count_tx; + + project_state + .worktrees + .retain(|worktree_id, worktree_state| { + let worktree = if let Some(worktree) = + project.read(cx).worktree_for_id(*worktree_id, cx) + { + worktree + } else { + return false; + }; + let worktree_state = + if let WorktreeState::Registered(worktree_state) = worktree_state { + worktree_state + } else { + return true; + }; + + worktree_state.changed_paths.retain(|path, info| { + if info.is_deleted { + files_to_delete.push((worktree_state.db_id, path.clone())); + } else { + let absolute_path = worktree.read(cx).absolutize(path); + let job_handle = JobHandle::new(pending_file_count_tx); + pending_files.push(PendingFile { + absolute_path, + relative_path: path.clone(), + language: None, + job_handle, + modified_time: info.mtime, + worktree_db_id: worktree_state.db_id, + }); + } + + false + }); + true + }); + + anyhow::Ok(()) + })??; + + cx.background_executor() + .spawn(async move { + for (worktree_db_id, path) in files_to_delete { + db.delete_file(worktree_db_id, path).await.log_err(); + } + + let embeddings_for_digest = { + let mut files = HashMap::default(); + for pending_file in &pending_files { + files + .entry(pending_file.worktree_db_id) + .or_insert(Vec::new()) + .push(pending_file.relative_path.clone()); + } + Arc::new( + db.embeddings_for_files(files) + .await + .log_err() + .unwrap_or_default(), + ) + }; + + for mut pending_file in pending_files { + if let Ok(language) = language_registry + .language_for_file(&pending_file.relative_path, None) + .await + { + if !PARSEABLE_ENTIRE_FILE_TYPES.contains(&language.name().as_ref()) + && &language.name().as_ref() != &"Markdown" + && language + .grammar() + .and_then(|grammar| grammar.embedding_config.as_ref()) + .is_none() + { + continue; + } + pending_file.language = Some(language); + } + parsing_files_tx + .try_send((embeddings_for_digest.clone(), pending_file)) + .ok(); + } + + // Wait until we're done indexing. + while let Some(count) = pending_file_count_rx.next().await { + if count == 0 { + break; + } + } + }) + .await; + + this.update(&mut cx, |this, cx| { + let project_state = this + .projects + .get_mut(&project.downgrade()) + .context("project was dropped")?; + project_state.pending_index -= 1; + cx.notify(); + anyhow::Ok(()) + })??; + + Ok(()) + }) + } + + fn wait_for_worktree_registration( + &self, + project: &Model, + cx: &mut ModelContext, + ) -> Task> { + let project = project.downgrade(); + cx.spawn(|this, cx| async move { + loop { + let mut pending_worktrees = Vec::new(); + this.upgrade() + .context("semantic index dropped")? + .read_with(&cx, |this, _| { + if let Some(project) = this.projects.get(&project) { + for worktree in project.worktrees.values() { + if let WorktreeState::Registering(worktree) = worktree { + pending_worktrees.push(worktree.done()); + } + } + } + })?; + + if pending_worktrees.is_empty() { + break; + } else { + future::join_all(pending_worktrees).await; + } + } + Ok(()) + }) + } + + async fn embed_spans( + spans: &mut [Span], + embedding_provider: &dyn EmbeddingProvider, + db: &VectorDatabase, + ) -> Result<()> { + let mut batch = Vec::new(); + let mut batch_tokens = 0; + let mut embeddings = Vec::new(); + + let digests = spans + .iter() + .map(|span| span.digest.clone()) + .collect::>(); + let embeddings_for_digests = db + .embeddings_for_digests(digests) + .await + .log_err() + .unwrap_or_default(); + + for span in &*spans { + if embeddings_for_digests.contains_key(&span.digest) { + continue; + }; + + if batch_tokens + span.token_count > embedding_provider.max_tokens_per_batch() { + let batch_embeddings = embedding_provider + .embed_batch(mem::take(&mut batch)) + .await?; + embeddings.extend(batch_embeddings); + batch_tokens = 0; + } + + batch_tokens += span.token_count; + batch.push(span.content.clone()); + } + + if !batch.is_empty() { + let batch_embeddings = embedding_provider + .embed_batch(mem::take(&mut batch)) + .await?; + + embeddings.extend(batch_embeddings); + } + + let mut embeddings = embeddings.into_iter(); + for span in spans { + let embedding = if let Some(embedding) = embeddings_for_digests.get(&span.digest) { + Some(embedding.clone()) + } else { + embeddings.next() + }; + let embedding = embedding.context("failed to embed spans")?; + span.embedding = Some(embedding); + } + Ok(()) + } +} + +impl Drop for JobHandle { + fn drop(&mut self) { + if let Some(inner) = Arc::get_mut(&mut self.tx) { + // This is the last instance of the JobHandle (regardless of it's origin - whether it was cloned or not) + if let Some(tx) = inner.upgrade() { + let mut tx = tx.lock(); + *tx.borrow_mut() -= 1; + } + } + } +} + +#[cfg(test)] +mod tests { + + use super::*; + #[test] + fn test_job_handle() { + let (job_count_tx, job_count_rx) = watch::channel_with(0); + let tx = Arc::new(Mutex::new(job_count_tx)); + let job_handle = JobHandle::new(&tx); + + assert_eq!(1, *job_count_rx.borrow()); + let new_job_handle = job_handle.clone(); + assert_eq!(1, *job_count_rx.borrow()); + drop(job_handle); + assert_eq!(1, *job_count_rx.borrow()); + drop(new_job_handle); + assert_eq!(0, *job_count_rx.borrow()); + } +} diff --git a/crates/semantic_index2/src/semantic_index_settings.rs b/crates/semantic_index2/src/semantic_index_settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..306a38fa9c2ec52f5a69d27898cc9fccc1af956c --- /dev/null +++ b/crates/semantic_index2/src/semantic_index_settings.rs @@ -0,0 +1,28 @@ +use anyhow; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::Settings; + +#[derive(Deserialize, Debug)] +pub struct SemanticIndexSettings { + pub enabled: bool, +} + +#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] +pub struct SemanticIndexSettingsContent { + pub enabled: Option, +} + +impl Settings for SemanticIndexSettings { + const KEY: Option<&'static str> = Some("semantic_index"); + + type FileContent = SemanticIndexSettingsContent; + + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &mut gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} diff --git a/crates/semantic_index2/src/semantic_index_tests.rs b/crates/semantic_index2/src/semantic_index_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..ced08f4cbc30a991bfad0577af24f96c8ff81d8b --- /dev/null +++ b/crates/semantic_index2/src/semantic_index_tests.rs @@ -0,0 +1,1697 @@ +use crate::{ + embedding_queue::EmbeddingQueue, + parsing::{subtract_ranges, CodeContextRetriever, Span, SpanDigest}, + semantic_index_settings::SemanticIndexSettings, + FileToEmbed, JobHandle, SearchResult, SemanticIndex, EMBEDDING_QUEUE_FLUSH_TIMEOUT, +}; +use ai::test::FakeEmbeddingProvider; + +use gpui::{Task, TestAppContext}; +use language::{Language, LanguageConfig, LanguageRegistry, ToOffset}; +use parking_lot::Mutex; +use pretty_assertions::assert_eq; +use project::{project_settings::ProjectSettings, FakeFs, Fs, Project}; +use rand::{rngs::StdRng, Rng}; +use serde_json::json; +use settings::{Settings, SettingsStore}; +use std::{path::Path, sync::Arc, time::SystemTime}; +use unindent::Unindent; +use util::{paths::PathMatcher, RandomCharIter}; + +#[ctor::ctor] +fn init_logger() { + if std::env::var("RUST_LOG").is_ok() { + env_logger::init(); + } +} + +#[gpui::test] +async fn test_semantic_index(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/the-root", + json!({ + "src": { + "file1.rs": " + fn aaa() { + println!(\"aaaaaaaaaaaa!\"); + } + + fn zzzzz() { + println!(\"SLEEPING\"); + } + ".unindent(), + "file2.rs": " + fn bbb() { + println!(\"bbbbbbbbbbbbb!\"); + } + struct pqpqpqp {} + ".unindent(), + "file3.toml": " + ZZZZZZZZZZZZZZZZZZ = 5 + ".unindent(), + } + }), + ) + .await; + + let languages = Arc::new(LanguageRegistry::new(Task::ready(()))); + let rust_language = rust_lang(); + let toml_language = toml_lang(); + languages.add(rust_language); + languages.add(toml_language); + + let db_dir = tempdir::TempDir::new("vector-store").unwrap(); + let db_path = db_dir.path().join("db.sqlite"); + + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let semantic_index = SemanticIndex::new( + fs.clone(), + db_path, + embedding_provider.clone(), + languages, + cx.to_async(), + ) + .await + .unwrap(); + + let project = Project::test(fs.clone(), ["/the-root".as_ref()], cx).await; + + let search_results = semantic_index.update(cx, |store, cx| { + store.search_project( + project.clone(), + "aaaaaabbbbzz".to_string(), + 5, + vec![], + vec![], + cx, + ) + }); + let pending_file_count = + semantic_index.read_with(cx, |index, _| index.pending_file_count(&project).unwrap()); + cx.background_executor.run_until_parked(); + assert_eq!(*pending_file_count.borrow(), 3); + cx.background_executor + .advance_clock(EMBEDDING_QUEUE_FLUSH_TIMEOUT); + assert_eq!(*pending_file_count.borrow(), 0); + + let search_results = search_results.await.unwrap(); + assert_search_results( + &search_results, + &[ + (Path::new("src/file1.rs").into(), 0), + (Path::new("src/file2.rs").into(), 0), + (Path::new("src/file3.toml").into(), 0), + (Path::new("src/file1.rs").into(), 45), + (Path::new("src/file2.rs").into(), 45), + ], + cx, + ); + + // Test Include Files Functonality + let include_files = vec![PathMatcher::new("*.rs").unwrap()]; + let exclude_files = vec![PathMatcher::new("*.rs").unwrap()]; + let rust_only_search_results = semantic_index + .update(cx, |store, cx| { + store.search_project( + project.clone(), + "aaaaaabbbbzz".to_string(), + 5, + include_files, + vec![], + cx, + ) + }) + .await + .unwrap(); + + assert_search_results( + &rust_only_search_results, + &[ + (Path::new("src/file1.rs").into(), 0), + (Path::new("src/file2.rs").into(), 0), + (Path::new("src/file1.rs").into(), 45), + (Path::new("src/file2.rs").into(), 45), + ], + cx, + ); + + let no_rust_search_results = semantic_index + .update(cx, |store, cx| { + store.search_project( + project.clone(), + "aaaaaabbbbzz".to_string(), + 5, + vec![], + exclude_files, + cx, + ) + }) + .await + .unwrap(); + + assert_search_results( + &no_rust_search_results, + &[(Path::new("src/file3.toml").into(), 0)], + cx, + ); + + fs.save( + "/the-root/src/file2.rs".as_ref(), + &" + fn dddd() { println!(\"ddddd!\"); } + struct pqpqpqp {} + " + .unindent() + .into(), + Default::default(), + ) + .await + .unwrap(); + + cx.background_executor + .advance_clock(EMBEDDING_QUEUE_FLUSH_TIMEOUT); + + let prev_embedding_count = embedding_provider.embedding_count(); + let index = semantic_index.update(cx, |store, cx| store.index_project(project.clone(), cx)); + cx.background_executor.run_until_parked(); + assert_eq!(*pending_file_count.borrow(), 1); + cx.background_executor + .advance_clock(EMBEDDING_QUEUE_FLUSH_TIMEOUT); + assert_eq!(*pending_file_count.borrow(), 0); + index.await.unwrap(); + + assert_eq!( + embedding_provider.embedding_count() - prev_embedding_count, + 1 + ); +} + +#[gpui::test(iterations = 10)] +async fn test_embedding_batching(cx: &mut TestAppContext, mut rng: StdRng) { + let (outstanding_job_count, _) = postage::watch::channel_with(0); + let outstanding_job_count = Arc::new(Mutex::new(outstanding_job_count)); + + let files = (1..=3) + .map(|file_ix| FileToEmbed { + worktree_id: 5, + path: Path::new(&format!("path-{file_ix}")).into(), + mtime: SystemTime::now(), + spans: (0..rng.gen_range(4..22)) + .map(|document_ix| { + let content_len = rng.gen_range(10..100); + let content = RandomCharIter::new(&mut rng) + .with_simple_text() + .take(content_len) + .collect::(); + let digest = SpanDigest::from(content.as_str()); + Span { + range: 0..10, + embedding: None, + name: format!("document {document_ix}"), + content, + digest, + token_count: rng.gen_range(10..30), + } + }) + .collect(), + job_handle: JobHandle::new(&outstanding_job_count), + }) + .collect::>(); + + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + + let mut queue = EmbeddingQueue::new(embedding_provider.clone(), cx.background_executor.clone()); + for file in &files { + queue.push(file.clone()); + } + queue.flush(); + + cx.background_executor.run_until_parked(); + let finished_files = queue.finished_files(); + let mut embedded_files: Vec<_> = files + .iter() + .map(|_| finished_files.try_recv().expect("no finished file")) + .collect(); + + let expected_files: Vec<_> = files + .iter() + .map(|file| { + let mut file = file.clone(); + for doc in &mut file.spans { + doc.embedding = Some(embedding_provider.embed_sync(doc.content.as_ref())); + } + file + }) + .collect(); + + embedded_files.sort_by_key(|f| f.path.clone()); + + assert_eq!(embedded_files, expected_files); +} + +#[track_caller] +fn assert_search_results( + actual: &[SearchResult], + expected: &[(Arc, usize)], + cx: &TestAppContext, +) { + let actual = actual + .iter() + .map(|search_result| { + search_result.buffer.read_with(cx, |buffer, _cx| { + ( + buffer.file().unwrap().path().clone(), + search_result.range.start.to_offset(buffer), + ) + }) + }) + .collect::>(); + assert_eq!(actual, expected); +} + +#[gpui::test] +async fn test_code_context_retrieval_rust() { + let language = rust_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = " + /// A doc comment + /// that spans multiple lines + #[gpui::test] + fn a() { + b + } + + impl C for D { + } + + impl E { + // This is also a preceding comment + pub fn function_1() -> Option<()> { + unimplemented!(); + } + + // This is a preceding comment + fn function_2() -> Result<()> { + unimplemented!(); + } + } + + #[derive(Clone)] + struct D { + name: String + } + " + .unindent(); + + let documents = retriever.parse_file(&text, language).unwrap(); + + assert_documents_eq( + &documents, + &[ + ( + " + /// A doc comment + /// that spans multiple lines + #[gpui::test] + fn a() { + b + }" + .unindent(), + text.find("fn a").unwrap(), + ), + ( + " + impl C for D { + }" + .unindent(), + text.find("impl C").unwrap(), + ), + ( + " + impl E { + // This is also a preceding comment + pub fn function_1() -> Option<()> { /* ... */ } + + // This is a preceding comment + fn function_2() -> Result<()> { /* ... */ } + }" + .unindent(), + text.find("impl E").unwrap(), + ), + ( + " + // This is also a preceding comment + pub fn function_1() -> Option<()> { + unimplemented!(); + }" + .unindent(), + text.find("pub fn function_1").unwrap(), + ), + ( + " + // This is a preceding comment + fn function_2() -> Result<()> { + unimplemented!(); + }" + .unindent(), + text.find("fn function_2").unwrap(), + ), + ( + " + #[derive(Clone)] + struct D { + name: String + }" + .unindent(), + text.find("struct D").unwrap(), + ), + ], + ); +} + +#[gpui::test] +async fn test_code_context_retrieval_json() { + let language = json_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = r#" + { + "array": [1, 2, 3, 4], + "string": "abcdefg", + "nested_object": { + "array_2": [5, 6, 7, 8], + "string_2": "hijklmnop", + "boolean": true, + "none": null + } + } + "# + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[( + r#" + { + "array": [], + "string": "", + "nested_object": { + "array_2": [], + "string_2": "", + "boolean": true, + "none": null + } + }"# + .unindent(), + text.find("{").unwrap(), + )], + ); + + let text = r#" + [ + { + "name": "somebody", + "age": 42 + }, + { + "name": "somebody else", + "age": 43 + } + ] + "# + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[( + r#" + [{ + "name": "", + "age": 42 + }]"# + .unindent(), + text.find("[").unwrap(), + )], + ); +} + +fn assert_documents_eq( + documents: &[Span], + expected_contents_and_start_offsets: &[(String, usize)], +) { + assert_eq!( + documents + .iter() + .map(|document| (document.content.clone(), document.range.start)) + .collect::>(), + expected_contents_and_start_offsets + ); +} + +#[gpui::test] +async fn test_code_context_retrieval_javascript() { + let language = js_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = " + /* globals importScripts, backend */ + function _authorize() {} + + /** + * Sometimes the frontend build is way faster than backend. + */ + export async function authorizeBank() { + _authorize(pushModal, upgradingAccountId, {}); + } + + export class SettingsPage { + /* This is a test setting */ + constructor(page) { + this.page = page; + } + } + + /* This is a test comment */ + class TestClass {} + + /* Schema for editor_events in Clickhouse. */ + export interface ClickhouseEditorEvent { + installation_id: string + operation: string + } + " + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[ + ( + " + /* globals importScripts, backend */ + function _authorize() {}" + .unindent(), + 37, + ), + ( + " + /** + * Sometimes the frontend build is way faster than backend. + */ + export async function authorizeBank() { + _authorize(pushModal, upgradingAccountId, {}); + }" + .unindent(), + 131, + ), + ( + " + export class SettingsPage { + /* This is a test setting */ + constructor(page) { + this.page = page; + } + }" + .unindent(), + 225, + ), + ( + " + /* This is a test setting */ + constructor(page) { + this.page = page; + }" + .unindent(), + 290, + ), + ( + " + /* This is a test comment */ + class TestClass {}" + .unindent(), + 374, + ), + ( + " + /* Schema for editor_events in Clickhouse. */ + export interface ClickhouseEditorEvent { + installation_id: string + operation: string + }" + .unindent(), + 440, + ), + ], + ) +} + +#[gpui::test] +async fn test_code_context_retrieval_lua() { + let language = lua_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = r#" + -- Creates a new class + -- @param baseclass The Baseclass of this class, or nil. + -- @return A new class reference. + function classes.class(baseclass) + -- Create the class definition and metatable. + local classdef = {} + -- Find the super class, either Object or user-defined. + baseclass = baseclass or classes.Object + -- If this class definition does not know of a function, it will 'look up' to the Baseclass via the __index of the metatable. + setmetatable(classdef, { __index = baseclass }) + -- All class instances have a reference to the class object. + classdef.class = classdef + --- Recursivly allocates the inheritance tree of the instance. + -- @param mastertable The 'root' of the inheritance tree. + -- @return Returns the instance with the allocated inheritance tree. + function classdef.alloc(mastertable) + -- All class instances have a reference to a superclass object. + local instance = { super = baseclass.alloc(mastertable) } + -- Any functions this instance does not know of will 'look up' to the superclass definition. + setmetatable(instance, { __index = classdef, __newindex = mastertable }) + return instance + end + end + "#.unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[ + (r#" + -- Creates a new class + -- @param baseclass The Baseclass of this class, or nil. + -- @return A new class reference. + function classes.class(baseclass) + -- Create the class definition and metatable. + local classdef = {} + -- Find the super class, either Object or user-defined. + baseclass = baseclass or classes.Object + -- If this class definition does not know of a function, it will 'look up' to the Baseclass via the __index of the metatable. + setmetatable(classdef, { __index = baseclass }) + -- All class instances have a reference to the class object. + classdef.class = classdef + --- Recursivly allocates the inheritance tree of the instance. + -- @param mastertable The 'root' of the inheritance tree. + -- @return Returns the instance with the allocated inheritance tree. + function classdef.alloc(mastertable) + --[ ... ]-- + --[ ... ]-- + end + end"#.unindent(), + 114), + (r#" + --- Recursivly allocates the inheritance tree of the instance. + -- @param mastertable The 'root' of the inheritance tree. + -- @return Returns the instance with the allocated inheritance tree. + function classdef.alloc(mastertable) + -- All class instances have a reference to a superclass object. + local instance = { super = baseclass.alloc(mastertable) } + -- Any functions this instance does not know of will 'look up' to the superclass definition. + setmetatable(instance, { __index = classdef, __newindex = mastertable }) + return instance + end"#.unindent(), 809), + ] + ); +} + +#[gpui::test] +async fn test_code_context_retrieval_elixir() { + let language = elixir_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = r#" + defmodule File.Stream do + @moduledoc """ + Defines a `File.Stream` struct returned by `File.stream!/3`. + + The following fields are public: + + * `path` - the file path + * `modes` - the file modes + * `raw` - a boolean indicating if bin functions should be used + * `line_or_bytes` - if reading should read lines or a given number of bytes + * `node` - the node the file belongs to + + """ + + defstruct path: nil, modes: [], line_or_bytes: :line, raw: true, node: nil + + @type t :: %__MODULE__{} + + @doc false + def __build__(path, modes, line_or_bytes) do + raw = :lists.keyfind(:encoding, 1, modes) == false + + modes = + case raw do + true -> + case :lists.keyfind(:read_ahead, 1, modes) do + {:read_ahead, false} -> [:raw | :lists.keydelete(:read_ahead, 1, modes)] + {:read_ahead, _} -> [:raw | modes] + false -> [:raw, :read_ahead | modes] + end + + false -> + modes + end + + %File.Stream{path: path, modes: modes, raw: raw, line_or_bytes: line_or_bytes, node: node()} + + end"# + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[( + r#" + defmodule File.Stream do + @moduledoc """ + Defines a `File.Stream` struct returned by `File.stream!/3`. + + The following fields are public: + + * `path` - the file path + * `modes` - the file modes + * `raw` - a boolean indicating if bin functions should be used + * `line_or_bytes` - if reading should read lines or a given number of bytes + * `node` - the node the file belongs to + + """ + + defstruct path: nil, modes: [], line_or_bytes: :line, raw: true, node: nil + + @type t :: %__MODULE__{} + + @doc false + def __build__(path, modes, line_or_bytes) do + raw = :lists.keyfind(:encoding, 1, modes) == false + + modes = + case raw do + true -> + case :lists.keyfind(:read_ahead, 1, modes) do + {:read_ahead, false} -> [:raw | :lists.keydelete(:read_ahead, 1, modes)] + {:read_ahead, _} -> [:raw | modes] + false -> [:raw, :read_ahead | modes] + end + + false -> + modes + end + + %File.Stream{path: path, modes: modes, raw: raw, line_or_bytes: line_or_bytes, node: node()} + + end"# + .unindent(), + 0, + ),(r#" + @doc false + def __build__(path, modes, line_or_bytes) do + raw = :lists.keyfind(:encoding, 1, modes) == false + + modes = + case raw do + true -> + case :lists.keyfind(:read_ahead, 1, modes) do + {:read_ahead, false} -> [:raw | :lists.keydelete(:read_ahead, 1, modes)] + {:read_ahead, _} -> [:raw | modes] + false -> [:raw, :read_ahead | modes] + end + + false -> + modes + end + + %File.Stream{path: path, modes: modes, raw: raw, line_or_bytes: line_or_bytes, node: node()} + + end"#.unindent(), 574)], + ); +} + +#[gpui::test] +async fn test_code_context_retrieval_cpp() { + let language = cpp_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = " + /** + * @brief Main function + * @returns 0 on exit + */ + int main() { return 0; } + + /** + * This is a test comment + */ + class MyClass { // The class + public: // Access specifier + int myNum; // Attribute (int variable) + string myString; // Attribute (string variable) + }; + + // This is a test comment + enum Color { red, green, blue }; + + /** This is a preceding block comment + * This is the second line + */ + struct { // Structure declaration + int myNum; // Member (int variable) + string myString; // Member (string variable) + } myStructure; + + /** + * @brief Matrix class. + */ + template ::value || std::is_floating_point::value, + bool>::type> + class Matrix2 { + std::vector> _mat; + + public: + /** + * @brief Constructor + * @tparam Integer ensuring integers are being evaluated and not other + * data types. + * @param size denoting the size of Matrix as size x size + */ + template ::value, + Integer>::type> + explicit Matrix(const Integer size) { + for (size_t i = 0; i < size; ++i) { + _mat.emplace_back(std::vector(size, 0)); + } + } + }" + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[ + ( + " + /** + * @brief Main function + * @returns 0 on exit + */ + int main() { return 0; }" + .unindent(), + 54, + ), + ( + " + /** + * This is a test comment + */ + class MyClass { // The class + public: // Access specifier + int myNum; // Attribute (int variable) + string myString; // Attribute (string variable) + }" + .unindent(), + 112, + ), + ( + " + // This is a test comment + enum Color { red, green, blue }" + .unindent(), + 322, + ), + ( + " + /** This is a preceding block comment + * This is the second line + */ + struct { // Structure declaration + int myNum; // Member (int variable) + string myString; // Member (string variable) + } myStructure;" + .unindent(), + 425, + ), + ( + " + /** + * @brief Matrix class. + */ + template ::value || std::is_floating_point::value, + bool>::type> + class Matrix2 { + std::vector> _mat; + + public: + /** + * @brief Constructor + * @tparam Integer ensuring integers are being evaluated and not other + * data types. + * @param size denoting the size of Matrix as size x size + */ + template ::value, + Integer>::type> + explicit Matrix(const Integer size) { + for (size_t i = 0; i < size; ++i) { + _mat.emplace_back(std::vector(size, 0)); + } + } + }" + .unindent(), + 612, + ), + ( + " + explicit Matrix(const Integer size) { + for (size_t i = 0; i < size; ++i) { + _mat.emplace_back(std::vector(size, 0)); + } + }" + .unindent(), + 1226, + ), + ], + ); +} + +#[gpui::test] +async fn test_code_context_retrieval_ruby() { + let language = ruby_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = r#" + # This concern is inspired by "sudo mode" on GitHub. It + # is a way to re-authenticate a user before allowing them + # to see or perform an action. + # + # Add `before_action :require_challenge!` to actions you + # want to protect. + # + # The user will be shown a page to enter the challenge (which + # is either the password, or just the username when no + # password exists). Upon passing, there is a grace period + # during which no challenge will be asked from the user. + # + # Accessing challenge-protected resources during the grace + # period will refresh the grace period. + module ChallengableConcern + extend ActiveSupport::Concern + + CHALLENGE_TIMEOUT = 1.hour.freeze + + def require_challenge! + return if skip_challenge? + + if challenge_passed_recently? + session[:challenge_passed_at] = Time.now.utc + return + end + + @challenge = Form::Challenge.new(return_to: request.url) + + if params.key?(:form_challenge) + if challenge_passed? + session[:challenge_passed_at] = Time.now.utc + else + flash.now[:alert] = I18n.t('challenge.invalid_password') + render_challenge + end + else + render_challenge + end + end + + def challenge_passed? + current_user.valid_password?(challenge_params[:current_password]) + end + end + + class Animal + include Comparable + + attr_reader :legs + + def initialize(name, legs) + @name, @legs = name, legs + end + + def <=>(other) + legs <=> other.legs + end + end + + # Singleton method for car object + def car.wheels + puts "There are four wheels" + end"# + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[ + ( + r#" + # This concern is inspired by "sudo mode" on GitHub. It + # is a way to re-authenticate a user before allowing them + # to see or perform an action. + # + # Add `before_action :require_challenge!` to actions you + # want to protect. + # + # The user will be shown a page to enter the challenge (which + # is either the password, or just the username when no + # password exists). Upon passing, there is a grace period + # during which no challenge will be asked from the user. + # + # Accessing challenge-protected resources during the grace + # period will refresh the grace period. + module ChallengableConcern + extend ActiveSupport::Concern + + CHALLENGE_TIMEOUT = 1.hour.freeze + + def require_challenge! + # ... + end + + def challenge_passed? + # ... + end + end"# + .unindent(), + 558, + ), + ( + r#" + def require_challenge! + return if skip_challenge? + + if challenge_passed_recently? + session[:challenge_passed_at] = Time.now.utc + return + end + + @challenge = Form::Challenge.new(return_to: request.url) + + if params.key?(:form_challenge) + if challenge_passed? + session[:challenge_passed_at] = Time.now.utc + else + flash.now[:alert] = I18n.t('challenge.invalid_password') + render_challenge + end + else + render_challenge + end + end"# + .unindent(), + 663, + ), + ( + r#" + def challenge_passed? + current_user.valid_password?(challenge_params[:current_password]) + end"# + .unindent(), + 1254, + ), + ( + r#" + class Animal + include Comparable + + attr_reader :legs + + def initialize(name, legs) + # ... + end + + def <=>(other) + # ... + end + end"# + .unindent(), + 1363, + ), + ( + r#" + def initialize(name, legs) + @name, @legs = name, legs + end"# + .unindent(), + 1427, + ), + ( + r#" + def <=>(other) + legs <=> other.legs + end"# + .unindent(), + 1501, + ), + ( + r#" + # Singleton method for car object + def car.wheels + puts "There are four wheels" + end"# + .unindent(), + 1591, + ), + ], + ); +} + +#[gpui::test] +async fn test_code_context_retrieval_php() { + let language = php_lang(); + let embedding_provider = Arc::new(FakeEmbeddingProvider::default()); + let mut retriever = CodeContextRetriever::new(embedding_provider); + + let text = r#" + 100) { + throw new Exception(message: 'Progress cannot be greater than 100'); + } + + if ($this->achievements()->find($achievement->id)) { + throw new Exception(message: 'User already has this Achievement'); + } + + $this->achievements()->attach($achievement, [ + 'progress' => $progress ?? null, + ]); + + $this->when(value: ($progress === null) || ($progress === 100), callback: fn (): ?array => event(new AchievementAwarded(achievement: $achievement, user: $this))); + } + + public function achievements(): BelongsToMany + { + return $this->belongsToMany(related: Achievement::class) + ->withPivot(columns: 'progress') + ->where('is_secret', false) + ->using(AchievementUser::class); + } + } + + interface Multiplier + { + public function qualifies(array $data): bool; + + public function setMultiplier(): int; + } + + enum AuditType: string + { + case Add = 'add'; + case Remove = 'remove'; + case Reset = 'reset'; + case LevelUp = 'level_up'; + } + + ?>"# + .unindent(); + + let documents = retriever.parse_file(&text, language.clone()).unwrap(); + + assert_documents_eq( + &documents, + &[ + ( + r#" + /* + This is a multiple-lines comment block + that spans over multiple + lines + */ + function functionName() { + echo "Hello world!"; + }"# + .unindent(), + 123, + ), + ( + r#" + trait HasAchievements + { + /** + * @throws \Exception + */ + public function grantAchievement(Achievement $achievement, $progress = null): void + {/* ... */} + + public function achievements(): BelongsToMany + {/* ... */} + }"# + .unindent(), + 177, + ), + (r#" + /** + * @throws \Exception + */ + public function grantAchievement(Achievement $achievement, $progress = null): void + { + if ($progress > 100) { + throw new Exception(message: 'Progress cannot be greater than 100'); + } + + if ($this->achievements()->find($achievement->id)) { + throw new Exception(message: 'User already has this Achievement'); + } + + $this->achievements()->attach($achievement, [ + 'progress' => $progress ?? null, + ]); + + $this->when(value: ($progress === null) || ($progress === 100), callback: fn (): ?array => event(new AchievementAwarded(achievement: $achievement, user: $this))); + }"#.unindent(), 245), + (r#" + public function achievements(): BelongsToMany + { + return $this->belongsToMany(related: Achievement::class) + ->withPivot(columns: 'progress') + ->where('is_secret', false) + ->using(AchievementUser::class); + }"#.unindent(), 902), + (r#" + interface Multiplier + { + public function qualifies(array $data): bool; + + public function setMultiplier(): int; + }"#.unindent(), + 1146), + (r#" + enum AuditType: string + { + case Add = 'add'; + case Remove = 'remove'; + case Reset = 'reset'; + case LevelUp = 'level_up'; + }"#.unindent(), 1265) + ], + ); +} + +fn js_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Javascript".into(), + path_suffixes: vec!["js".into()], + ..Default::default() + }, + Some(tree_sitter_typescript::language_tsx()), + ) + .with_embedding_query( + &r#" + + ( + (comment)* @context + . + [ + (export_statement + (function_declaration + "async"? @name + "function" @name + name: (_) @name)) + (function_declaration + "async"? @name + "function" @name + name: (_) @name) + ] @item + ) + + ( + (comment)* @context + . + [ + (export_statement + (class_declaration + "class" @name + name: (_) @name)) + (class_declaration + "class" @name + name: (_) @name) + ] @item + ) + + ( + (comment)* @context + . + [ + (export_statement + (interface_declaration + "interface" @name + name: (_) @name)) + (interface_declaration + "interface" @name + name: (_) @name) + ] @item + ) + + ( + (comment)* @context + . + [ + (export_statement + (enum_declaration + "enum" @name + name: (_) @name)) + (enum_declaration + "enum" @name + name: (_) @name) + ] @item + ) + + ( + (comment)* @context + . + (method_definition + [ + "get" + "set" + "async" + "*" + "static" + ]* @name + name: (_) @name) @item + ) + + "# + .unindent(), + ) + .unwrap(), + ) +} + +fn rust_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Rust".into(), + path_suffixes: vec!["rs".into()], + collapsed_placeholder: " /* ... */ ".to_string(), + ..Default::default() + }, + Some(tree_sitter_rust::language()), + ) + .with_embedding_query( + r#" + ( + [(line_comment) (attribute_item)]* @context + . + [ + (struct_item + name: (_) @name) + + (enum_item + name: (_) @name) + + (impl_item + trait: (_)? @name + "for"? @name + type: (_) @name) + + (trait_item + name: (_) @name) + + (function_item + name: (_) @name + body: (block + "{" @keep + "}" @keep) @collapse) + + (macro_definition + name: (_) @name) + ] @item + ) + + (attribute_item) @collapse + (use_declaration) @collapse + "#, + ) + .unwrap(), + ) +} + +fn json_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "JSON".into(), + path_suffixes: vec!["json".into()], + ..Default::default() + }, + Some(tree_sitter_json::language()), + ) + .with_embedding_query( + r#" + (document) @item + + (array + "[" @keep + . + (object)? @keep + "]" @keep) @collapse + + (pair value: (string + "\"" @keep + "\"" @keep) @collapse) + "#, + ) + .unwrap(), + ) +} + +fn toml_lang() -> Arc { + Arc::new(Language::new( + LanguageConfig { + name: "TOML".into(), + path_suffixes: vec!["toml".into()], + ..Default::default() + }, + Some(tree_sitter_toml::language()), + )) +} + +fn cpp_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "CPP".into(), + path_suffixes: vec!["cpp".into()], + ..Default::default() + }, + Some(tree_sitter_cpp::language()), + ) + .with_embedding_query( + r#" + ( + (comment)* @context + . + (function_definition + (type_qualifier)? @name + type: (_)? @name + declarator: [ + (function_declarator + declarator: (_) @name) + (pointer_declarator + "*" @name + declarator: (function_declarator + declarator: (_) @name)) + (pointer_declarator + "*" @name + declarator: (pointer_declarator + "*" @name + declarator: (function_declarator + declarator: (_) @name))) + (reference_declarator + ["&" "&&"] @name + (function_declarator + declarator: (_) @name)) + ] + (type_qualifier)? @name) @item + ) + + ( + (comment)* @context + . + (template_declaration + (class_specifier + "class" @name + name: (_) @name) + ) @item + ) + + ( + (comment)* @context + . + (class_specifier + "class" @name + name: (_) @name) @item + ) + + ( + (comment)* @context + . + (enum_specifier + "enum" @name + name: (_) @name) @item + ) + + ( + (comment)* @context + . + (declaration + type: (struct_specifier + "struct" @name) + declarator: (_) @name) @item + ) + + "#, + ) + .unwrap(), + ) +} + +fn lua_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Lua".into(), + path_suffixes: vec!["lua".into()], + collapsed_placeholder: "--[ ... ]--".to_string(), + ..Default::default() + }, + Some(tree_sitter_lua::language()), + ) + .with_embedding_query( + r#" + ( + (comment)* @context + . + (function_declaration + "function" @name + name: (_) @name + (comment)* @collapse + body: (block) @collapse + ) @item + ) + "#, + ) + .unwrap(), + ) +} + +fn php_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "PHP".into(), + path_suffixes: vec!["php".into()], + collapsed_placeholder: "/* ... */".into(), + ..Default::default() + }, + Some(tree_sitter_php::language()), + ) + .with_embedding_query( + r#" + ( + (comment)* @context + . + [ + (function_definition + "function" @name + name: (_) @name + body: (_ + "{" @keep + "}" @keep) @collapse + ) + + (trait_declaration + "trait" @name + name: (_) @name) + + (method_declaration + "function" @name + name: (_) @name + body: (_ + "{" @keep + "}" @keep) @collapse + ) + + (interface_declaration + "interface" @name + name: (_) @name + ) + + (enum_declaration + "enum" @name + name: (_) @name + ) + + ] @item + ) + "#, + ) + .unwrap(), + ) +} + +fn ruby_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Ruby".into(), + path_suffixes: vec!["rb".into()], + collapsed_placeholder: "# ...".to_string(), + ..Default::default() + }, + Some(tree_sitter_ruby::language()), + ) + .with_embedding_query( + r#" + ( + (comment)* @context + . + [ + (module + "module" @name + name: (_) @name) + (method + "def" @name + name: (_) @name + body: (body_statement) @collapse) + (class + "class" @name + name: (_) @name) + (singleton_method + "def" @name + object: (_) @name + "." @name + name: (_) @name + body: (body_statement) @collapse) + ] @item + ) + "#, + ) + .unwrap(), + ) +} + +fn elixir_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "Elixir".into(), + path_suffixes: vec!["rs".into()], + ..Default::default() + }, + Some(tree_sitter_elixir::language()), + ) + .with_embedding_query( + r#" + ( + (unary_operator + operator: "@" + operand: (call + target: (identifier) @unary + (#match? @unary "^(doc)$")) + ) @context + . + (call + target: (identifier) @name + (arguments + [ + (identifier) @name + (call + target: (identifier) @name) + (binary_operator + left: (call + target: (identifier) @name) + operator: "when") + ]) + (#any-match? @name "^(def|defp|defdelegate|defguard|defguardp|defmacro|defmacrop|defn|defnp)$")) @item + ) + + (call + target: (identifier) @name + (arguments (alias) @name) + (#any-match? @name "^(defmodule|defprotocol)$")) @item + "#, + ) + .unwrap(), + ) +} + +#[gpui::test] +fn test_subtract_ranges() { + // collapsed_ranges: Vec>, keep_ranges: Vec> + + assert_eq!( + subtract_ranges(&[0..5, 10..21], &[0..1, 4..5]), + vec![1..4, 10..21] + ); + + assert_eq!(subtract_ranges(&[0..5], &[1..2]), &[0..1, 2..5]); +} + +fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + SemanticIndexSettings::register(cx); + ProjectSettings::register(cx); + }); +} diff --git a/crates/storybook2/src/stories.rs b/crates/storybook2/src/stories.rs index 0eaf3d126c216650161896fa28cdb404aa1e30ac..f7ab3ef548984b32e70bc83a58ad38e64c188186 100644 --- a/crates/storybook2/src/stories.rs +++ b/crates/storybook2/src/stories.rs @@ -1,3 +1,5 @@ +mod auto_height_editor; +mod cursor; mod focus; mod kitchen_sink; mod picker; @@ -5,6 +7,8 @@ mod scroll; mod text; mod z_index; +pub use auto_height_editor::*; +pub use cursor::*; pub use focus::*; pub use kitchen_sink::*; pub use picker::*; diff --git a/crates/storybook2/src/stories/auto_height_editor.rs b/crates/storybook2/src/stories/auto_height_editor.rs new file mode 100644 index 0000000000000000000000000000000000000000..2f3089a4e6e766057559376f284e570b19701c5c --- /dev/null +++ b/crates/storybook2/src/stories/auto_height_editor.rs @@ -0,0 +1,34 @@ +use editor::Editor; +use gpui::{ + div, white, Div, KeyBinding, ParentElement, Render, Styled, View, ViewContext, VisualContext, + WindowContext, +}; + +pub struct AutoHeightEditorStory { + editor: View, +} + +impl AutoHeightEditorStory { + pub fn new(cx: &mut WindowContext) -> View { + cx.bind_keys([KeyBinding::new("enter", editor::Newline, Some("Editor"))]); + cx.build_view(|cx| Self { + editor: cx.build_view(|cx| { + let mut editor = Editor::auto_height(3, cx); + editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); + editor + }), + }) + } +} + +impl Render for AutoHeightEditorStory { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + div() + .size_full() + .bg(white()) + .text_sm() + .child(div().w_32().bg(gpui::black()).child(self.editor.clone())) + } +} diff --git a/crates/storybook2/src/stories/cursor.rs b/crates/storybook2/src/stories/cursor.rs new file mode 100644 index 0000000000000000000000000000000000000000..7d4cf8145a034906bb87bb944ae926fff5ab9f3a --- /dev/null +++ b/crates/storybook2/src/stories/cursor.rs @@ -0,0 +1,111 @@ +use gpui::{Div, Render, Stateful}; +use story::Story; +use ui::prelude::*; + +pub struct CursorStory; + +impl Render for CursorStory { + type Element = Div; + + fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { + let all_cursors: [(&str, Box) -> Stateful
>); 19] = [ + ( + "cursor_default", + Box::new(|el: Stateful
| el.cursor_default()), + ), + ( + "cursor_pointer", + Box::new(|el: Stateful
| el.cursor_pointer()), + ), + ( + "cursor_text", + Box::new(|el: Stateful
| el.cursor_text()), + ), + ( + "cursor_move", + Box::new(|el: Stateful
| el.cursor_move()), + ), + ( + "cursor_not_allowed", + Box::new(|el: Stateful
| el.cursor_not_allowed()), + ), + ( + "cursor_context_menu", + Box::new(|el: Stateful
| el.cursor_context_menu()), + ), + ( + "cursor_crosshair", + Box::new(|el: Stateful
| el.cursor_crosshair()), + ), + ( + "cursor_vertical_text", + Box::new(|el: Stateful
| el.cursor_vertical_text()), + ), + ( + "cursor_alias", + Box::new(|el: Stateful
| el.cursor_alias()), + ), + ( + "cursor_copy", + Box::new(|el: Stateful
| el.cursor_copy()), + ), + ( + "cursor_no_drop", + Box::new(|el: Stateful
| el.cursor_no_drop()), + ), + ( + "cursor_grab", + Box::new(|el: Stateful
| el.cursor_grab()), + ), + ( + "cursor_grabbing", + Box::new(|el: Stateful
| el.cursor_grabbing()), + ), + ( + "cursor_col_resize", + Box::new(|el: Stateful
| el.cursor_col_resize()), + ), + ( + "cursor_row_resize", + Box::new(|el: Stateful
| el.cursor_row_resize()), + ), + ( + "cursor_n_resize", + Box::new(|el: Stateful
| el.cursor_n_resize()), + ), + ( + "cursor_e_resize", + Box::new(|el: Stateful
| el.cursor_e_resize()), + ), + ( + "cursor_s_resize", + Box::new(|el: Stateful
| el.cursor_s_resize()), + ), + ( + "cursor_w_resize", + Box::new(|el: Stateful
| el.cursor_w_resize()), + ), + ]; + + Story::container() + .flex() + .gap_1() + .child(Story::title("cursor")) + .children(all_cursors.map(|(name, apply_cursor)| { + div().gap_1().flex().text_color(gpui::white()).child( + div() + .flex() + .items_center() + .justify_center() + .id(name) + .map(apply_cursor) + .w_64() + .h_8() + .bg(gpui::red()) + .active(|style| style.bg(gpui::green())) + .text_sm() + .child(Story::label(name)), + ) + })) + } +} diff --git a/crates/storybook2/src/stories/text.rs b/crates/storybook2/src/stories/text.rs index 3cb39aa01a2122691ed4a337d37e4d6a26c1eb07..ccd13cb4d80b1e739cef0671a3bfd54b78264fea 100644 --- a/crates/storybook2/src/stories/text.rs +++ b/crates/storybook2/src/stories/text.rs @@ -1,6 +1,6 @@ use gpui::{ - blue, div, green, red, white, Div, InteractiveText, ParentElement, Render, Styled, StyledText, - TextRun, View, VisualContext, WindowContext, + blue, div, green, red, white, Div, HighlightStyle, InteractiveText, ParentElement, Render, + Styled, StyledText, View, VisualContext, WindowContext, }; use ui::v_stack; @@ -59,13 +59,11 @@ impl Render for TextStory { ))).child( InteractiveText::new( "interactive", - StyledText::new("Hello world, how is it going?").with_runs(vec![ - cx.text_style().to_run(6), - TextRun { + StyledText::new("Hello world, how is it going?").with_highlights(&cx.text_style(), [ + (6..11, HighlightStyle { background_color: Some(green()), - ..cx.text_style().to_run(5) - }, - cx.text_style().to_run(18), + ..Default::default() + }), ]), ) .on_click(vec![2..4, 1..3, 7..9], |range_ix, _cx| { diff --git a/crates/storybook2/src/story_selector.rs b/crates/storybook2/src/story_selector.rs index 0354097c0b19b525662efbd630a4dd33c15515da..216762060d83870e14177449befc5a0079332148 100644 --- a/crates/storybook2/src/story_selector.rs +++ b/crates/storybook2/src/story_selector.rs @@ -12,10 +12,12 @@ use ui::prelude::*; #[derive(Debug, PartialEq, Eq, Clone, Copy, strum::Display, EnumString, EnumIter)] #[strum(serialize_all = "snake_case")] pub enum ComponentStory { + AutoHeightEditor, Avatar, Button, Checkbox, ContextMenu, + Cursor, Disclosure, Focus, Icon, @@ -23,6 +25,7 @@ pub enum ComponentStory { Keybinding, Label, List, + ListHeader, ListItem, Scroll, Text, @@ -33,10 +36,12 @@ pub enum ComponentStory { impl ComponentStory { pub fn story(&self, cx: &mut WindowContext) -> AnyView { match self { + Self::AutoHeightEditor => AutoHeightEditorStory::new(cx).into(), Self::Avatar => cx.build_view(|_| ui::AvatarStory).into(), Self::Button => cx.build_view(|_| ui::ButtonStory).into(), Self::Checkbox => cx.build_view(|_| ui::CheckboxStory).into(), Self::ContextMenu => cx.build_view(|_| ui::ContextMenuStory).into(), + Self::Cursor => cx.build_view(|_| crate::stories::CursorStory).into(), Self::Disclosure => cx.build_view(|_| ui::DisclosureStory).into(), Self::Focus => FocusStory::view(cx).into(), Self::Icon => cx.build_view(|_| ui::IconStory).into(), @@ -44,6 +49,7 @@ impl ComponentStory { Self::Keybinding => cx.build_view(|_| ui::KeybindingStory).into(), Self::Label => cx.build_view(|_| ui::LabelStory).into(), Self::List => cx.build_view(|_| ui::ListStory).into(), + Self::ListHeader => cx.build_view(|_| ui::ListHeaderStory).into(), Self::ListItem => cx.build_view(|_| ui::ListItemStory).into(), Self::Scroll => ScrollStory::view(cx).into(), Self::Text => TextStory::view(cx).into(), diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 5a13efd07a0da4b8490444b3322bfc4600b70e17..dda976b2cdf245d681b44493ce9e845f01e25291 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -1132,6 +1132,7 @@ mod tests { }) }) .await + .unwrap() .unwrap(); (wt, entry) diff --git a/crates/terminal_view2/src/terminal_view.rs b/crates/terminal_view2/src/terminal_view.rs index b007d58c34bcb2163f42bd2b88e1979a18152f56..e184fa68762b3480732c222f713069b517b8412b 100644 --- a/crates/terminal_view2/src/terminal_view.rs +++ b/crates/terminal_view2/src/terminal_view.rs @@ -299,11 +299,8 @@ impl TerminalView { cx: &mut ViewContext, ) { self.context_menu = Some(ContextMenu::build(cx, |menu, cx| { - menu.action("Clear", Box::new(Clear), cx).action( - "Close", - Box::new(CloseActiveItem { save_intent: None }), - cx, - ) + menu.action("Clear", Box::new(Clear)) + .action("Close", Box::new(CloseActiveItem { save_intent: None })) })); dbg!(&position); // todo!() @@ -1173,6 +1170,7 @@ mod tests { }) }) .await + .unwrap() .unwrap(); (wt, entry) diff --git a/crates/theme2/src/one_themes.rs b/crates/theme2/src/one_themes.rs index 2f663618a686c841ec13c4cc083ae44619bfd19c..e1fb5f1bed21422f64eb210899b3850f7bb1c6d2 100644 --- a/crates/theme2/src/one_themes.rs +++ b/crates/theme2/src/one_themes.rs @@ -52,13 +52,13 @@ pub(crate) fn one_dark() -> Theme { element_hover: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), element_active: hsla(220.0 / 360., 11.8 / 100., 20.0 / 100., 1.0), element_selected: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), - element_disabled: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), + element_disabled: SystemColors::default().transparent, drop_target_background: hsla(220.0 / 360., 8.3 / 100., 21.4 / 100., 1.0), ghost_element_background: SystemColors::default().transparent, ghost_element_hover: hsla(225.0 / 360., 11.8 / 100., 26.7 / 100., 1.0), ghost_element_active: hsla(220.0 / 360., 11.8 / 100., 20.0 / 100., 1.0), ghost_element_selected: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), - ghost_element_disabled: hsla(224.0 / 360., 11.3 / 100., 26.1 / 100., 1.0), + ghost_element_disabled: SystemColors::default().transparent, text: hsla(221. / 360., 11. / 100., 86. / 100., 1.0), text_muted: hsla(218.0 / 360., 7. / 100., 46. / 100., 1.0), text_placeholder: hsla(220.0 / 360., 6.6 / 100., 44.5 / 100., 1.0), diff --git a/crates/theme_selector2/src/theme_selector.rs b/crates/theme_selector2/src/theme_selector.rs index be55194e76ebdaedc0e72aa4a9f9d4d6314fb3eb..582ce43a88ddc8c197617693adbf0e05ab7b11e3 100644 --- a/crates/theme_selector2/src/theme_selector.rs +++ b/crates/theme_selector2/src/theme_selector.rs @@ -2,14 +2,14 @@ use feature_flags::FeatureFlagAppExt; use fs::Fs; use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use gpui::{ - actions, AppContext, DismissEvent, EventEmitter, FocusableView, Render, SharedString, View, - ViewContext, VisualContext, WeakView, + actions, AppContext, DismissEvent, Div, EventEmitter, FocusableView, Render, SharedString, + View, ViewContext, VisualContext, WeakView, }; use picker::{Picker, PickerDelegate}; use settings::{update_settings_file, SettingsStore}; use std::sync::Arc; use theme::{Theme, ThemeRegistry, ThemeSettings}; -use ui::{prelude::*, ListItem}; +use ui::{prelude::*, v_stack, ListItem}; use util::ResultExt; use workspace::{ui::HighlightedLabel, Workspace}; @@ -65,10 +65,10 @@ impl FocusableView for ThemeSelector { } impl Render for ThemeSelector { - type Element = View>; + type Element = Div; fn render(&mut self, _cx: &mut ViewContext) -> Self::Element { - self.picker.clone() + v_stack().min_w_96().child(self.picker.clone()) } } @@ -98,7 +98,7 @@ impl ThemeSelectorDelegate { let original_theme = cx.theme().clone(); let staff_mode = cx.is_staff(); - let registry = cx.global::>(); + let registry = cx.global::(); let theme_names = registry.list(staff_mode).collect::>(); //todo!(theme sorting) // theme_names.sort_unstable_by(|a, b| a.is_light.cmp(&b.is_light).then(a.name.cmp(&b.name))); @@ -126,7 +126,7 @@ impl ThemeSelectorDelegate { fn show_selected_theme(&mut self, cx: &mut ViewContext>) { if let Some(mat) = self.matches.get(self.selected_index) { - let registry = cx.global::>(); + let registry = cx.global::(); match registry.get(&mat.string) { Ok(theme) => { Self::set_theme(theme, cx); diff --git a/crates/ui2/src/components.rs b/crates/ui2/src/components.rs index be95fc1fab6cc66edaef96f1a74622a01457e0f1..17271de48d4993c111c5cececd50499c6ef801b3 100644 --- a/crates/ui2/src/components.rs +++ b/crates/ui2/src/components.rs @@ -9,6 +9,8 @@ mod keybinding; mod label; mod list; mod popover; +mod popover_menu; +mod right_click_menu; mod stack; mod tooltip; @@ -26,6 +28,8 @@ pub use keybinding::*; pub use label::*; pub use list::*; pub use popover::*; +pub use popover_menu::*; +pub use right_click_menu::*; pub use stack::*; pub use tooltip::*; diff --git a/crates/ui2/src/components/button/mod.rs b/crates/ui2/src/components/button.rs similarity index 80% rename from crates/ui2/src/components/button/mod.rs rename to crates/ui2/src/components/button.rs index 085d5f376b596763999a2feac17dab90e7f4e825..25e88201f47417860b85ffc6267408ea47b1c052 100644 --- a/crates/ui2/src/components/button/mod.rs +++ b/crates/ui2/src/components/button.rs @@ -1,4 +1,5 @@ mod button; +pub(self) mod button_icon; mod button_like; mod icon_button; diff --git a/crates/ui2/src/components/button/button.rs b/crates/ui2/src/components/button/button.rs index 4bfa71d092883f38fc915bc13c7479bdc2bb864a..c1262321cede804c65199ae9e206138796e9024f 100644 --- a/crates/ui2/src/components/button/button.rs +++ b/crates/ui2/src/components/button/button.rs @@ -1,13 +1,22 @@ -use gpui::AnyView; +use gpui::{AnyView, DefiniteLength}; use crate::prelude::*; -use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Label, LineHeightStyle}; +use crate::{ + ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize, Label, LineHeightStyle, +}; + +use super::button_icon::ButtonIcon; #[derive(IntoElement)] pub struct Button { base: ButtonLike, label: SharedString, label_color: Option, + selected_label: Option, + icon: Option, + icon_size: Option, + icon_color: Option, + selected_icon: Option, } impl Button { @@ -16,6 +25,11 @@ impl Button { base: ButtonLike::new(id), label: label.into(), label_color: None, + selected_label: None, + icon: None, + icon_size: None, + icon_color: None, + selected_icon: None, } } @@ -23,6 +37,31 @@ impl Button { self.label_color = label_color.into(); self } + + pub fn selected_label>(mut self, label: impl Into>) -> Self { + self.selected_label = label.into().map(Into::into); + self + } + + pub fn icon(mut self, icon: impl Into>) -> Self { + self.icon = icon.into(); + self + } + + pub fn icon_size(mut self, icon_size: impl Into>) -> Self { + self.icon_size = icon_size.into(); + self + } + + pub fn icon_color(mut self, icon_color: impl Into>) -> Self { + self.icon_color = icon_color.into(); + self + } + + pub fn selected_icon(mut self, icon: impl Into>) -> Self { + self.selected_icon = icon.into(); + self + } } impl Selectable for Button { @@ -49,6 +88,18 @@ impl Clickable for Button { } } +impl FixedWidth for Button { + fn width(mut self, width: DefiniteLength) -> Self { + self.base = self.base.width(width); + self + } + + fn full_width(mut self) -> Self { + self.base = self.base.full_width(); + self + } +} + impl ButtonCommon for Button { fn id(&self) -> &ElementId { self.base.id() @@ -74,18 +125,35 @@ impl RenderOnce for Button { type Rendered = ButtonLike; fn render(self, _cx: &mut WindowContext) -> Self::Rendered { - let label_color = if self.base.disabled { + let is_disabled = self.base.disabled; + let is_selected = self.base.selected; + + let label = self + .selected_label + .filter(|_| is_selected) + .unwrap_or(self.label); + + let label_color = if is_disabled { Color::Disabled - } else if self.base.selected { + } else if is_selected { Color::Selected } else { - Color::Default + self.label_color.unwrap_or_default() }; - self.base.child( - Label::new(self.label) - .color(label_color) - .line_height_style(LineHeightStyle::UILabel), - ) + self.base + .children(self.icon.map(|icon| { + ButtonIcon::new(icon) + .disabled(is_disabled) + .selected(is_selected) + .selected_icon(self.selected_icon) + .size(self.icon_size) + .color(self.icon_color) + })) + .child( + Label::new(label) + .color(label_color) + .line_height_style(LineHeightStyle::UILabel), + ) } } diff --git a/crates/ui2/src/components/button/button_icon.rs b/crates/ui2/src/components/button/button_icon.rs new file mode 100644 index 0000000000000000000000000000000000000000..3b2c703938bf0564179a1ce3b9305933b55b0810 --- /dev/null +++ b/crates/ui2/src/components/button/button_icon.rs @@ -0,0 +1,84 @@ +use crate::{prelude::*, Icon, IconElement, IconSize}; + +/// An icon that appears within a button. +/// +/// Can be used as either an icon alongside a label, like in [`Button`](crate::Button), +/// or as a standalone icon, like in [`IconButton`](crate::IconButton). +#[derive(IntoElement)] +pub(super) struct ButtonIcon { + icon: Icon, + size: IconSize, + color: Color, + disabled: bool, + selected: bool, + selected_icon: Option, +} + +impl ButtonIcon { + pub fn new(icon: Icon) -> Self { + Self { + icon, + size: IconSize::default(), + color: Color::default(), + disabled: false, + selected: false, + selected_icon: None, + } + } + + pub fn size(mut self, size: impl Into>) -> Self { + if let Some(size) = size.into() { + self.size = size; + } + + self + } + + pub fn color(mut self, color: impl Into>) -> Self { + if let Some(color) = color.into() { + self.color = color; + } + + self + } + + pub fn selected_icon(mut self, icon: impl Into>) -> Self { + self.selected_icon = icon.into(); + self + } +} + +impl Disableable for ButtonIcon { + fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +impl Selectable for ButtonIcon { + fn selected(mut self, selected: bool) -> Self { + self.selected = selected; + self + } +} + +impl RenderOnce for ButtonIcon { + type Rendered = IconElement; + + fn render(self, _cx: &mut WindowContext) -> Self::Rendered { + let icon = self + .selected_icon + .filter(|_| self.selected) + .unwrap_or(self.icon); + + let icon_color = if self.disabled { + Color::Disabled + } else if self.selected { + Color::Selected + } else { + self.color + }; + + IconElement::new(icon).size(self.size).color(icon_color) + } +} diff --git a/crates/ui2/src/components/button/button_like.rs b/crates/ui2/src/components/button/button_like.rs index 207d59ecf15704025d77b1dd26e18e340f4f0c47..4bef6bff774571047dd1519c18b7ec61b32f1904 100644 --- a/crates/ui2/src/components/button/button_like.rs +++ b/crates/ui2/src/components/button/button_like.rs @@ -1,3 +1,4 @@ +use gpui::{relative, DefiniteLength}; use gpui::{rems, transparent_black, AnyElement, AnyView, ClickEvent, Div, Hsla, Rems, Stateful}; use smallvec::SmallVec; @@ -5,18 +6,50 @@ use crate::h_stack; use crate::prelude::*; pub trait ButtonCommon: Clickable + Disableable { + /// A unique element ID to identify the button. fn id(&self) -> &ElementId; + + /// The visual style of the button. + /// + /// Mosty commonly will be [`ButtonStyle::Subtle`], or [`ButtonStyle::Filled`] + /// for an emphasized button. fn style(self, style: ButtonStyle) -> Self; + + /// The size of the button. + /// + /// Most buttons will use the default size. + /// + /// [`ButtonSize`] can also be used to help build non-button elements + /// that are consistently sized with buttons. fn size(self, size: ButtonSize) -> Self; + + /// The tooltip that shows when a user hovers over the button. + /// + /// Nearly all interactable elements should have a tooltip. Some example + /// exceptions might a scroll bar, or a slider. fn tooltip(self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self; } #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum ButtonStyle { - #[default] + /// A filled button with a solid background color. Provides emphasis versus + /// the more common subtle button. Filled, - // Tinted, + + /// 🚧 Under construction 🚧 + /// + /// Used to emphasize a button in some way, like a selected state, or a semantic + /// coloring like an error or success button. + Tinted, + + /// The default button style, used for most buttons. Has a transparent background, + /// but has a background color to indicate states like hover and active. + #[default] Subtle, + + /// Used for buttons that only change forground color on hover and active states. + /// + /// TODO: Better docs for this. Transparent, } @@ -40,6 +73,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::Tinted => ButtonLikeStyles { + background: gpui::red(), + border_color: gpui::red(), + label_color: gpui::red(), + icon_color: gpui::red(), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_background, border_color: transparent_black(), @@ -63,6 +102,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::Tinted => ButtonLikeStyles { + background: gpui::red(), + border_color: gpui::red(), + label_color: gpui::red(), + icon_color: gpui::red(), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_hover, border_color: transparent_black(), @@ -88,6 +133,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::Tinted => ButtonLikeStyles { + background: gpui::red(), + border_color: gpui::red(), + label_color: gpui::red(), + icon_color: gpui::red(), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_active, border_color: transparent_black(), @@ -114,6 +165,12 @@ impl ButtonStyle { label_color: Color::Default.color(cx), icon_color: Color::Default.color(cx), }, + ButtonStyle::Tinted => ButtonLikeStyles { + background: gpui::red(), + border_color: gpui::red(), + label_color: gpui::red(), + icon_color: gpui::red(), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_background, border_color: cx.theme().colors().border_focused, @@ -137,6 +194,12 @@ impl ButtonStyle { label_color: Color::Disabled.color(cx), icon_color: Color::Disabled.color(cx), }, + ButtonStyle::Tinted => ButtonLikeStyles { + background: gpui::red(), + border_color: gpui::red(), + label_color: gpui::red(), + icon_color: gpui::red(), + }, ButtonStyle::Subtle => ButtonLikeStyles { background: cx.theme().colors().ghost_element_disabled, border_color: cx.theme().colors().border_disabled, @@ -153,6 +216,8 @@ impl ButtonStyle { } } +/// ButtonSize can also be used to help build non-button elements +/// that are consistently sized with buttons. #[derive(Default, PartialEq, Clone, Copy)] pub enum ButtonSize { #[default] @@ -171,12 +236,18 @@ impl ButtonSize { } } +/// A button-like element that can be used to create a custom button when +/// prebuilt buttons are not sufficient. Use this sparingly, as it is +/// unconstrained and may make the UI feel less consistent. +/// +/// This is also used to build the prebuilt buttons. #[derive(IntoElement)] pub struct ButtonLike { id: ElementId, pub(super) style: ButtonStyle, pub(super) disabled: bool, pub(super) selected: bool, + pub(super) width: Option, size: ButtonSize, tooltip: Option AnyView>>, on_click: Option>, @@ -190,6 +261,7 @@ impl ButtonLike { style: ButtonStyle::default(), disabled: false, selected: false, + width: None, size: ButtonSize::Default, tooltip: None, children: SmallVec::new(), @@ -219,6 +291,18 @@ impl Clickable for ButtonLike { } } +impl FixedWidth for ButtonLike { + fn width(mut self, width: DefiniteLength) -> Self { + self.width = Some(width); + self + } + + fn full_width(mut self) -> Self { + self.width = Some(relative(1.)); + self + } +} + impl ButtonCommon for ButtonLike { fn id(&self) -> &ElementId { &self.id @@ -252,14 +336,19 @@ impl RenderOnce for ButtonLike { fn render(self, cx: &mut WindowContext) -> Self::Rendered { h_stack() .id(self.id.clone()) + .group("") + .flex_none() .h(self.size.height()) + .when_some(self.width, |this, width| this.w(width)) .rounded_md() - .cursor_pointer() .gap_1() .px_1() .bg(self.style.enabled(cx).background) - .hover(|hover| hover.bg(self.style.hovered(cx).background)) - .active(|active| active.bg(self.style.active(cx).background)) + .when(!self.disabled, |this| { + this.cursor_pointer() + .hover(|hover| hover.bg(self.style.hovered(cx).background)) + .active(|active| active.bg(self.style.active(cx).background)) + }) .when_some( self.on_click.filter(|_| !self.disabled), |this, on_click| { @@ -270,7 +359,11 @@ impl RenderOnce for ButtonLike { }, ) .when_some(self.tooltip, |this, tooltip| { - this.tooltip(move |cx| tooltip(cx)) + if !self.selected { + this.tooltip(move |cx| tooltip(cx)) + } else { + this + } }) .children(self.children) } diff --git a/crates/ui2/src/components/button/icon_button.rs b/crates/ui2/src/components/button/icon_button.rs index a62832059d148c0d5ed9a1ec34aa6352695217f0..94431ef642e08840034ff7f2e4025be5f220daac 100644 --- a/crates/ui2/src/components/button/icon_button.rs +++ b/crates/ui2/src/components/button/icon_button.rs @@ -1,7 +1,9 @@ -use gpui::{Action, AnyView}; +use gpui::{Action, AnyView, DefiniteLength}; use crate::prelude::*; -use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconElement, IconSize}; +use crate::{ButtonCommon, ButtonLike, ButtonSize, ButtonStyle, Icon, IconSize}; + +use super::button_icon::ButtonIcon; #[derive(IntoElement)] pub struct IconButton { @@ -9,6 +11,7 @@ pub struct IconButton { icon: Icon, icon_size: IconSize, icon_color: Color, + selected_icon: Option, } impl IconButton { @@ -18,6 +21,7 @@ impl IconButton { icon, icon_size: IconSize::default(), icon_color: Color::Default, + selected_icon: None, } } @@ -31,6 +35,11 @@ impl IconButton { self } + pub fn selected_icon(mut self, icon: impl Into>) -> Self { + self.selected_icon = icon.into(); + self + } + pub fn action(self, action: Box) -> Self { self.on_click(move |_event, cx| cx.dispatch_action(action.boxed_clone())) } @@ -60,6 +69,18 @@ impl Clickable for IconButton { } } +impl FixedWidth for IconButton { + fn width(mut self, width: DefiniteLength) -> Self { + self.base = self.base.width(width); + self + } + + fn full_width(mut self) -> Self { + self.base = self.base.full_width(); + self + } +} + impl ButtonCommon for IconButton { fn id(&self) -> &ElementId { self.base.id() @@ -85,18 +106,16 @@ impl RenderOnce for IconButton { type Rendered = ButtonLike; fn render(self, _cx: &mut WindowContext) -> Self::Rendered { - let icon_color = if self.base.disabled { - Color::Disabled - } else if self.base.selected { - Color::Selected - } else { - self.icon_color - }; + let is_disabled = self.base.disabled; + let is_selected = self.base.selected; self.base.child( - IconElement::new(self.icon) + ButtonIcon::new(self.icon) + .disabled(is_disabled) + .selected(is_selected) + .selected_icon(self.selected_icon) .size(self.icon_size) - .color(icon_color), + .color(self.icon_color), ) } } diff --git a/crates/ui2/src/components/context_menu.rs b/crates/ui2/src/components/context_menu.rs index f071d188a12f8dc1b892215c11ba5e4c0ff650f5..0d6a632db58f3d750bcd8e60cf6a9a92b5405468 100644 --- a/crates/ui2/src/components/context_menu.rs +++ b/crates/ui2/src/components/context_menu.rs @@ -1,21 +1,22 @@ use crate::{ - h_stack, prelude::*, v_stack, KeyBinding, Label, List, ListItem, ListSeparator, ListSubHeader, + h_stack, prelude::*, v_stack, Icon, IconElement, KeyBinding, Label, List, ListItem, + ListSeparator, ListSubHeader, }; use gpui::{ - overlay, px, Action, AnchorCorner, AnyElement, AppContext, Bounds, DismissEvent, DispatchPhase, - Div, EventEmitter, FocusHandle, FocusableView, IntoElement, LayoutId, ManagedView, MouseButton, - MouseDownEvent, Pixels, Point, Render, View, VisualContext, + px, Action, AppContext, DismissEvent, Div, EventEmitter, FocusHandle, FocusableView, + IntoElement, Render, View, VisualContext, }; use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; -use std::{cell::RefCell, rc::Rc}; +use std::{rc::Rc, time::Duration}; pub enum ContextMenuItem { Separator, Header(SharedString), Entry { label: SharedString, + icon: Option, handler: Rc, - key_binding: Option, + action: Option>, }, } @@ -23,6 +24,7 @@ pub struct ContextMenu { items: Vec, focus_handle: FocusHandle, selected_index: Option, + delayed: bool, } impl FocusableView for ContextMenu { @@ -45,6 +47,7 @@ impl ContextMenu { items: Default::default(), focus_handle: cx.focus_handle(), selected_index: None, + delayed: false, }, cx, ) @@ -69,21 +72,28 @@ impl ContextMenu { self.items.push(ContextMenuItem::Entry { label: label.into(), handler: Rc::new(on_click), - key_binding: None, + icon: None, + action: None, }); self } - pub fn action( - mut self, - label: impl Into, - action: Box, - cx: &mut WindowContext, - ) -> Self { + pub fn action(mut self, label: impl Into, action: Box) -> Self { + self.items.push(ContextMenuItem::Entry { + label: label.into(), + action: Some(action.boxed_clone()), + handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), + icon: None, + }); + self + } + + pub fn link(mut self, label: impl Into, action: Box) -> Self { self.items.push(ContextMenuItem::Entry { label: label.into(), - key_binding: KeyBinding::for_action(&*action, cx), + action: Some(action.boxed_clone()), handler: Rc::new(move |cx| cx.dispatch_action(action.boxed_clone())), + icon: Some(Icon::Link), }); self } @@ -143,6 +153,37 @@ impl ContextMenu { self.select_last(&Default::default(), cx); } } + + pub fn on_action_dispatch(&mut self, dispatched: &Box, cx: &mut ViewContext) { + if let Some(ix) = self.items.iter().position(|item| { + if let ContextMenuItem::Entry { + action: Some(action), + .. + } = item + { + action.partial_eq(&**dispatched) + } else { + false + } + }) { + self.selected_index = Some(ix); + self.delayed = true; + cx.notify(); + let action = dispatched.boxed_clone(); + cx.spawn(|this, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + this.update(&mut cx, |this, cx| { + cx.dispatch_action(action); + this.cancel(&Default::default(), cx) + }) + }) + .detach_and_log_err(cx); + } else { + cx.propagate() + } + } } impl ContextMenuItem { @@ -167,6 +208,22 @@ impl Render for ContextMenu { .on_action(cx.listener(ContextMenu::select_prev)) .on_action(cx.listener(ContextMenu::confirm)) .on_action(cx.listener(ContextMenu::cancel)) + .when(!self.delayed, |mut el| { + for item in self.items.iter() { + if let ContextMenuItem::Entry { + action: Some(action), + .. + } = item + { + el = el.on_boxed_action( + action, + cx.listener(ContextMenu::on_action_dispatch), + ); + } + } + el + }) + .on_blur(cx.listener(|this, _, cx| this.cancel(&Default::default(), cx))) .flex_none() .child( List::new().children(self.items.iter().enumerate().map( @@ -176,28 +233,38 @@ impl Render for ContextMenu { ListSubHeader::new(header.clone()).into_any_element() } ContextMenuItem::Entry { - label: entry, - handler: callback, - key_binding, + label, + handler, + icon, + action, } => { - let callback = callback.clone(); + let handler = handler.clone(); let dismiss = cx.listener(|_, _, cx| cx.emit(DismissEvent)); - ListItem::new(entry.clone()) + let label_element = if let Some(icon) = icon { + h_stack() + .gap_1() + .child(Label::new(label.clone())) + .child(IconElement::new(*icon)) + .into_any_element() + } else { + Label::new(label.clone()).into_any_element() + }; + + ListItem::new(label.clone()) .child( h_stack() .w_full() .justify_between() - .child(Label::new(entry.clone())) - .children( - key_binding - .clone() - .map(|binding| div().ml_1().child(binding)), - ), + .child(label_element) + .children(action.as_ref().and_then(|action| { + KeyBinding::for_action(&**action, cx) + .map(|binding| div().ml_1().child(binding)) + })), ) .selected(Some(ix) == self.selected_index) .on_click(move |event, cx| { - callback(cx); + handler(cx); dismiss(event, cx) }) .into_any_element() @@ -208,174 +275,3 @@ impl Render for ContextMenu { ) } } - -pub struct MenuHandle { - id: ElementId, - child_builder: Option AnyElement + 'static>>, - menu_builder: Option View + 'static>>, - anchor: Option, - attach: Option, -} - -impl MenuHandle { - pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View + 'static) -> Self { - self.menu_builder = Some(Rc::new(f)); - self - } - - pub fn child(mut self, f: impl FnOnce(bool) -> R + 'static) -> Self { - self.child_builder = Some(Box::new(|b| f(b).into_element().into_any())); - self - } - - /// anchor defines which corner of the menu to anchor to the attachment point - /// (by default the cursor position, but see attach) - pub fn anchor(mut self, anchor: AnchorCorner) -> Self { - self.anchor = Some(anchor); - self - } - - /// attach defines which corner of the handle to attach the menu's anchor to - pub fn attach(mut self, attach: AnchorCorner) -> Self { - self.attach = Some(attach); - self - } -} - -pub fn menu_handle(id: impl Into) -> MenuHandle { - MenuHandle { - id: id.into(), - child_builder: None, - menu_builder: None, - anchor: None, - attach: None, - } -} - -pub struct MenuHandleState { - menu: Rc>>>, - position: Rc>>, - child_layout_id: Option, - child_element: Option, - menu_element: Option, -} - -impl Element for MenuHandle { - type State = MenuHandleState; - - fn layout( - &mut self, - element_state: Option, - cx: &mut WindowContext, - ) -> (gpui::LayoutId, Self::State) { - let (menu, position) = if let Some(element_state) = element_state { - (element_state.menu, element_state.position) - } else { - (Rc::default(), Rc::default()) - }; - - let mut menu_layout_id = None; - - let menu_element = menu.borrow_mut().as_mut().map(|menu| { - let mut overlay = overlay().snap_to_window(); - if let Some(anchor) = self.anchor { - overlay = overlay.anchor(anchor); - } - overlay = overlay.position(*position.borrow()); - - let mut element = overlay.child(menu.clone()).into_any(); - menu_layout_id = Some(element.layout(cx)); - element - }); - - let mut child_element = self - .child_builder - .take() - .map(|child_builder| (child_builder)(menu.borrow().is_some())); - - let child_layout_id = child_element - .as_mut() - .map(|child_element| child_element.layout(cx)); - - let layout_id = cx.request_layout( - &gpui::Style::default(), - menu_layout_id.into_iter().chain(child_layout_id), - ); - - ( - layout_id, - MenuHandleState { - menu, - position, - child_element, - child_layout_id, - menu_element, - }, - ) - } - - fn paint( - self, - bounds: Bounds, - element_state: &mut Self::State, - cx: &mut WindowContext, - ) { - if let Some(child) = element_state.child_element.take() { - child.paint(cx); - } - - if let Some(menu) = element_state.menu_element.take() { - menu.paint(cx); - return; - } - - let Some(builder) = self.menu_builder else { - return; - }; - let menu = element_state.menu.clone(); - let position = element_state.position.clone(); - let attach = self.attach.clone(); - let child_layout_id = element_state.child_layout_id.clone(); - - cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { - if phase == DispatchPhase::Bubble - && event.button == MouseButton::Right - && bounds.contains_point(&event.position) - { - cx.stop_propagation(); - cx.prevent_default(); - - let new_menu = (builder)(cx); - let menu2 = menu.clone(); - cx.subscribe(&new_menu, move |_modal, _: &DismissEvent, cx| { - *menu2.borrow_mut() = None; - cx.notify(); - }) - .detach(); - cx.focus_view(&new_menu); - *menu.borrow_mut() = Some(new_menu); - - *position.borrow_mut() = if attach.is_some() && child_layout_id.is_some() { - attach - .unwrap() - .corner(cx.layout_bounds(child_layout_id.unwrap())) - } else { - cx.mouse_position() - }; - cx.notify(); - } - }); - } -} - -impl IntoElement for MenuHandle { - type Element = Self; - - fn element_id(&self) -> Option { - Some(self.id.clone()) - } - - fn into_element(self) -> Self::Element { - self - } -} diff --git a/crates/ui2/src/components/icon.rs b/crates/ui2/src/components/icon.rs index 12b3e577926eea3972fb65d76a6ca8f80c32019d..a993a54e15463d14cbdf8c14325aec96480204e6 100644 --- a/crates/ui2/src/components/icon.rs +++ b/crates/ui2/src/components/icon.rs @@ -27,6 +27,7 @@ pub enum Icon { Bolt, CaseSensitive, Check, + Copy, ChevronDown, ChevronLeft, ChevronRight, @@ -54,6 +55,7 @@ pub enum Icon { FolderX, Hash, InlayHint, + Link, MagicWand, MagnifyingGlass, MailOpen, @@ -99,6 +101,7 @@ impl Icon { Icon::Bolt => "icons/bolt.svg", Icon::CaseSensitive => "icons/case_insensitive.svg", Icon::Check => "icons/check.svg", + Icon::Copy => "icons/copy.svg", Icon::ChevronDown => "icons/chevron_down.svg", Icon::ChevronLeft => "icons/chevron_left.svg", Icon::ChevronRight => "icons/chevron_right.svg", @@ -126,6 +129,7 @@ impl Icon { Icon::FolderX => "icons/stop_sharing.svg", Icon::Hash => "icons/hash.svg", Icon::InlayHint => "icons/inlay_hint.svg", + Icon::Link => "icons/link.svg", Icon::MagicWand => "icons/magic-wand.svg", Icon::MagnifyingGlass => "icons/magnifying_glass.svg", Icon::MailOpen => "icons/mail-open.svg", diff --git a/crates/ui2/src/components/keybinding.rs b/crates/ui2/src/components/keybinding.rs index 993e2f323e7d2305cf4963f1fbd780a77441f208..c4054fa1a434e677c3480740c31ee55ec45cb419 100644 --- a/crates/ui2/src/components/keybinding.rs +++ b/crates/ui2/src/components/keybinding.rs @@ -1,5 +1,5 @@ use crate::{h_stack, prelude::*, Icon, IconElement, IconSize}; -use gpui::{relative, rems, Action, Div, IntoElement, Keystroke}; +use gpui::{relative, rems, Action, Div, FocusHandle, IntoElement, Keystroke}; #[derive(IntoElement, Clone)] pub struct KeyBinding { @@ -49,12 +49,21 @@ impl RenderOnce for KeyBinding { impl KeyBinding { pub fn for_action(action: &dyn Action, cx: &mut WindowContext) -> Option { - // todo! this last is arbitrary, we want to prefer users key bindings over defaults, - // and vim over normal (in vim mode), etc. let key_binding = cx.bindings_for_action(action).last().cloned()?; Some(Self::new(key_binding)) } + // like for_action(), but lets you specify the context from which keybindings + // are matched. + pub fn for_action_in( + action: &dyn Action, + focus: &FocusHandle, + cx: &mut WindowContext, + ) -> Option { + let key_binding = cx.bindings_for_action_in(action, focus).last().cloned()?; + Some(Self::new(key_binding)) + } + fn icon_for_key(keystroke: &Keystroke) -> Option { let mut icon: Option = None; diff --git a/crates/ui2/src/components/label.rs b/crates/ui2/src/components/label.rs index 562131a96975ad2f8c5986bac2bfdd723c20032d..7aeda3e850fbe91463f6f3f6a0298cb3fc7fa5a7 100644 --- a/crates/ui2/src/components/label.rs +++ b/crates/ui2/src/components/label.rs @@ -1,6 +1,8 @@ +use std::ops::Range; + use crate::prelude::*; use crate::styled_ext::StyledExt; -use gpui::{relative, Div, IntoElement, StyledText, TextRun, WindowContext}; +use gpui::{relative, Div, HighlightStyle, IntoElement, StyledText, WindowContext}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Default)] pub enum LabelSize { @@ -99,38 +101,32 @@ impl RenderOnce for HighlightedLabel { fn render(self, cx: &mut WindowContext) -> Self::Rendered { let highlight_color = cx.theme().colors().text_accent; - let mut text_style = cx.text_style().clone(); let mut highlight_indices = self.highlight_indices.iter().copied().peekable(); - - let mut runs: Vec = Vec::new(); - - for (char_ix, char) in self.label.char_indices() { - let mut color = self.color.color(cx); - - if let Some(highlight_ix) = highlight_indices.peek() { - if char_ix == *highlight_ix { - color = highlight_color; - highlight_indices.next(); + let mut highlights: Vec<(Range, HighlightStyle)> = Vec::new(); + + while let Some(start_ix) = highlight_indices.next() { + let mut end_ix = start_ix; + + loop { + end_ix = end_ix + self.label[end_ix..].chars().next().unwrap().len_utf8(); + if let Some(&next_ix) = highlight_indices.peek() { + if next_ix == end_ix { + end_ix = next_ix; + highlight_indices.next(); + continue; + } } + break; } - let last_run = runs.last_mut(); - let start_new_run = if let Some(last_run) = last_run { - if color == last_run.color { - last_run.len += char.len_utf8(); - false - } else { - true - } - } else { - true - }; - - if start_new_run { - text_style.color = color; - runs.push(text_style.to_run(char.len_utf8())) - } + highlights.push(( + start_ix..end_ix, + HighlightStyle { + color: Some(highlight_color), + ..Default::default() + }, + )); } div() @@ -150,7 +146,7 @@ impl RenderOnce for HighlightedLabel { LabelSize::Default => this.text_ui(), LabelSize::Small => this.text_ui_sm(), }) - .child(StyledText::new(self.label).with_runs(runs)) + .child(StyledText::new(self.label).with_highlights(&cx.text_style(), highlights)) } } diff --git a/crates/ui2/src/components/list.rs b/crates/ui2/src/components/list.rs index aafd04539149a703ab48e35fe8401f3b7db81f18..88650b6ae8d2aa8d60cf0dbe682474f417919a9d 100644 --- a/crates/ui2/src/components/list.rs +++ b/crates/ui2/src/components/list.rs @@ -1,73 +1,11 @@ +mod list; mod list_header; mod list_item; mod list_separator; mod list_sub_header; -use gpui::{AnyElement, Div}; -use smallvec::SmallVec; - -use crate::prelude::*; -use crate::{v_stack, Label}; - +pub use list::*; pub use list_header::*; pub use list_item::*; pub use list_separator::*; pub use list_sub_header::*; - -#[derive(IntoElement)] -pub struct List { - /// Message to display when the list is empty - /// Defaults to "No items" - empty_message: SharedString, - header: Option, - toggle: Option, - children: SmallVec<[AnyElement; 2]>, -} - -impl List { - pub fn new() -> Self { - Self { - empty_message: "No items".into(), - header: None, - toggle: None, - children: SmallVec::new(), - } - } - - pub fn empty_message(mut self, empty_message: impl Into) -> Self { - self.empty_message = empty_message.into(); - self - } - - pub fn header(mut self, header: ListHeader) -> Self { - self.header = Some(header); - self - } - - pub fn toggle(mut self, toggle: impl Into>) -> Self { - self.toggle = toggle.into(); - self - } -} - -impl ParentElement for List { - fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { - &mut self.children - } -} - -impl RenderOnce for List { - type Rendered = Div; - - fn render(self, _cx: &mut WindowContext) -> Self::Rendered { - v_stack() - .w_full() - .py_1() - .children(self.header.map(|header| header)) - .map(|this| match (self.children.is_empty(), self.toggle) { - (false, _) => this.children(self.children), - (true, Some(false)) => this, - (true, _) => this.child(Label::new(self.empty_message.clone()).color(Color::Muted)), - }) - } -} diff --git a/crates/ui2/src/components/list/list.rs b/crates/ui2/src/components/list/list.rs new file mode 100644 index 0000000000000000000000000000000000000000..fdfe256bd6b40b3c3926e74d517c28dc1f2f9216 --- /dev/null +++ b/crates/ui2/src/components/list/list.rs @@ -0,0 +1,60 @@ +use gpui::{AnyElement, Div}; +use smallvec::SmallVec; + +use crate::{prelude::*, v_stack, Label, ListHeader}; + +#[derive(IntoElement)] +pub struct List { + /// Message to display when the list is empty + /// Defaults to "No items" + empty_message: SharedString, + header: Option, + toggle: Option, + children: SmallVec<[AnyElement; 2]>, +} + +impl List { + pub fn new() -> Self { + Self { + empty_message: "No items".into(), + header: None, + toggle: None, + children: SmallVec::new(), + } + } + + pub fn empty_message(mut self, empty_message: impl Into) -> Self { + self.empty_message = empty_message.into(); + self + } + + pub fn header(mut self, header: impl Into>) -> Self { + self.header = header.into(); + self + } + + pub fn toggle(mut self, toggle: impl Into>) -> Self { + self.toggle = toggle.into(); + self + } +} + +impl ParentElement for List { + fn children_mut(&mut self) -> &mut SmallVec<[AnyElement; 2]> { + &mut self.children + } +} + +impl RenderOnce for List { + type Rendered = Div; + + fn render(self, _cx: &mut WindowContext) -> Self::Rendered { + v_stack().w_full().py_1().children(self.header).map(|this| { + match (self.children.is_empty(), self.toggle) { + (false, _) => this.children(self.children), + (true, Some(false)) => this, + (true, _) => this.child(Label::new(self.empty_message.clone()).color(Color::Muted)), + } + }) + } +} diff --git a/crates/ui2/src/components/list/list_header.rs b/crates/ui2/src/components/list/list_header.rs index 431665ffd34204b9861d174065963a2b36dd46ab..799b1c5dae886aba2e323def0cf9270b132ba2cb 100644 --- a/crates/ui2/src/components/list/list_header.rs +++ b/crates/ui2/src/components/list/list_header.rs @@ -1,22 +1,16 @@ use std::rc::Rc; -use gpui::{ClickEvent, Div}; +use gpui::{AnyElement, ClickEvent, Div}; +use smallvec::SmallVec; use crate::prelude::*; -use crate::{h_stack, Disclosure, Icon, IconButton, IconElement, IconSize, Label}; - -pub enum ListHeaderMeta { - Tools(Vec), - // TODO: This should be a button - Button(Label), - Text(Label), -} +use crate::{h_stack, Disclosure, Icon, IconElement, IconSize, Label}; #[derive(IntoElement)] pub struct ListHeader { label: SharedString, left_icon: Option, - meta: Option, + meta: SmallVec<[AnyElement; 2]>, toggle: Option, on_toggle: Option>, inset: bool, @@ -28,7 +22,7 @@ impl ListHeader { Self { label: label.into(), left_icon: None, - meta: None, + meta: SmallVec::new(), inset: false, toggle: None, on_toggle: None, @@ -49,17 +43,13 @@ impl ListHeader { self } - pub fn left_icon(mut self, left_icon: Option) -> Self { - self.left_icon = left_icon; + pub fn left_icon(mut self, left_icon: impl Into>) -> Self { + self.left_icon = left_icon.into(); self } - pub fn right_button(self, button: IconButton) -> Self { - self.meta(Some(ListHeaderMeta::Tools(vec![button]))) - } - - pub fn meta(mut self, meta: Option) -> Self { - self.meta = meta; + pub fn meta(mut self, meta: impl IntoElement) -> Self { + self.meta.push(meta.into_any_element()); self } } @@ -75,18 +65,6 @@ impl RenderOnce for ListHeader { type Rendered = Div; fn render(self, cx: &mut WindowContext) -> Self::Rendered { - let meta = match self.meta { - Some(ListHeaderMeta::Tools(icons)) => div().child( - h_stack() - .gap_2() - .items_center() - .children(icons.into_iter().map(|i| i.icon_color(Color::Muted))), - ), - Some(ListHeaderMeta::Button(label)) => div().child(label), - Some(ListHeaderMeta::Text(label)) => div().child(label), - None => div(), - }; - h_stack().w_full().relative().child( div() .h_5() @@ -120,7 +98,7 @@ impl RenderOnce for ListHeader { .map(|is_open| Disclosure::new(is_open).on_toggle(self.on_toggle)), ), ) - .child(meta), + .child(h_stack().gap_2().items_center().children(self.meta)), ) } } diff --git a/crates/ui2/src/components/list/list_item.rs b/crates/ui2/src/components/list/list_item.rs index 85198416cd94e7b498fa2c9283a175d390b77a82..529f2c2a58765caeeb20ce7ecf6f12080c2edc0b 100644 --- a/crates/ui2/src/components/list/list_item.rs +++ b/crates/ui2/src/components/list/list_item.rs @@ -1,7 +1,8 @@ use std::rc::Rc; use gpui::{ - px, AnyElement, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels, Stateful, + px, AnyElement, AnyView, ClickEvent, Div, ImageSource, MouseButton, MouseDownEvent, Pixels, + Stateful, }; use smallvec::SmallVec; @@ -21,6 +22,7 @@ pub struct ListItem { inset: bool, on_click: Option>, on_toggle: Option>, + tooltip: Option AnyView + 'static>>, on_secondary_mouse_down: Option>, children: SmallVec<[AnyElement; 2]>, } @@ -38,6 +40,7 @@ impl ListItem { on_click: None, on_secondary_mouse_down: None, on_toggle: None, + tooltip: None, children: SmallVec::new(), } } @@ -55,6 +58,11 @@ impl ListItem { self } + pub fn tooltip(mut self, tooltip: impl Fn(&mut WindowContext) -> AnyView + 'static) -> Self { + self.tooltip = Some(Box::new(tooltip)); + self + } + pub fn inset(mut self, inset: bool) -> Self { self.inset = inset; self @@ -149,6 +157,7 @@ impl RenderOnce for ListItem { (on_mouse_down)(event, cx) }) }) + .when_some(self.tooltip, |this, tooltip| this.tooltip(tooltip)) .child( div() .when(self.inset, |this| this.px_2()) diff --git a/crates/ui2/src/components/popover_menu.rs b/crates/ui2/src/components/popover_menu.rs new file mode 100644 index 0000000000000000000000000000000000000000..4b5144e7c7de468e823cd7e60d062083ea065386 --- /dev/null +++ b/crates/ui2/src/components/popover_menu.rs @@ -0,0 +1,231 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + overlay, point, px, rems, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase, + Element, ElementId, InteractiveBounds, IntoElement, LayoutId, ManagedView, MouseDownEvent, + ParentElement, Pixels, Point, View, VisualContext, WindowContext, +}; + +use crate::{Clickable, Selectable}; + +pub trait PopoverTrigger: IntoElement + Clickable + Selectable + 'static {} + +impl PopoverTrigger for T {} + +pub struct PopoverMenu { + id: ElementId, + child_builder: Option< + Box< + dyn FnOnce( + Rc>>>, + Option View + 'static>>, + ) -> AnyElement + + 'static, + >, + >, + menu_builder: Option View + 'static>>, + anchor: AnchorCorner, + attach: Option, + offset: Option>, +} + +impl PopoverMenu { + pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View + 'static) -> Self { + self.menu_builder = Some(Rc::new(f)); + self + } + + pub fn trigger(mut self, t: T) -> Self { + self.child_builder = Some(Box::new(|menu, builder| { + let open = menu.borrow().is_some(); + t.selected(open) + .when_some(builder, |el, builder| { + el.on_click({ + move |_, cx| { + let new_menu = (builder)(cx); + let menu2 = menu.clone(); + let previous_focus_handle = cx.focused(); + + cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| { + if modal.focus_handle(cx).contains_focused(cx) { + if previous_focus_handle.is_some() { + cx.focus(&previous_focus_handle.as_ref().unwrap()) + } + } + *menu2.borrow_mut() = None; + cx.notify(); + }) + .detach(); + cx.focus_view(&new_menu); + *menu.borrow_mut() = Some(new_menu); + } + }) + }) + .into_any_element() + })); + self + } + + /// anchor defines which corner of the menu to anchor to the attachment point + /// (by default the cursor position, but see attach) + pub fn anchor(mut self, anchor: AnchorCorner) -> Self { + self.anchor = anchor; + self + } + + /// attach defines which corner of the handle to attach the menu's anchor to + pub fn attach(mut self, attach: AnchorCorner) -> Self { + self.attach = Some(attach); + self + } + + /// offset offsets the position of the content by that many pixels. + pub fn offset(mut self, offset: Point) -> Self { + self.offset = Some(offset); + self + } + + fn resolved_attach(&self) -> AnchorCorner { + self.attach.unwrap_or_else(|| match self.anchor { + AnchorCorner::TopLeft => AnchorCorner::BottomLeft, + AnchorCorner::TopRight => AnchorCorner::BottomRight, + AnchorCorner::BottomLeft => AnchorCorner::TopLeft, + AnchorCorner::BottomRight => AnchorCorner::TopRight, + }) + } + + fn resolved_offset(&self, cx: &WindowContext) -> Point { + self.offset.unwrap_or_else(|| { + // Default offset = 4px padding + 1px border + let offset = rems(5. / 16.) * cx.rem_size(); + match self.anchor { + AnchorCorner::TopRight | AnchorCorner::BottomRight => point(offset, px(0.)), + AnchorCorner::TopLeft | AnchorCorner::BottomLeft => point(-offset, px(0.)), + } + }) + } +} + +pub fn popover_menu(id: impl Into) -> PopoverMenu { + PopoverMenu { + id: id.into(), + child_builder: None, + menu_builder: None, + anchor: AnchorCorner::TopLeft, + attach: None, + offset: None, + } +} + +pub struct PopoverMenuState { + child_layout_id: Option, + child_element: Option, + child_bounds: Option>, + menu_element: Option, + menu: Rc>>>, +} + +impl Element for PopoverMenu { + type State = PopoverMenuState; + + fn layout( + &mut self, + element_state: Option, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::State) { + let mut menu_layout_id = None; + + let (menu, child_bounds) = if let Some(element_state) = element_state { + (element_state.menu, element_state.child_bounds) + } else { + (Rc::default(), None) + }; + + let menu_element = menu.borrow_mut().as_mut().map(|menu| { + let mut overlay = overlay().snap_to_window().anchor(self.anchor); + + if let Some(child_bounds) = child_bounds { + overlay = overlay.position( + self.resolved_attach().corner(child_bounds) + self.resolved_offset(cx), + ); + } + + let mut element = overlay.child(menu.clone()).into_any(); + menu_layout_id = Some(element.layout(cx)); + element + }); + + let mut child_element = self + .child_builder + .take() + .map(|child_builder| (child_builder)(menu.clone(), self.menu_builder.clone())); + + let child_layout_id = child_element + .as_mut() + .map(|child_element| child_element.layout(cx)); + + let layout_id = cx.request_layout( + &gpui::Style::default(), + menu_layout_id.into_iter().chain(child_layout_id), + ); + + ( + layout_id, + PopoverMenuState { + menu, + child_element, + child_layout_id, + menu_element, + child_bounds, + }, + ) + } + + fn paint( + self, + _: Bounds, + element_state: &mut Self::State, + cx: &mut WindowContext, + ) { + if let Some(child) = element_state.child_element.take() { + child.paint(cx); + } + + if let Some(child_layout_id) = element_state.child_layout_id.take() { + element_state.child_bounds = Some(cx.layout_bounds(child_layout_id)); + } + + if let Some(menu) = element_state.menu_element.take() { + menu.paint(cx); + + if let Some(child_bounds) = element_state.child_bounds { + let interactive_bounds = InteractiveBounds { + bounds: child_bounds, + stacking_order: cx.stacking_order().clone(), + }; + + // Mouse-downing outside the menu dismisses it, so we don't + // want a click on the toggle to re-open it. + cx.on_mouse_event(move |e: &MouseDownEvent, phase, cx| { + if phase == DispatchPhase::Bubble + && interactive_bounds.visibly_contains(&e.position, cx) + { + cx.stop_propagation() + } + }) + } + } + } +} + +impl IntoElement for PopoverMenu { + type Element = Self; + + fn element_id(&self) -> Option { + Some(self.id.clone()) + } + + fn into_element(self) -> Self::Element { + self + } +} diff --git a/crates/ui2/src/components/right_click_menu.rs b/crates/ui2/src/components/right_click_menu.rs new file mode 100644 index 0000000000000000000000000000000000000000..27c4fdab960f93bf9ac950c455d62d90f4ce049e --- /dev/null +++ b/crates/ui2/src/components/right_click_menu.rs @@ -0,0 +1,185 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + overlay, AnchorCorner, AnyElement, Bounds, DismissEvent, DispatchPhase, Element, ElementId, + IntoElement, LayoutId, ManagedView, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, + View, VisualContext, WindowContext, +}; + +pub struct RightClickMenu { + id: ElementId, + child_builder: Option AnyElement + 'static>>, + menu_builder: Option View + 'static>>, + anchor: Option, + attach: Option, +} + +impl RightClickMenu { + pub fn menu(mut self, f: impl Fn(&mut WindowContext) -> View + 'static) -> Self { + self.menu_builder = Some(Rc::new(f)); + self + } + + pub fn trigger(mut self, e: E) -> Self { + self.child_builder = Some(Box::new(move |_| e.into_any_element())); + self + } + + /// anchor defines which corner of the menu to anchor to the attachment point + /// (by default the cursor position, but see attach) + pub fn anchor(mut self, anchor: AnchorCorner) -> Self { + self.anchor = Some(anchor); + self + } + + /// attach defines which corner of the handle to attach the menu's anchor to + pub fn attach(mut self, attach: AnchorCorner) -> Self { + self.attach = Some(attach); + self + } +} + +pub fn right_click_menu(id: impl Into) -> RightClickMenu { + RightClickMenu { + id: id.into(), + child_builder: None, + menu_builder: None, + anchor: None, + attach: None, + } +} + +pub struct MenuHandleState { + menu: Rc>>>, + position: Rc>>, + child_layout_id: Option, + child_element: Option, + menu_element: Option, +} + +impl Element for RightClickMenu { + type State = MenuHandleState; + + fn layout( + &mut self, + element_state: Option, + cx: &mut WindowContext, + ) -> (gpui::LayoutId, Self::State) { + let (menu, position) = if let Some(element_state) = element_state { + (element_state.menu, element_state.position) + } else { + (Rc::default(), Rc::default()) + }; + + let mut menu_layout_id = None; + + let menu_element = menu.borrow_mut().as_mut().map(|menu| { + let mut overlay = overlay().snap_to_window(); + if let Some(anchor) = self.anchor { + overlay = overlay.anchor(anchor); + } + overlay = overlay.position(*position.borrow()); + + let mut element = overlay.child(menu.clone()).into_any(); + menu_layout_id = Some(element.layout(cx)); + element + }); + + let mut child_element = self + .child_builder + .take() + .map(|child_builder| (child_builder)(menu.borrow().is_some())); + + let child_layout_id = child_element + .as_mut() + .map(|child_element| child_element.layout(cx)); + + let layout_id = cx.request_layout( + &gpui::Style::default(), + menu_layout_id.into_iter().chain(child_layout_id), + ); + + ( + layout_id, + MenuHandleState { + menu, + position, + child_element, + child_layout_id, + menu_element, + }, + ) + } + + fn paint( + self, + bounds: Bounds, + element_state: &mut Self::State, + cx: &mut WindowContext, + ) { + if let Some(child) = element_state.child_element.take() { + child.paint(cx); + } + + if let Some(menu) = element_state.menu_element.take() { + menu.paint(cx); + return; + } + + let Some(builder) = self.menu_builder else { + return; + }; + let menu = element_state.menu.clone(); + let position = element_state.position.clone(); + let attach = self.attach.clone(); + let child_layout_id = element_state.child_layout_id.clone(); + + cx.on_mouse_event(move |event: &MouseDownEvent, phase, cx| { + if phase == DispatchPhase::Bubble + && event.button == MouseButton::Right + && bounds.contains_point(&event.position) + { + cx.stop_propagation(); + cx.prevent_default(); + + let new_menu = (builder)(cx); + let menu2 = menu.clone(); + let previous_focus_handle = cx.focused(); + + cx.subscribe(&new_menu, move |modal, _: &DismissEvent, cx| { + if modal.focus_handle(cx).contains_focused(cx) { + if previous_focus_handle.is_some() { + cx.focus(&previous_focus_handle.as_ref().unwrap()) + } + } + *menu2.borrow_mut() = None; + cx.notify(); + }) + .detach(); + cx.focus_view(&new_menu); + *menu.borrow_mut() = Some(new_menu); + + *position.borrow_mut() = if attach.is_some() && child_layout_id.is_some() { + attach + .unwrap() + .corner(cx.layout_bounds(child_layout_id.unwrap())) + } else { + cx.mouse_position() + }; + cx.notify(); + } + }); + } +} + +impl IntoElement for RightClickMenu { + type Element = Self; + + fn element_id(&self) -> Option { + Some(self.id.clone()) + } + + fn into_element(self) -> Self::Element { + self + } +} diff --git a/crates/ui2/src/components/stories.rs b/crates/ui2/src/components/stories.rs index e870515caf170b3477c0c7a3ff75abf3d67c7780..113c2679b7f2b9cfec58d92d51060f7712b8a384 100644 --- a/crates/ui2/src/components/stories.rs +++ b/crates/ui2/src/components/stories.rs @@ -8,6 +8,7 @@ mod icon_button; mod keybinding; mod label; mod list; +mod list_header; mod list_item; pub use avatar::*; @@ -20,4 +21,5 @@ pub use icon_button::*; pub use keybinding::*; pub use label::*; pub use list::*; +pub use list_header::*; pub use list_item::*; diff --git a/crates/ui2/src/components/stories/button.rs b/crates/ui2/src/components/stories/button.rs index db8aa40cf79d0d1fe0b063b995574f7f287c817c..9fe4f55dcb35c820c4553ef23e462714a51da5a0 100644 --- a/crates/ui2/src/components/stories/button.rs +++ b/crates/ui2/src/components/stories/button.rs @@ -1,7 +1,7 @@ use gpui::{Div, Render}; use story::Story; -use crate::prelude::*; +use crate::{prelude::*, Icon}; use crate::{Button, ButtonStyle}; pub struct ButtonStory; @@ -14,6 +14,24 @@ impl Render for ButtonStory { .child(Story::title_for::