From 20bffaf93f3598d129ab654493bb866af33a6152 Mon Sep 17 00:00:00 2001 From: Carroll Wainwright Date: Sun, 24 Nov 2024 15:52:11 -0800 Subject: [PATCH 001/215] python: Highlight docstrings for classes and modules (#20486) Release Notes: - Add `string.doc` python syntax highlighting to class and module-level docstrings. Previously, only docstrings inside python functions were labeled as `string.doc`, but docstrings can exist at the class or module level too. This adds the more specific string type for each of those. *Before*: image *After*: image --- crates/languages/src/python/highlights.scm | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/python/highlights.scm b/crates/languages/src/python/highlights.scm index 6c3f027c19..98ed203969 100644 --- a/crates/languages/src/python/highlights.scm +++ b/crates/languages/src/python/highlights.scm @@ -96,7 +96,16 @@ "def" name: (_) (parameters)? - body: (block (expression_statement (string) @string.doc))) + body: (block . (expression_statement (string) @string.doc))) + +(class_definition + body: (block + . (comment) @comment* + . (expression_statement (string) @string.doc))) + +(module + . (comment) @comment* + . (expression_statement (string) @string.doc)) (module (expression_statement (assignment)) From e85848a69508d901fc33f8d8883e1f7c356fb8a2 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 25 Nov 2024 00:54:47 +0100 Subject: [PATCH 002/215] pylsp: Prefer version from user venv (#21069) Closes #ISSUE Release Notes: - pylsp will now use version installed in user venv, if one is available. --- crates/language/src/language.rs | 9 ++++- .../src/extension_lsp_adapter.rs | 1 + crates/languages/src/c.rs | 1 + crates/languages/src/go.rs | 1 + crates/languages/src/python.rs | 37 +++++++++---------- crates/languages/src/rust.rs | 1 + crates/languages/src/vtsls.rs | 1 + crates/project/src/lsp_store.rs | 9 ++++- 8 files changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 580955a98b..58be8a4dc3 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -201,13 +201,14 @@ impl CachedLspAdapter { pub async fn get_language_server_command( self: Arc, delegate: Arc, + toolchains: Arc, binary_options: LanguageServerBinaryOptions, cx: &mut AsyncAppContext, ) -> Result { let cached_binary = self.cached_binary.lock().await; self.adapter .clone() - .get_language_server_command(delegate, binary_options, cached_binary, cx) + .get_language_server_command(delegate, toolchains, binary_options, cached_binary, cx) .await } @@ -281,6 +282,7 @@ pub trait LspAdapter: 'static + Send + Sync { fn get_language_server_command<'a>( self: Arc, delegate: Arc, + toolchains: Arc, binary_options: LanguageServerBinaryOptions, mut cached_binary: futures::lock::MutexGuard<'a, Option>, cx: &'a mut AsyncAppContext, @@ -298,7 +300,7 @@ pub trait LspAdapter: 'static + Send + Sync { // because we don't want to download and overwrite our global one // for each worktree we might have open. if binary_options.allow_path_lookup { - if let Some(binary) = self.check_if_user_installed(delegate.as_ref(), cx).await { + if let Some(binary) = self.check_if_user_installed(delegate.as_ref(), toolchains, cx).await { log::info!( "found user-installed language server for {}. path: {:?}, arguments: {:?}", self.name().0, @@ -357,6 +359,7 @@ pub trait LspAdapter: 'static + Send + Sync { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { None @@ -1665,6 +1668,7 @@ impl LspAdapter for FakeLspAdapter { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { Some(self.language_server_binary.clone()) @@ -1673,6 +1677,7 @@ impl LspAdapter for FakeLspAdapter { fn get_language_server_command<'a>( self: Arc, _: Arc, + _: Arc, _: LanguageServerBinaryOptions, _: futures::lock::MutexGuard<'a, Option>, _: &'a mut AsyncAppContext, diff --git a/crates/language_extension/src/extension_lsp_adapter.rs b/crates/language_extension/src/extension_lsp_adapter.rs index eab9529fe0..3286e09e2d 100644 --- a/crates/language_extension/src/extension_lsp_adapter.rs +++ b/crates/language_extension/src/extension_lsp_adapter.rs @@ -115,6 +115,7 @@ impl LspAdapter for ExtensionLspAdapter { fn get_language_server_command<'a>( self: Arc, delegate: Arc, + _: Arc, _: LanguageServerBinaryOptions, _: futures::lock::MutexGuard<'a, Option>, _: &'a mut AsyncAppContext, diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 5bfb7f0bc2..8d0369f0e0 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -24,6 +24,7 @@ impl super::LspAdapter for CLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; diff --git a/crates/languages/src/go.rs b/crates/languages/src/go.rs index b3073d7eaa..6e2b5d464e 100644 --- a/crates/languages/src/go.rs +++ b/crates/languages/src/go.rs @@ -67,6 +67,7 @@ impl super::LspAdapter for GoLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let path = delegate.which(Self::SERVER_NAME.as_ref()).await?; diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 2cedd704cf..8736a12942 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -79,6 +79,7 @@ impl LspAdapter for PythonLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let node = delegate.which("node".as_ref()).await?; @@ -753,33 +754,29 @@ impl LspAdapter for PyLspAdapter { async fn check_if_user_installed( &self, - _: &dyn LspAdapterDelegate, - _: &AsyncAppContext, + delegate: &dyn LspAdapterDelegate, + toolchains: Arc, + cx: &AsyncAppContext, ) -> Option { - // We don't support user-provided pylsp, as global packages are discouraged in Python ecosystem. - None + let venv = toolchains + .active_toolchain( + delegate.worktree_id(), + LanguageName::new("Python"), + &mut cx.clone(), + ) + .await?; + let pylsp_path = Path::new(venv.path.as_ref()).parent()?.join("pylsp"); + pylsp_path.exists().then(|| LanguageServerBinary { + path: venv.path.to_string().into(), + arguments: vec![pylsp_path.into()], + env: None, + }) } async fn fetch_latest_server_version( &self, _: &dyn LspAdapterDelegate, ) -> Result> { - // let uri = "https://pypi.org/pypi/python-lsp-server/json"; - // let mut root_manifest = delegate - // .http_client() - // .get(&uri, Default::default(), true) - // .await?; - // let mut body = Vec::new(); - // root_manifest.body_mut().read_to_end(&mut body).await?; - // let as_str = String::from_utf8(body)?; - // let json = serde_json::Value::from_str(&as_str)?; - // let latest_version = json - // .get("info") - // .and_then(|info| info.get("version")) - // .and_then(|version| version.as_str().map(ToOwned::to_owned)) - // .ok_or_else(|| { - // anyhow!("PyPI response did not contain version info for python-language-server") - // })?; Ok(Box::new(()) as Box<_>) } diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 7f5912d73e..25cddae5a6 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -76,6 +76,7 @@ impl LspAdapter for RustLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let path = delegate.which("rust-analyzer".as_ref()).await?; diff --git a/crates/languages/src/vtsls.rs b/crates/languages/src/vtsls.rs index 0ad9158003..e44e4e295f 100644 --- a/crates/languages/src/vtsls.rs +++ b/crates/languages/src/vtsls.rs @@ -77,6 +77,7 @@ impl LspAdapter for VtslsLspAdapter { async fn check_if_user_installed( &self, delegate: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { let env = delegate.shell_env().await; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 29a4c8e71b..cc326285cb 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -5523,10 +5523,16 @@ impl LspStore { .unwrap_or_default(), allow_binary_download, }; + let toolchains = self.toolchain_store(cx); cx.spawn(|_, mut cx| async move { let binary_result = adapter .clone() - .get_language_server_command(delegate.clone(), lsp_binary_options, &mut cx) + .get_language_server_command( + delegate.clone(), + toolchains, + lsp_binary_options, + &mut cx, + ) .await; delegate.update_status(adapter.name.clone(), LanguageServerBinaryStatus::None); @@ -7783,6 +7789,7 @@ impl LspAdapter for SshLspAdapter { async fn check_if_user_installed( &self, _: &dyn LspAdapterDelegate, + _: Arc, _: &AsyncAppContext, ) -> Option { Some(self.binary.clone()) From 5b0fa6e585dd0b3bb4d81813051b1b8931d24371 Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 25 Nov 2024 01:48:43 -0700 Subject: [PATCH 003/215] Hide AI hints on line ends so we can discuss more (#21128) @bennetbo @as-cii @mrnugget I'm really not liking the hints about AI on every line. It feels too distracting to me and damaging to the user experience. I'm wondering if we can hide them and work with design for other ideas. Or at least talk it through. Release Notes: - N/A --- assets/settings/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 45a211789f..f1071f9676 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -492,7 +492,7 @@ "enabled": true, // Whether to show inline hints showing the keybindings to use the inline assistant and the // assistant panel. - "show_hints": true, + "show_hints": false, // Whether to show the assistant panel button in the status bar. "button": true, // Where to dock the assistant panel. Can be 'left', 'right' or 'bottom'. From aa58cab766a64f79ca8c25ae01b9b9c17ad2d4f0 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 25 Nov 2024 12:21:32 +0100 Subject: [PATCH 004/215] Fix offline workspace deserialization with assistant2 (#21159) Closes #21156 /cc @maxdeviant Release Notes: - N/A --- crates/zed/src/zed.rs | 53 ++++++++++++++++++++++--------------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 086935542c..be6df49a2d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -241,25 +241,6 @@ pub fn initialize_workspace( let prompt_builder = prompt_builder.clone(); cx.spawn(|workspace_handle, mut cx| async move { - let is_assistant2_enabled = if cfg!(test) { - false - } else { - let is_assistant2_feature_flag_enabled = assistant2_feature_flag.await; - release_channel == ReleaseChannel::Dev && is_assistant2_feature_flag_enabled - }; - - let (assistant_panel, assistant2_panel) = if is_assistant2_enabled { - let assistant2_panel = - assistant2::AssistantPanel::load(workspace_handle.clone(), cx.clone()).await?; - - (None, Some(assistant2_panel)) - } else { - let assistant_panel = - assistant::AssistantPanel::load(workspace_handle.clone(), prompt_builder, cx.clone()).await?; - - (Some(assistant_panel), None) - }; - let project_panel = ProjectPanel::load(workspace_handle.clone(), cx.clone()); let outline_panel = OutlinePanel::load(workspace_handle.clone(), cx.clone()); let terminal_panel = TerminalPanel::load(workspace_handle.clone(), cx.clone()); @@ -288,6 +269,33 @@ pub fn initialize_workspace( notification_panel, )?; + workspace_handle.update(&mut cx, |workspace, cx| { + workspace.add_panel(project_panel, cx); + workspace.add_panel(outline_panel, cx); + workspace.add_panel(terminal_panel, cx); + workspace.add_panel(channels_panel, cx); + workspace.add_panel(chat_panel, cx); + workspace.add_panel(notification_panel, cx); + })?; + let is_assistant2_enabled = + if cfg!(test) || release_channel != ReleaseChannel::Dev { + false + } else { + assistant2_feature_flag.await + } + ; + + let (assistant_panel, assistant2_panel) = if is_assistant2_enabled { + let assistant2_panel = + assistant2::AssistantPanel::load(workspace_handle.clone(), cx.clone()).await?; + + (None, Some(assistant2_panel)) + } else { + let assistant_panel = + assistant::AssistantPanel::load(workspace_handle.clone(), prompt_builder, cx.clone()).await?; + + (Some(assistant_panel), None) + }; workspace_handle.update(&mut cx, |workspace, cx| { if let Some(assistant_panel) = assistant_panel { workspace.add_panel(assistant_panel, cx); @@ -296,13 +304,6 @@ pub fn initialize_workspace( if let Some(assistant2_panel) = assistant2_panel { workspace.add_panel(assistant2_panel, cx); } - - workspace.add_panel(project_panel, cx); - workspace.add_panel(outline_panel, cx); - workspace.add_panel(terminal_panel, cx); - workspace.add_panel(channels_panel, cx); - workspace.add_panel(chat_panel, cx); - workspace.add_panel(notification_panel, cx); }) }) .detach(); From 08b214dfb9b9f59e2e1fdff6d2112dde33ca61aa Mon Sep 17 00:00:00 2001 From: Nathan Sobo Date: Mon, 25 Nov 2024 05:27:35 -0700 Subject: [PATCH 005/215] Rename 'chat' to 'thread' in assistant2 (#21141) Release Notes: - N/A --- crates/assistant2/src/assistant.rs | 4 +-- crates/assistant2/src/assistant_panel.rs | 29 +++++++++++-------- .../src/{chat_editor.rs => message_editor.rs} | 6 ++-- 3 files changed, 22 insertions(+), 17 deletions(-) rename crates/assistant2/src/{chat_editor.rs => message_editor.rs} (97%) diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index f8284d9ff5..6a80186525 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,5 +1,5 @@ mod assistant_panel; -mod chat_editor; +mod message_editor; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{Assistant2FeatureFlag, FeatureFlagAppExt}; @@ -7,7 +7,7 @@ use gpui::{actions, AppContext}; pub use crate::assistant_panel::AssistantPanel; -actions!(assistant2, [ToggleFocus, NewChat, ToggleModelSelector]); +actions!(assistant2, [ToggleFocus, NewThread, ToggleModelSelector]); const NAMESPACE: &str = "assistant2"; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 2fa08d7f5e..890020e54a 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -9,8 +9,8 @@ use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::{Pane, Workspace}; -use crate::chat_editor::ChatEditor; -use crate::{NewChat, ToggleFocus, ToggleModelSelector}; +use crate::message_editor::MessageEditor; +use crate::{NewThread, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { cx.observe_new_views( @@ -25,7 +25,7 @@ pub fn init(cx: &mut AppContext) { pub struct AssistantPanel { pane: View, - chat_editor: View, + message_editor: View, } impl AssistantPanel { @@ -47,7 +47,7 @@ impl AssistantPanel { workspace.project().clone(), Default::default(), None, - NewChat.boxed_clone(), + NewThread.boxed_clone(), cx, ); pane.set_can_split(false, cx); @@ -58,7 +58,7 @@ impl AssistantPanel { Self { pane, - chat_editor: cx.new_view(ChatEditor::new), + message_editor: cx.new_view(MessageEditor::new), } } } @@ -136,25 +136,30 @@ impl AssistantPanel { .bg(cx.theme().colors().tab_bar_background) .border_b_1() .border_color(cx.theme().colors().border_variant) - .child(h_flex().child(Label::new("Chat Title Goes Here"))) + .child(h_flex().child(Label::new("Thread Title Goes Here"))) .child( h_flex() .gap(DynamicSpacing::Base08.rems(cx)) .child(self.render_language_model_selector(cx)) .child(Divider::vertical()) .child( - IconButton::new("new-chat", IconName::Plus) + IconButton::new("new-thread", IconName::Plus) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) .tooltip({ let focus_handle = focus_handle.clone(); move |cx| { - Tooltip::for_action_in("New Chat", &NewChat, &focus_handle, cx) + Tooltip::for_action_in( + "New Thread", + &NewThread, + &focus_handle, + cx, + ) } }) .on_click(move |_event, _cx| { - println!("New Chat"); + println!("New Thread"); }), ) .child( @@ -238,8 +243,8 @@ impl Render for AssistantPanel { .key_context("AssistantPanel2") .justify_between() .size_full() - .on_action(cx.listener(|_this, _: &NewChat, _cx| { - println!("Action: New Chat"); + .on_action(cx.listener(|_this, _: &NewThread, _cx| { + println!("Action: New Thread"); })) .child(self.render_toolbar(cx)) .child(v_flex().bg(cx.theme().colors().panel_background)) @@ -247,7 +252,7 @@ impl Render for AssistantPanel { h_flex() .border_t_1() .border_color(cx.theme().colors().border_variant) - .child(self.chat_editor.clone()), + .child(self.message_editor.clone()), ) } } diff --git a/crates/assistant2/src/chat_editor.rs b/crates/assistant2/src/message_editor.rs similarity index 97% rename from crates/assistant2/src/chat_editor.rs rename to crates/assistant2/src/message_editor.rs index 9111f57eac..ee25ad5da7 100644 --- a/crates/assistant2/src/chat_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -4,11 +4,11 @@ use settings::Settings; use theme::ThemeSettings; use ui::prelude::*; -pub struct ChatEditor { +pub struct MessageEditor { editor: View, } -impl ChatEditor { +impl MessageEditor { pub fn new(cx: &mut ViewContext) -> Self { Self { editor: cx.new_view(|cx| { @@ -21,7 +21,7 @@ impl ChatEditor { } } -impl Render for ChatEditor { +impl Render for MessageEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let font_size = TextSize::Default.rems(cx); let line_height = font_size.to_pixels(cx.rem_size()) * 1.3; From b83f104f6eea872e18ea2599497328ed26a5d8b4 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 25 Nov 2024 15:58:45 +0200 Subject: [PATCH 006/215] Do not reuse render cache for nested items whose parents are re-rendered (#21165) Fixes a bug with terminal splits panicking during writing a command in the command input Release Notes: - N/A Co-authored-by: Antonio Scandurra --- crates/gpui/src/view.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/gpui/src/view.rs b/crates/gpui/src/view.rs index 7f10eb25c3..4f35413a27 100644 --- a/crates/gpui/src/view.rs +++ b/crates/gpui/src/view.rs @@ -7,6 +7,7 @@ use crate::{ }; use anyhow::{Context, Result}; use refineable::Refineable; +use std::mem; use std::{ any::{type_name, TypeId}, fmt, @@ -341,11 +342,13 @@ impl Element for AnyView { } } + let refreshing = mem::replace(&mut cx.window.refreshing, true); let prepaint_start = cx.prepaint_index(); let mut element = (self.render)(self, cx); element.layout_as_root(bounds.size.into(), cx); element.prepaint_at(bounds.origin, cx); let prepaint_end = cx.prepaint_index(); + cx.window.refreshing = refreshing; ( Some(element), @@ -382,7 +385,9 @@ impl Element for AnyView { let paint_start = cx.paint_index(); if let Some(element) = element { + let refreshing = mem::replace(&mut cx.window.refreshing, true); element.paint(cx); + cx.window.refreshing = refreshing; } else { cx.reuse_paint(element_state.paint_range.clone()); } From 385c447bbe7c2fb40fef0296aef7a4d1725fbbf4 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 11:05:14 -0500 Subject: [PATCH 007/215] docs: Document context servers (#21170) This PR adds documentation for context servers. Release Notes: - N/A --- .cloudflare/docs-proxy/src/worker.js | 9 ---- docs/src/SUMMARY.md | 5 +- docs/src/assistant/assistant.md | 2 + docs/src/assistant/context-servers.md | 49 ++++++++++++++++++++ docs/src/assistant/model-context-protocol.md | 21 +++++++++ docs/src/extensions/context-servers.md | 39 ++++++++++++++++ docs/src/extensions/developing-extensions.md | 1 + 7 files changed, 116 insertions(+), 10 deletions(-) create mode 100644 docs/src/assistant/context-servers.md create mode 100644 docs/src/assistant/model-context-protocol.md create mode 100644 docs/src/extensions/context-servers.md diff --git a/.cloudflare/docs-proxy/src/worker.js b/.cloudflare/docs-proxy/src/worker.js index b29ddc00f1..f9f441883a 100644 --- a/.cloudflare/docs-proxy/src/worker.js +++ b/.cloudflare/docs-proxy/src/worker.js @@ -3,15 +3,6 @@ export default { const url = new URL(request.url); url.hostname = "docs-anw.pages.dev"; - // These pages were removed, but may still be served due to Cloudflare's - // [asset retention](https://developers.cloudflare.com/pages/configuration/serving-pages/#asset-retention). - if ( - url.pathname === "/docs/assistant/context-servers" || - url.pathname === "/docs/assistant/model-context-protocol" - ) { - return await fetch("https://zed.dev/404"); - } - let res = await fetch(url, request); if (res.status === 404) { diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index bc7ba52869..d807da8193 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -43,6 +43,8 @@ - [Inline Assistant](./assistant/inline-assistant.md) - [Commands](./assistant/commands.md) - [Prompts](./assistant/prompting.md) +- [Context Servers](./assistant/context-servers.md) + - [Model Context Protocol](./assistant/model-context-protocol.md) # Extensions @@ -51,7 +53,8 @@ - [Developing Extensions](./extensions/developing-extensions.md) - [Language Extensions](./extensions/languages.md) - [Theme Extensions](./extensions/themes.md) -- [Slash Commands](./extensions/slash-commands.md) +- [Slash Command Extensions](./extensions/slash-commands.md) +- [Context Server Extensions](./extensions/context-servers.md) # Language Support diff --git a/docs/src/assistant/assistant.md b/docs/src/assistant/assistant.md index ee4796ec02..94144882f0 100644 --- a/docs/src/assistant/assistant.md +++ b/docs/src/assistant/assistant.md @@ -15,3 +15,5 @@ This section covers various aspects of the Assistant: - [Using Commands](./commands.md): Explore slash commands that enhance the Assistant's capabilities and future extensibility. - [Prompting & Prompt Library](./prompting.md): Learn how to write and save prompts, how to use the Prompt Library, and how to edit prompt templates. + +- [Context Servers](./context-servers.md): Learn about context servers that enhance the Assistant's capabilities via the [Model Context Protocol](./model-context-protocol.md). diff --git a/docs/src/assistant/context-servers.md b/docs/src/assistant/context-servers.md new file mode 100644 index 0000000000..398442044c --- /dev/null +++ b/docs/src/assistant/context-servers.md @@ -0,0 +1,49 @@ +# Context Servers + +Context servers are a mechanism for pulling context into the Assistant from an external source. They are powered by the [Model Context Protocol](./model-context-protocol.md). + +Currently Zed supports context servers providing [slash commands](./commands.md) for use in the Assistant. + +## Installation + +Context servers can be installed via [extensions](../extensions/context-servers.md). + +If you don't already have a context server, check out one of these: + +- [Postgres Context Server](https://github.com/zed-extensions/postgres-context-server) + +## Configuration + +Context servers may require some configuration in order to run or to change their behavior. + +You can configure each context server using the `context_servers` setting in your `settings.json`: + +```json +{ + "context_servers": { + "postgres-context-server": { + "settings": { + "database_url": "postgresql://postgres@localhost/my_database" + } + } +} +``` + +If desired, you may also provide a custom command to execute a context server: + +```json +{ + "context_servers": { + "my-context-server": { + "command": { + "path": "/path/to/my-context-server", + "args": ["run"], + "env": {} + }, + "settings": { + "enable_something": true + } + } + } +} +``` diff --git a/docs/src/assistant/model-context-protocol.md b/docs/src/assistant/model-context-protocol.md new file mode 100644 index 0000000000..34c67a8845 --- /dev/null +++ b/docs/src/assistant/model-context-protocol.md @@ -0,0 +1,21 @@ +# Model Context Protocol + +Zed uses the [Model Context Protocol](https://modelcontextprotocol.io/) to interact with [context servers](./context-server.md): + +> The Model Context Protocol (MCP) is an open protocol that enables seamless integration between LLM applications and external data sources and tools. Whether you're building an AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to connect LLMs with the context they need. + +Check out the [Anthropic news post](https://www.anthropic.com/news/model-context-protocol) and the [Zed blog post](https://zed.dev/blog/mcp) for an introduction to MCP. + +## Try it out + +Want to try it for yourself? + +The following context servers are available today as Zed extensions: + +- [Postgres Context Server](https://github.com/zed-extensions/postgres-context-server) + +## Bring your own context server + +If there's an existing context server you'd like to bring to Zed, check out the [context server extension docs](../extensions/context-servers.md) for how to make it available as an extension. + +If you are interested in building your own context server, check out the [Model Context Protocol docs](https://modelcontextprotocol.io/introduction#get-started-with-mcp) to get started. diff --git a/docs/src/extensions/context-servers.md b/docs/src/extensions/context-servers.md new file mode 100644 index 0000000000..6e61987384 --- /dev/null +++ b/docs/src/extensions/context-servers.md @@ -0,0 +1,39 @@ +# Context Servers + +Extensions may provide [context servers](../assistant/context-servers.md) for use in the Assistant. + +## Example extension + +To see a working example of an extension that provides context servers, check out the [`postgres-context-server` extension](https://github.com/zed-extensions/postgres-context-server). + +This extension can be [installed as a dev extension](./developing-extensions.html#developing-an-extension-locally) if you want to try it out for yourself. + +## Defining context servers + +A given extension may provide one or more context servers. Each context server must be registered in the `extension.toml`: + +```toml +[context-servers.my-context-server] +``` + +Then, in the Rust code for your extension, implement the `context_server_command` method on your extension: + +```rust +impl zed::Extension for MyExtension { + fn context_server_command( + &mut self, + context_server_id: &ContextServerId, + project: &zed::Project, + ) -> Result { + Ok(zed::Command { + command: get_path_to_context_server_executable()?, + args: get_args_for_context_server()?, + env: get_env_for_context_server()?, + }) + } +} +``` + +This method should return the command to start up a context server, along with any arguments or environment variables necessary for it to function. + +If you need to download the context server from an external source—like GitHub Releases or npm—you can also do this here. diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index 36939d4f1e..bdfab5fcde 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -7,6 +7,7 @@ Extensions can add the following capabilities to Zed: - [Languages](./languages.md) - [Themes](./themes.md) - [Slash Commands](./slash-commands.md) +- [Context Servers](./context-servers.md) ## Directory Structure of a Zed Extension From 93533ed2359f09cfa41743854c6f3ae3543ed7cf Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Nov 2024 17:19:33 +0100 Subject: [PATCH 008/215] Remove assistant hints (#21171) This reverts #20824 and #20899. After adding them last week we came to the conclusion that the hints are too distracting in everyday use, see #21128 for more details. Release Notes: - N/A --- assets/settings/default.json | 3 - crates/assistant/src/assistant_settings.rs | 11 -- crates/editor/src/editor.rs | 44 ------ crates/editor/src/element.rs | 134 ++++++++---------- crates/gpui/src/window.rs | 22 +-- crates/outline_panel/src/outline_panel.rs | 6 +- crates/recent_projects/src/recent_projects.rs | 8 +- crates/zed/src/main.rs | 3 +- crates/zed/src/zed.rs | 1 - crates/zed/src/zed/assistant_hints.rs | 115 --------------- docs/src/assistant/configuration.md | 26 ++-- docs/src/configuring-zed.md | 21 ++- 12 files changed, 93 insertions(+), 301 deletions(-) delete mode 100644 crates/zed/src/zed/assistant_hints.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index f1071f9676..efb0cc9479 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -490,9 +490,6 @@ "version": "2", // Whether the assistant is enabled. "enabled": true, - // Whether to show inline hints showing the keybindings to use the inline assistant and the - // assistant panel. - "show_hints": false, // Whether to show the assistant panel button in the status bar. "button": true, // Where to dock the assistant panel. Can be 'left', 'right' or 'bottom'. diff --git a/crates/assistant/src/assistant_settings.rs b/crates/assistant/src/assistant_settings.rs index a782f05d03..87baf041ff 100644 --- a/crates/assistant/src/assistant_settings.rs +++ b/crates/assistant/src/assistant_settings.rs @@ -59,7 +59,6 @@ pub struct AssistantSettings { pub inline_alternatives: Vec, pub using_outdated_settings_version: bool, pub enable_experimental_live_diffs: bool, - pub show_hints: bool, } impl AssistantSettings { @@ -202,7 +201,6 @@ impl AssistantSettingsContent { AssistantSettingsContent::Versioned(settings) => match settings { VersionedAssistantSettingsContent::V1(settings) => AssistantSettingsContentV2 { enabled: settings.enabled, - show_hints: None, button: settings.button, dock: settings.dock, default_width: settings.default_width, @@ -243,7 +241,6 @@ impl AssistantSettingsContent { }, AssistantSettingsContent::Legacy(settings) => AssistantSettingsContentV2 { enabled: None, - show_hints: None, button: settings.button, dock: settings.dock, default_width: settings.default_width, @@ -356,7 +353,6 @@ impl Default for VersionedAssistantSettingsContent { fn default() -> Self { Self::V2(AssistantSettingsContentV2 { enabled: None, - show_hints: None, button: None, dock: None, default_width: None, @@ -374,11 +370,6 @@ pub struct AssistantSettingsContentV2 { /// /// Default: true enabled: Option, - /// Whether to show inline hints that show keybindings for inline assistant - /// and assistant panel. - /// - /// Default: true - show_hints: Option, /// Whether to show the assistant panel button in the status bar. /// /// Default: true @@ -513,7 +504,6 @@ impl Settings for AssistantSettings { let value = value.upgrade(); merge(&mut settings.enabled, value.enabled); - merge(&mut settings.show_hints, value.show_hints); merge(&mut settings.button, value.button); merge(&mut settings.dock, value.dock); merge( @@ -584,7 +574,6 @@ mod tests { }), inline_alternatives: None, enabled: None, - show_hints: None, button: None, dock: None, default_width: None, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 401462795e..78f0aab5a5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -534,15 +534,6 @@ pub enum IsVimMode { No, } -pub trait ActiveLineTrailerProvider { - fn render_active_line_trailer( - &mut self, - style: &EditorStyle, - focus_handle: &FocusHandle, - cx: &mut WindowContext, - ) -> Option; -} - /// Zed's primary text input `View`, allowing users to edit a [`MultiBuffer`] /// /// See the [module level documentation](self) for more information. @@ -670,7 +661,6 @@ pub struct Editor { next_scroll_position: NextScrollCursorCenterTopBottom, addons: HashMap>, _scroll_cursor_center_top_bottom_task: Task<()>, - active_line_trailer_provider: Option>, } #[derive(Copy, Clone, Debug, PartialEq, Eq, Default)] @@ -2209,7 +2199,6 @@ impl Editor { addons: HashMap::default(), _scroll_cursor_center_top_bottom_task: Task::ready(()), text_style_refinement: None, - active_line_trailer_provider: None, }; this.tasks_update_task = Some(this.refresh_runnables(cx)); this._subscriptions.extend(project_subscriptions); @@ -2498,16 +2487,6 @@ impl Editor { self.refresh_inline_completion(false, false, cx); } - pub fn set_active_line_trailer_provider( - &mut self, - provider: Option, - _cx: &mut ViewContext, - ) where - T: ActiveLineTrailerProvider + 'static, - { - self.active_line_trailer_provider = provider.map(|provider| Box::new(provider) as Box<_>); - } - pub fn placeholder_text(&self, _cx: &WindowContext) -> Option<&str> { self.placeholder_text.as_deref() } @@ -11891,29 +11870,6 @@ impl Editor { && self.has_blame_entries(cx) } - pub fn render_active_line_trailer( - &mut self, - style: &EditorStyle, - cx: &mut WindowContext, - ) -> Option { - let selection = self.selections.newest::(cx); - if !selection.is_empty() { - return None; - }; - - let snapshot = self.buffer.read(cx).snapshot(cx); - let buffer_row = MultiBufferRow(selection.head().row); - - if snapshot.line_len(buffer_row) != 0 || self.has_active_inline_completion(cx) { - return None; - } - - let focus_handle = self.focus_handle.clone(); - self.active_line_trailer_provider - .as_mut()? - .render_active_line_trailer(style, &focus_handle, cx) - } - fn has_blame_entries(&self, cx: &mut WindowContext) -> bool { self.blame() .map_or(false, |blame| blame.read(cx).has_generated_entries()) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0c403022a3..7f4bc3fb77 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1414,7 +1414,7 @@ impl EditorElement { } #[allow(clippy::too_many_arguments)] - fn layout_active_line_trailer( + fn layout_inline_blame( &self, display_row: DisplayRow, display_snapshot: &DisplaySnapshot, @@ -1426,71 +1426,61 @@ impl EditorElement { line_height: Pixels, cx: &mut WindowContext, ) -> Option { - let render_inline_blame = self + if !self .editor - .update(cx, |editor, cx| editor.render_git_blame_inline(cx)); - if render_inline_blame { - let workspace = self - .editor - .read(cx) - .workspace - .as_ref() - .map(|(w, _)| w.clone()); - - let display_point = DisplayPoint::new(display_row, 0); - let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row); - - let blame = self.editor.read(cx).blame.clone()?; - let blame_entry = blame - .update(cx, |blame, cx| { - blame.blame_for_rows([Some(buffer_row)], cx).next() - }) - .flatten()?; - - let mut element = - render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx); - - let start_y = content_origin.y - + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); - - let start_x = { - const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.; - - let line_end = if let Some(crease_trailer) = crease_trailer { - crease_trailer.bounds.right() - } else { - content_origin.x - scroll_pixel_position.x + line_layout.width - }; - let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS; - - let min_column_in_pixels = ProjectSettings::get_global(cx) - .git - .inline_blame - .and_then(|settings| settings.min_column) - .map(|col| self.column_pixels(col as usize, cx)) - .unwrap_or(px(0.)); - let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; - - cmp::max(padded_line_end, min_start) - }; - - let absolute_offset = point(start_x, start_y); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - - Some(element) - } else if let Some(mut element) = self.editor.update(cx, |editor, cx| { - editor.render_active_line_trailer(&self.style, cx) - }) { - let start_y = content_origin.y - + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); - let start_x = content_origin.x - scroll_pixel_position.x + em_width; - let absolute_offset = point(start_x, start_y); - element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); - - Some(element) - } else { - None + .update(cx, |editor, cx| editor.render_git_blame_inline(cx)) + { + return None; } + + let workspace = self + .editor + .read(cx) + .workspace + .as_ref() + .map(|(w, _)| w.clone()); + + let display_point = DisplayPoint::new(display_row, 0); + let buffer_row = MultiBufferRow(display_point.to_point(display_snapshot).row); + + let blame = self.editor.read(cx).blame.clone()?; + let blame_entry = blame + .update(cx, |blame, cx| { + blame.blame_for_rows([Some(buffer_row)], cx).next() + }) + .flatten()?; + + let mut element = + render_inline_blame_entry(&blame, blame_entry, &self.style, workspace, cx); + + let start_y = content_origin.y + + line_height * (display_row.as_f32() - scroll_pixel_position.y / line_height); + + let start_x = { + const INLINE_BLAME_PADDING_EM_WIDTHS: f32 = 6.; + + let line_end = if let Some(crease_trailer) = crease_trailer { + crease_trailer.bounds.right() + } else { + content_origin.x - scroll_pixel_position.x + line_layout.width + }; + let padded_line_end = line_end + em_width * INLINE_BLAME_PADDING_EM_WIDTHS; + + let min_column_in_pixels = ProjectSettings::get_global(cx) + .git + .inline_blame + .and_then(|settings| settings.min_column) + .map(|col| self.column_pixels(col as usize, cx)) + .unwrap_or(px(0.)); + let min_start = content_origin.x - scroll_pixel_position.x + min_column_in_pixels; + + cmp::max(padded_line_end, min_start) + }; + + let absolute_offset = point(start_x, start_y); + element.prepaint_as_root(absolute_offset, AvailableSpace::min_size(), cx); + + Some(element) } #[allow(clippy::too_many_arguments)] @@ -3466,7 +3456,7 @@ impl EditorElement { self.paint_lines(&invisible_display_ranges, layout, cx); self.paint_redactions(layout, cx); self.paint_cursors(layout, cx); - self.paint_active_line_trailer(layout, cx); + self.paint_inline_blame(layout, cx); cx.with_element_namespace("crease_trailers", |cx| { for trailer in layout.crease_trailers.iter_mut().flatten() { trailer.element.paint(cx); @@ -3948,10 +3938,10 @@ impl EditorElement { } } - fn paint_active_line_trailer(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { - if let Some(mut element) = layout.active_line_trailer.take() { + fn paint_inline_blame(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { + if let Some(mut inline_blame) = layout.inline_blame.take() { cx.paint_layer(layout.text_hitbox.bounds, |cx| { - element.paint(cx); + inline_blame.paint(cx); }) } } @@ -5343,14 +5333,14 @@ impl Element for EditorElement { ) }); - let mut active_line_trailer = None; + let mut inline_blame = None; if let Some(newest_selection_head) = newest_selection_head { let display_row = newest_selection_head.row(); if (start_row..end_row).contains(&display_row) { let line_ix = display_row.minus(start_row) as usize; let line_layout = &line_layouts[line_ix]; let crease_trailer_layout = crease_trailers[line_ix].as_ref(); - active_line_trailer = self.layout_active_line_trailer( + inline_blame = self.layout_inline_blame( display_row, &snapshot.display_snapshot, line_layout, @@ -5669,7 +5659,7 @@ impl Element for EditorElement { line_elements, line_numbers, blamed_display_rows, - active_line_trailer, + inline_blame, blocks, cursors, visible_cursors, @@ -5806,7 +5796,7 @@ pub struct EditorLayout { line_numbers: Vec>, display_hunks: Vec<(DisplayDiffHunk, Option)>, blamed_display_rows: Option>, - active_line_trailer: Option, + inline_blame: Option, blocks: Vec, highlighted_ranges: Vec<(Range, Hsla)>, highlighted_gutter_ranges: Vec<(Range, Hsla)>, diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 2b6f1d4a99..c1c14edba2 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -3064,7 +3064,7 @@ impl<'a> WindowContext<'a> { } /// Represent this action as a key binding string, to display in the UI. - pub fn keystroke_text_for_action(&self, action: &dyn Action) -> String { + pub fn keystroke_text_for(&self, action: &dyn Action) -> String { self.bindings_for_action(action) .into_iter() .next() @@ -3079,26 +3079,6 @@ impl<'a> WindowContext<'a> { .unwrap_or_else(|| action.name().to_string()) } - /// Represent this action as a key binding string, to display in the UI. - pub fn keystroke_text_for_action_in( - &self, - action: &dyn Action, - focus_handle: &FocusHandle, - ) -> String { - self.bindings_for_action_in(action, focus_handle) - .into_iter() - .next() - .map(|binding| { - binding - .keystrokes() - .iter() - .map(ToString::to_string) - .collect::>() - .join(" ") - }) - .unwrap_or_else(|| action.name().to_string()) - } - /// Dispatch a mouse or keyboard event on the window. #[profiling::function] pub fn dispatch_event(&mut self, event: PlatformInput) -> DispatchEventResult { diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index f378348782..f878b582d9 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -3875,13 +3875,13 @@ impl OutlinePanel { .child({ let keystroke = match self.position(cx) { DockPosition::Left => { - cx.keystroke_text_for_action(&workspace::ToggleLeftDock) + cx.keystroke_text_for(&workspace::ToggleLeftDock) } DockPosition::Bottom => { - cx.keystroke_text_for_action(&workspace::ToggleBottomDock) + cx.keystroke_text_for(&workspace::ToggleBottomDock) } DockPosition::Right => { - cx.keystroke_text_for_action(&workspace::ToggleRightDock) + cx.keystroke_text_for(&workspace::ToggleRightDock) } }; Label::new(format!("Toggle this panel with {keystroke}")) diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index c08136cdf5..404bf26b62 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/crates/recent_projects/src/recent_projects.rs @@ -172,13 +172,13 @@ impl PickerDelegate for RecentProjectsDelegate { fn placeholder_text(&self, cx: &mut WindowContext) -> Arc { let (create_window, reuse_window) = if self.create_new_window { ( - cx.keystroke_text_for_action(&menu::Confirm), - cx.keystroke_text_for_action(&menu::SecondaryConfirm), + cx.keystroke_text_for(&menu::Confirm), + cx.keystroke_text_for(&menu::SecondaryConfirm), ) } else { ( - cx.keystroke_text_for_action(&menu::SecondaryConfirm), - cx.keystroke_text_for_action(&menu::Confirm), + cx.keystroke_text_for(&menu::SecondaryConfirm), + cx.keystroke_text_for(&menu::Confirm), ) }; Arc::from(format!( diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6febe05d10..cccd50da96 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -61,7 +61,7 @@ use zed::{ OpenRequest, }; -use crate::zed::{assistant_hints, inline_completion_registry}; +use crate::zed::inline_completion_registry; #[cfg(feature = "mimalloc")] #[global_allocator] @@ -407,7 +407,6 @@ fn main() { cx, ); assistant2::init(cx); - assistant_hints::init(cx); repl::init( app_state.fs.clone(), app_state.client.telemetry().clone(), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index be6df49a2d..5ba63b9c1f 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1,5 +1,4 @@ mod app_menus; -pub mod assistant_hints; pub mod inline_completion_registry; #[cfg(any(target_os = "linux", target_os = "freebsd"))] pub(crate) mod linux_prompts; diff --git a/crates/zed/src/zed/assistant_hints.rs b/crates/zed/src/zed/assistant_hints.rs deleted file mode 100644 index 244b7fab26..0000000000 --- a/crates/zed/src/zed/assistant_hints.rs +++ /dev/null @@ -1,115 +0,0 @@ -use assistant::assistant_settings::AssistantSettings; -use collections::HashMap; -use editor::{ActiveLineTrailerProvider, Editor, EditorMode}; -use gpui::{AnyWindowHandle, AppContext, ViewContext, WeakView, WindowContext}; -use settings::{Settings, SettingsStore}; -use std::{cell::RefCell, rc::Rc}; -use theme::ActiveTheme; -use ui::prelude::*; -use workspace::Workspace; - -pub fn init(cx: &mut AppContext) { - let editors: Rc, AnyWindowHandle>>> = Rc::default(); - - cx.observe_new_views({ - let editors = editors.clone(); - move |_: &mut Workspace, cx: &mut ViewContext| { - let workspace_handle = cx.view().clone(); - cx.subscribe(&workspace_handle, { - let editors = editors.clone(); - move |_, _, event, cx| match event { - workspace::Event::ItemAdded { item } => { - if let Some(editor) = item.act_as::(cx) { - if editor.read(cx).mode() != EditorMode::Full { - return; - } - - cx.on_release({ - let editor_handle = editor.downgrade(); - let editors = editors.clone(); - move |_, _, _| { - editors.borrow_mut().remove(&editor_handle); - } - }) - .detach(); - editors - .borrow_mut() - .insert(editor.downgrade(), cx.window_handle()); - - let show_hints = should_show_hints(cx); - editor.update(cx, |editor, cx| { - assign_active_line_trailer_provider(editor, show_hints, cx) - }) - } - } - _ => {} - } - }) - .detach(); - } - }) - .detach(); - - let mut show_hints = AssistantSettings::get_global(cx).show_hints; - cx.observe_global::(move |cx| { - let new_show_hints = should_show_hints(cx); - if new_show_hints != show_hints { - show_hints = new_show_hints; - for (editor, window) in editors.borrow().iter() { - _ = window.update(cx, |_window, cx| { - _ = editor.update(cx, |editor, cx| { - assign_active_line_trailer_provider(editor, show_hints, cx); - }) - }); - } - } - }) - .detach(); -} - -struct AssistantHintsProvider; - -impl ActiveLineTrailerProvider for AssistantHintsProvider { - fn render_active_line_trailer( - &mut self, - style: &editor::EditorStyle, - focus_handle: &gpui::FocusHandle, - cx: &mut WindowContext, - ) -> Option { - if !focus_handle.is_focused(cx) { - return None; - } - - let chat_keybinding = - cx.keystroke_text_for_action_in(&assistant::ToggleFocus, focus_handle); - let generate_keybinding = - cx.keystroke_text_for_action_in(&zed_actions::InlineAssist::default(), focus_handle); - - Some( - h_flex() - .id("inline-assistant-instructions") - .w_full() - .font_family(style.text.font().family) - .text_color(cx.theme().status().hint) - .line_height(style.text.line_height) - .child(format!( - "{chat_keybinding} to chat, {generate_keybinding} to generate" - )) - .into_any(), - ) - } -} - -fn assign_active_line_trailer_provider( - editor: &mut Editor, - show_hints: bool, - cx: &mut ViewContext, -) { - let provider = show_hints.then_some(AssistantHintsProvider); - editor.set_active_line_trailer_provider(provider, cx); -} - -fn should_show_hints(cx: &AppContext) -> bool { - let assistant_settings = AssistantSettings::get_global(cx); - assistant_settings.enabled && assistant_settings.show_hints -} diff --git a/docs/src/assistant/configuration.md b/docs/src/assistant/configuration.md index 1be96491f4..2145bd9504 100644 --- a/docs/src/assistant/configuration.md +++ b/docs/src/assistant/configuration.md @@ -200,28 +200,18 @@ You must provide the model's Context Window in the `max_tokens` parameter, this { "assistant": { "enabled": true, - "show_hints": true, - "button": true, - "dock": "right" - "default_width": 480, "default_model": { "provider": "zed.dev", "model": "claude-3-5-sonnet" }, "version": "2", + "button": true, + "default_width": 480, + "dock": "right" } } ``` -| key | type | default | description | -| -------------- | ------- | ------- | ------------------------------------------------------------------------------------- | -| enabled | boolean | true | Setting this to `false` will completely disable the assistant | -| show_hints | boolean | true | Whether to to show hints in the editor explaining how to use assistant | -| button | boolean | true | Show the assistant icon in the status bar | -| dock | string | "right" | The default dock position for the assistant panel. Can be ["left", "right", "bottom"] | -| default_height | string | null | The pixel height of the assistant panel when docked to the bottom | -| default_width | string | null | The pixel width of the assistant panel when docked to the left or right | - #### Custom endpoints {#custom-endpoint} You can use a custom API endpoint for different providers, as long as it's compatible with the providers API structure. @@ -281,3 +271,13 @@ will generate two outputs for every assist. One with Claude 3.5 Sonnet, and one } } ``` + +#### Common Panel Settings + +| key | type | default | description | +| -------------- | ------- | ------- | ------------------------------------------------------------------------------------- | +| enabled | boolean | true | Setting this to `false` will completely disable the assistant | +| button | boolean | true | Show the assistant icon in the status bar | +| dock | string | "right" | The default dock position for the assistant panel. Can be ["left", "right", "bottom"] | +| default_height | string | null | The pixel height of the assistant panel when docked to the bottom | +| default_width | string | null | The pixel width of the assistant panel when docked to the left or right | diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 4991ff1119..5eacf4136d 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -2333,18 +2333,15 @@ Run the `theme selector: toggle` action in the command palette to see a current - Default: ```json -{ - "assistant": { - "enabled": true, - "button": true, - "dock": "right", - "default_width": 640, - "default_height": 320, - "provider": "openai", - "version": "1", - "show_hints": true - } -} +"assistant": { + "enabled": true, + "button": true, + "dock": "right", + "default_width": 640, + "default_height": 320, + "provider": "openai", + "version": "1", +}, ``` ## Outline Panel From 389422cbf3ebd44f833159fbdcacbe99f5320c92 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 11:25:37 -0500 Subject: [PATCH 009/215] docs: Fix broken link to context servers docs (#21172) This PR fixes a broken link to the context server docs. Release Notes: - N/A --- docs/src/assistant/model-context-protocol.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/assistant/model-context-protocol.md b/docs/src/assistant/model-context-protocol.md index 34c67a8845..74e16b59ff 100644 --- a/docs/src/assistant/model-context-protocol.md +++ b/docs/src/assistant/model-context-protocol.md @@ -1,6 +1,6 @@ # Model Context Protocol -Zed uses the [Model Context Protocol](https://modelcontextprotocol.io/) to interact with [context servers](./context-server.md): +Zed uses the [Model Context Protocol](https://modelcontextprotocol.io/) to interact with [context servers](./context-servers.md): > The Model Context Protocol (MCP) is an open protocol that enables seamless integration between LLM applications and external data sources and tools. Whether you're building an AI-powered IDE, enhancing a chat interface, or creating custom AI workflows, MCP provides a standardized way to connect LLMs with the context they need. From 28142be5e9ff16abbf62d8c28fa634ba12886b14 Mon Sep 17 00:00:00 2001 From: teapo <75266237+4teapo@users.noreply.github.com> Date: Mon, 25 Nov 2024 17:11:23 +0000 Subject: [PATCH 010/215] Update Luau docs (#21174) Formatter arguments & Tree-sitter grammar changed. Release Notes: - N/A --- docs/src/languages/luau.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/languages/luau.md b/docs/src/languages/luau.md index c7abd0cae9..0c5e94dcf8 100644 --- a/docs/src/languages/luau.md +++ b/docs/src/languages/luau.md @@ -5,7 +5,7 @@ Luau language support in Zed is provided by the community-maintained [Luau extension](https://github.com/4teapo/zed-luau). Report issues to: [https://github.com/4teapo/zed-luau/issues](https://github.com/4teapo/zed-luau/issues) -- Tree Sitter: [tree-sitter-grammars/tree-sitter-luau](https://github.com/tree-sitter-grammars/tree-sitter-luau) +- Tree Sitter: [4teapo/tree-sitter-luau](https://github.com/4teapo/tree-sitter-luau) - Language Server: [JohnnyMorganz/luau-lsp](https://github.com/JohnnyMorganz/luau-lsp) ## Configuration @@ -33,7 +33,7 @@ Then add the following to your Zed `settings.json`: "formatter": { "external": { "command": "stylua", - "arguments": ["-"] + "arguments": ["--stdin-filepath", "{buffer_path}", "-"] } } } From bd02b35ba948970d22bdee167abe86bbdd8351c7 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 25 Nov 2024 19:21:30 +0200 Subject: [PATCH 011/215] Avoid excessive diagnostics refreshes (#21173) Attempts to reduce the diagnostics flicker, when editing very fundamental parts of the large code base in Rust. https://github.com/user-attachments/assets/dc3f9c21-8c6e-48db-967b-040649fd00da Release Notes: - N/A --- crates/diagnostics/src/diagnostics.rs | 6 ++++++ crates/diagnostics/src/diagnostics_tests.rs | 17 ++++++++++++++--- crates/gpui/src/app/test_context.rs | 11 ++++++++--- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index bd0af230ab..be8da5c130 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -33,6 +33,7 @@ use std::{ mem, ops::Range, sync::Arc, + time::Duration, }; use theme::ActiveTheme; pub use toolbar_controls::ToolbarControls; @@ -82,6 +83,8 @@ struct DiagnosticGroupState { impl EventEmitter for ProjectDiagnosticsEditor {} +const DIAGNOSTICS_UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); + impl Render for ProjectDiagnosticsEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let child = if self.path_states.is_empty() { @@ -198,6 +201,9 @@ impl ProjectDiagnosticsEditor { } let project_handle = self.project.clone(); self.update_excerpts_task = Some(cx.spawn(|this, mut cx| async move { + cx.background_executor() + .timer(DIAGNOSTICS_UPDATE_DEBOUNCE) + .await; loop { let Some((path, language_server_id)) = this.update(&mut cx, |this, _| { let Some((path, language_server_id)) = this.paths_to_update.pop_first() else { diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index c5ae29ff2e..ff305e45a2 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -155,7 +155,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) { }); let editor = view.update(cx, |view, _| view.editor.clone()); - view.next_notification(cx).await; + view.next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; assert_eq!( editor_blocks(&editor, cx), [ @@ -240,7 +241,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) { lsp_store.disk_based_diagnostics_finished(language_server_id, cx); }); - view.next_notification(cx).await; + view.next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; assert_eq!( editor_blocks(&editor, cx), [ @@ -352,7 +354,8 @@ async fn test_diagnostics(cx: &mut TestAppContext) { lsp_store.disk_based_diagnostics_finished(language_server_id, cx); }); - view.next_notification(cx).await; + view.next_notification(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10), cx) + .await; assert_eq!( editor_blocks(&editor, cx), [ @@ -491,6 +494,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Only the first language server's diagnostics are shown. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -537,6 +542,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Both language server's diagnostics are shown. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -603,6 +610,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Only the first language server's diagnostics are updated. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), @@ -659,6 +668,8 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { }); // Both language servers' diagnostics are updated. + cx.executor() + .advance_clock(DIAGNOSTICS_UPDATE_DEBOUNCE + Duration::from_millis(10)); cx.executor().run_until_parked(); assert_eq!( editor_blocks(&editor, cx), diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 34449c91ec..2fea804301 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -538,12 +538,15 @@ impl Model { impl View { /// Returns a future that resolves when the view is next updated. - pub fn next_notification(&self, cx: &TestAppContext) -> impl Future { + pub fn next_notification( + &self, + advance_clock_by: Duration, + cx: &TestAppContext, + ) -> impl Future { use postage::prelude::{Sink as _, Stream as _}; let (mut tx, mut rx) = postage::mpsc::channel(1); - let mut cx = cx.app.app.borrow_mut(); - let subscription = cx.observe(self, move |_, _| { + let subscription = cx.app.app.borrow_mut().observe(self, move |_, _| { tx.try_send(()).ok(); }); @@ -553,6 +556,8 @@ impl View { Duration::from_secs(1) }; + cx.executor().advance_clock(advance_clock_by); + async move { let notification = crate::util::timeout(duration, rx.recv()) .await From a02684b2f7f86fc8cc7963c350815a656525c677 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 14:08:40 -0500 Subject: [PATCH 012/215] assistant2: Add rudimentary chat functionality (#21178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds in rudimentary functionality for sending messages to the LLM in `assistant2`. Screenshot 2024-11-25 at 1 49 11 PM Release Notes: - N/A --- Cargo.lock | 3 + assets/keymaps/default-macos.json | 12 ++ crates/assistant2/Cargo.toml | 3 + crates/assistant2/src/assistant.rs | 6 +- crates/assistant2/src/assistant_panel.rs | 28 ++++- crates/assistant2/src/message_editor.rs | 144 ++++++++++++++++++++++- crates/assistant2/src/thread.rs | 23 ++++ 7 files changed, 211 insertions(+), 8 deletions(-) create mode 100644 crates/assistant2/src/thread.rs diff --git a/Cargo.lock b/Cargo.lock index 91205f214f..b8c24b4594 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,13 +458,16 @@ dependencies = [ "command_palette_hooks", "editor", "feature_flags", + "futures 0.3.31", "gpui", "language_model", "language_model_selector", "proto", "settings", + "smol", "theme", "ui", + "util", "workspace", ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 963d48ba5e..c8bc80a9c0 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -209,6 +209,18 @@ "alt-enter": "editor::Newline" } }, + { + "context": "AssistantPanel2", + "bindings": { + "cmd-n": "assistant2::NewThread" + } + }, + { + "context": "MessageEditor > Editor", + "bindings": { + "cmd-enter": "assistant2::Chat" + } + }, { "context": "PromptLibrary", "bindings": { diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 9dd605d559..02cbdadb62 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -17,11 +17,14 @@ anyhow.workspace = true command_palette_hooks.workspace = true editor.workspace = true feature_flags.workspace = true +futures.workspace = true gpui.workspace = true language_model.workspace = true language_model_selector.workspace = true proto.workspace = true settings.workspace = true +smol.workspace = true theme.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 6a80186525..1b33e27928 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,5 +1,6 @@ mod assistant_panel; mod message_editor; +mod thread; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{Assistant2FeatureFlag, FeatureFlagAppExt}; @@ -7,7 +8,10 @@ use gpui::{actions, AppContext}; pub use crate::assistant_panel::AssistantPanel; -actions!(assistant2, [ToggleFocus, NewThread, ToggleModelSelector]); +actions!( + assistant2, + [ToggleFocus, NewThread, ToggleModelSelector, Chat] +); const NAMESPACE: &str = "assistant2"; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 890020e54a..88a3f73176 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -1,7 +1,7 @@ use anyhow::Result; use gpui::{ prelude::*, px, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, - FocusableView, Pixels, Task, View, ViewContext, WeakView, WindowContext, + FocusableView, Model, Pixels, Task, View, ViewContext, WeakView, WindowContext, }; use language_model::LanguageModelRegistry; use language_model_selector::LanguageModelSelector; @@ -10,6 +10,7 @@ use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::{Pane, Workspace}; use crate::message_editor::MessageEditor; +use crate::thread::Thread; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { @@ -25,6 +26,7 @@ pub fn init(cx: &mut AppContext) { pub struct AssistantPanel { pane: View, + thread: Model, message_editor: View, } @@ -56,9 +58,12 @@ impl AssistantPanel { pane }); + let thread = cx.new_model(Thread::new); + Self { pane, - message_editor: cx.new_view(MessageEditor::new), + thread: thread.clone(), + message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), } } } @@ -247,7 +252,24 @@ impl Render for AssistantPanel { println!("Action: New Thread"); })) .child(self.render_toolbar(cx)) - .child(v_flex().bg(cx.theme().colors().panel_background)) + .child( + v_flex() + .id("message-list") + .gap_2() + .size_full() + .p_2() + .overflow_y_scroll() + .bg(cx.theme().colors().panel_background) + .children(self.thread.read(cx).messages.iter().map(|message| { + v_flex() + .p_2() + .border_1() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .child(Label::new(message.role.to_string())) + .child(Label::new(message.text.clone())) + })), + ) .child( h_flex() .border_t_1() diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index ee25ad5da7..63f8c869d4 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,16 +1,32 @@ use editor::{Editor, EditorElement, EditorStyle}; -use gpui::{TextStyle, View}; +use futures::StreamExt; +use gpui::{AppContext, Model, TextStyle, View}; +use language_model::{ + LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, MessageContent, Role, StopReason, +}; use settings::Settings; use theme::ThemeSettings; -use ui::prelude::*; +use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; +use util::ResultExt; + +use crate::thread::{self, Thread}; +use crate::Chat; + +#[derive(Debug, Clone, Copy)] +pub enum RequestKind { + Chat, +} pub struct MessageEditor { + thread: Model, editor: View, } impl MessageEditor { - pub fn new(cx: &mut ViewContext) -> Self { + pub fn new(thread: Model, cx: &mut ViewContext) -> Self { Self { + thread, editor: cx.new_view(|cx| { let mut editor = Editor::auto_height(80, cx); editor.set_placeholder_text("Ask anything…", cx); @@ -19,14 +35,122 @@ impl MessageEditor { }), } } + + fn chat(&mut self, _: &Chat, cx: &mut ViewContext) { + self.send_to_model(RequestKind::Chat, cx); + } + + fn send_to_model( + &mut self, + request_kind: RequestKind, + cx: &mut ViewContext, + ) -> Option<()> { + let provider = LanguageModelRegistry::read_global(cx).active_provider(); + if provider + .as_ref() + .map_or(false, |provider| provider.must_accept_terms(cx)) + { + cx.notify(); + return None; + } + + let model_registry = LanguageModelRegistry::read_global(cx); + let model = model_registry.active_model()?; + + let request = self.build_completion_request(request_kind, cx); + + let user_message = self.editor.read(cx).text(cx); + self.thread.update(cx, |thread, _cx| { + thread.messages.push(thread::Message { + role: Role::User, + text: user_message, + }); + }); + + self.editor.update(cx, |editor, cx| { + editor.clear(cx); + }); + + let task = cx.spawn(|this, mut cx| async move { + let stream = model.stream_completion(request, &cx); + let stream_completion = async { + let mut events = stream.await?; + let mut stop_reason = StopReason::EndTurn; + + let mut text = String::new(); + + while let Some(event) = events.next().await { + let event = event?; + match event { + LanguageModelCompletionEvent::StartMessage { .. } => {} + LanguageModelCompletionEvent::Stop(reason) => { + stop_reason = reason; + } + LanguageModelCompletionEvent::Text(chunk) => { + text.push_str(&chunk); + } + LanguageModelCompletionEvent::ToolUse(_tool_use) => {} + } + + smol::future::yield_now().await; + } + + anyhow::Ok((stop_reason, text)) + }; + + let result = stream_completion.await; + + this.update(&mut cx, |this, cx| { + if let Some((_stop_reason, text)) = result.log_err() { + this.thread.update(cx, |thread, _cx| { + thread.messages.push(thread::Message { + role: Role::Assistant, + text, + }); + }); + } + }) + .ok(); + }); + + self.thread.update(cx, |thread, _cx| { + thread.pending_completion_tasks.push(task); + }); + + None + } + + fn build_completion_request( + &self, + _request_kind: RequestKind, + cx: &AppContext, + ) -> LanguageModelRequest { + let text = self.editor.read(cx).text(cx); + + let request = LanguageModelRequest { + messages: vec![LanguageModelRequestMessage { + role: Role::User, + content: vec![MessageContent::Text(text)], + cache: false, + }], + tools: Vec::new(), + stop: Vec::new(), + temperature: None, + }; + + request + } } impl Render for MessageEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let font_size = TextSize::Default.rems(cx); let line_height = font_size.to_pixels(cx.rem_size()) * 1.3; + let focus_handle = self.editor.focus_handle(cx); v_flex() + .key_context("MessageEditor") + .on_action(cx.listener(Self::chat)) .size_full() .gap_2() .p_2() @@ -69,7 +193,19 @@ impl Render for MessageEditor { .gap_2() .child(Button::new("codebase", "Codebase").style(ButtonStyle::Filled)) .child(Label::new("or")) - .child(Button::new("chat", "Chat").style(ButtonStyle::Filled)), + .child( + ButtonLike::new("chat") + .style(ButtonStyle::Filled) + .layer(ElevationIndex::ModalSurface) + .child(Label::new("Chat")) + .children( + KeyBinding::for_action_in(&Chat, &focus_handle, cx) + .map(|binding| binding.into_any_element()), + ) + .on_click(move |_event, cx| { + focus_handle.dispatch_action(&Chat, cx); + }), + ), ), ) } diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs new file mode 100644 index 0000000000..1553eaabb6 --- /dev/null +++ b/crates/assistant2/src/thread.rs @@ -0,0 +1,23 @@ +use gpui::{ModelContext, Task}; +use language_model::Role; + +/// A message in a [`Thread`]. +pub struct Message { + pub role: Role, + pub text: String, +} + +/// A thread of conversation with the LLM. +pub struct Thread { + pub messages: Vec, + pub pending_completion_tasks: Vec>, +} + +impl Thread { + pub fn new(_cx: &mut ModelContext) -> Self { + Self { + messages: Vec::new(), + pending_completion_tasks: Vec::new(), + } + } +} From 91a565f5faa396cb9f31d4a939c597b0ea11b794 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 25 Nov 2024 12:53:23 -0800 Subject: [PATCH 013/215] Simplify BufferStore's local vs remote state (#21180) This is a pure refactor, motivated by wanting to introduce to the BufferStore new logic for opening staged and committed changes. I found the `BufferStoreImpl` trait a little bit confusing, particularly how the different implementors of the trait held a handle back to the owning buffer store. I was able to reduce the amount of code considerably (-78 lines) by using a two-variant enum instead, similar to what we do for `LspStore`, `WorktreeStore` and `Worktree`. Release Notes: - N/A --- crates/project/src/buffer_store.rs | 738 +++++++++++++---------------- 1 file changed, 330 insertions(+), 408 deletions(-) diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index eb56680fb3..55b0f413a9 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -29,38 +29,23 @@ use text::BufferId; use util::{debug_panic, maybe, ResultExt as _, TryFutureExt}; use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Worktree, WorktreeId}; -trait BufferStoreImpl { - fn open_buffer( - &self, - path: Arc, - worktree: Model, - cx: &mut ModelContext, - ) -> Task>>; +/// A set of open buffers. +pub struct BufferStore { + state: BufferStoreState, + #[allow(clippy::type_complexity)] + loading_buffers_by_path: HashMap< + ProjectPath, + postage::watch::Receiver, Arc>>>, + >, + worktree_store: Model, + opened_buffers: HashMap, + downstream_client: Option<(AnyProtoClient, u64)>, + shared_buffers: HashMap>>, +} - fn save_buffer( - &self, - buffer: Model, - cx: &mut ModelContext, - ) -> Task>; - - fn save_buffer_as( - &self, - buffer: Model, - path: ProjectPath, - cx: &mut ModelContext, - ) -> Task>; - - fn create_buffer(&self, cx: &mut ModelContext) -> Task>>; - - fn reload_buffers( - &self, - buffers: HashSet>, - push_to_history: bool, - cx: &mut ModelContext, - ) -> Task>; - - fn as_remote(&self) -> Option>; - fn as_local(&self) -> Option>; +enum BufferStoreState { + Local(LocalBufferStore), + Remote(RemoteBufferStore), } struct RemoteBufferStore { @@ -71,31 +56,15 @@ struct RemoteBufferStore { remote_buffer_listeners: HashMap, anyhow::Error>>>>, worktree_store: Model, - buffer_store: WeakModel, } struct LocalBufferStore { local_buffer_ids_by_path: HashMap, local_buffer_ids_by_entry_id: HashMap, - buffer_store: WeakModel, worktree_store: Model, _subscription: Subscription, } -/// A set of open buffers. -pub struct BufferStore { - state: Box, - #[allow(clippy::type_complexity)] - loading_buffers_by_path: HashMap< - ProjectPath, - postage::watch::Receiver, Arc>>>, - >, - worktree_store: Model, - opened_buffers: HashMap, - downstream_client: Option<(AnyProtoClient, u64)>, - shared_buffers: HashMap>>, -} - enum OpenBuffer { Buffer(WeakModel), Operations(Vec), @@ -119,14 +88,13 @@ impl RemoteBufferStore { pub fn wait_for_remote_buffer( &mut self, id: BufferId, - cx: &mut AppContext, + cx: &mut ModelContext, ) -> Task>> { - let buffer_store = self.buffer_store.clone(); let (tx, rx) = oneshot::channel(); self.remote_buffer_listeners.entry(id).or_default().push(tx); - cx.spawn(|cx| async move { - if let Some(buffer) = buffer_store + cx.spawn(|this, cx| async move { + if let Some(buffer) = this .read_with(&cx, |buffer_store, _| buffer_store.get(id)) .ok() .flatten() @@ -144,7 +112,7 @@ impl RemoteBufferStore { &self, buffer_handle: Model, new_path: Option, - cx: &ModelContext, + cx: &ModelContext, ) -> Task> { let buffer = buffer_handle.read(cx); let buffer_id = buffer.remote_id().into(); @@ -176,7 +144,7 @@ impl RemoteBufferStore { envelope: TypedEnvelope, replica_id: u16, capability: Capability, - cx: &mut ModelContext, + cx: &mut ModelContext, ) -> Result>> { match envelope .payload @@ -277,7 +245,7 @@ impl RemoteBufferStore { &self, message: proto::ProjectTransaction, push_to_history: bool, - cx: &mut ModelContext, + cx: &mut ModelContext, ) -> Task> { cx.spawn(|this, mut cx| async move { let mut project_transaction = ProjectTransaction::default(); @@ -310,36 +278,6 @@ impl RemoteBufferStore { Ok(project_transaction) }) } -} - -impl BufferStoreImpl for Model { - fn as_remote(&self) -> Option> { - Some(self.clone()) - } - - fn as_local(&self) -> Option> { - None - } - - fn save_buffer( - &self, - buffer: Model, - cx: &mut ModelContext, - ) -> Task> { - self.update(cx, |this, cx| { - this.save_remote_buffer(buffer.clone(), None, cx) - }) - } - fn save_buffer_as( - &self, - buffer: Model, - path: ProjectPath, - cx: &mut ModelContext, - ) -> Task> { - self.update(cx, |this, cx| { - this.save_remote_buffer(buffer, Some(path.to_proto()), cx) - }) - } fn open_buffer( &self, @@ -347,46 +285,42 @@ impl BufferStoreImpl for Model { worktree: Model, cx: &mut ModelContext, ) -> Task>> { - self.update(cx, |this, cx| { - let worktree_id = worktree.read(cx).id().to_proto(); - let project_id = this.project_id; - let client = this.upstream_client.clone(); - let path_string = path.clone().to_string_lossy().to_string(); - cx.spawn(move |this, mut cx| async move { - let response = client - .request(proto::OpenBufferByPath { - project_id, - worktree_id, - path: path_string, - }) - .await?; - let buffer_id = BufferId::new(response.buffer_id)?; + let worktree_id = worktree.read(cx).id().to_proto(); + let project_id = self.project_id; + let client = self.upstream_client.clone(); + let path_string = path.clone().to_string_lossy().to_string(); + cx.spawn(move |this, mut cx| async move { + let response = client + .request(proto::OpenBufferByPath { + project_id, + worktree_id, + path: path_string, + }) + .await?; + let buffer_id = BufferId::new(response.buffer_id)?; - let buffer = this - .update(&mut cx, { - |this, cx| this.wait_for_remote_buffer(buffer_id, cx) - })? - .await?; + let buffer = this + .update(&mut cx, { + |this, cx| this.wait_for_remote_buffer(buffer_id, cx) + })? + .await?; - Ok(buffer) - }) + Ok(buffer) }) } fn create_buffer(&self, cx: &mut ModelContext) -> Task>> { - self.update(cx, |this, cx| { - let create = this.upstream_client.request(proto::OpenNewBuffer { - project_id: this.project_id, - }); - cx.spawn(|this, mut cx| async move { - let response = create.await?; - let buffer_id = BufferId::new(response.buffer_id)?; + let create = self.upstream_client.request(proto::OpenNewBuffer { + project_id: self.project_id, + }); + cx.spawn(|this, mut cx| async move { + let response = create.await?; + let buffer_id = BufferId::new(response.buffer_id)?; - this.update(&mut cx, |this, cx| { - this.wait_for_remote_buffer(buffer_id, cx) - })? - .await - }) + this.update(&mut cx, |this, cx| { + this.wait_for_remote_buffer(buffer_id, cx) + })? + .await }) } @@ -396,25 +330,23 @@ impl BufferStoreImpl for Model { push_to_history: bool, cx: &mut ModelContext, ) -> Task> { - self.update(cx, |this, cx| { - let request = this.upstream_client.request(proto::ReloadBuffers { - project_id: this.project_id, - buffer_ids: buffers - .iter() - .map(|buffer| buffer.read(cx).remote_id().to_proto()) - .collect(), - }); + let request = self.upstream_client.request(proto::ReloadBuffers { + project_id: self.project_id, + buffer_ids: buffers + .iter() + .map(|buffer| buffer.read(cx).remote_id().to_proto()) + .collect(), + }); - cx.spawn(|this, mut cx| async move { - let response = request - .await? - .transaction - .ok_or_else(|| anyhow!("missing transaction"))?; - this.update(&mut cx, |this, cx| { - this.deserialize_project_transaction(response, push_to_history, cx) - })? - .await - }) + cx.spawn(|this, mut cx| async move { + let response = request + .await? + .transaction + .ok_or_else(|| anyhow!("missing transaction"))?; + this.update(&mut cx, |this, cx| { + this.deserialize_project_transaction(response, push_to_history, cx) + })? + .await }) } } @@ -426,7 +358,7 @@ impl LocalBufferStore { worktree: Model, path: Arc, mut has_changed_file: bool, - cx: &mut ModelContext, + cx: &mut ModelContext, ) -> Task> { let buffer = buffer_handle.read(cx); @@ -449,7 +381,7 @@ impl LocalBufferStore { let new_file = save.await?; let mtime = new_file.disk_state().mtime(); this.update(&mut cx, |this, cx| { - if let Some((downstream_client, project_id)) = this.downstream_client(cx) { + if let Some((downstream_client, project_id)) = this.downstream_client.clone() { if has_changed_file { downstream_client .send(proto::UpdateBufferFile { @@ -478,15 +410,24 @@ impl LocalBufferStore { }) } - fn subscribe_to_worktree(&mut self, worktree: &Model, cx: &mut ModelContext) { + fn subscribe_to_worktree( + &mut self, + worktree: &Model, + cx: &mut ModelContext, + ) { cx.subscribe(worktree, |this, worktree, event, cx| { if worktree.read(cx).is_local() { match event { worktree::Event::UpdatedEntries(changes) => { - this.local_worktree_entries_changed(&worktree, changes, cx); + Self::local_worktree_entries_changed(this, &worktree, changes, cx); } worktree::Event::UpdatedGitRepositories(updated_repos) => { - this.local_worktree_git_repos_changed(worktree.clone(), updated_repos, cx) + Self::local_worktree_git_repos_changed( + this, + worktree.clone(), + updated_repos, + cx, + ) } _ => {} } @@ -496,66 +437,67 @@ impl LocalBufferStore { } fn local_worktree_entries_changed( - &mut self, + this: &mut BufferStore, worktree_handle: &Model, changes: &[(Arc, ProjectEntryId, PathChange)], - cx: &mut ModelContext, + cx: &mut ModelContext, ) { let snapshot = worktree_handle.read(cx).snapshot(); for (path, entry_id, _) in changes { - self.local_worktree_entry_changed(*entry_id, path, worktree_handle, &snapshot, cx); + Self::local_worktree_entry_changed( + this, + *entry_id, + path, + worktree_handle, + &snapshot, + cx, + ); } } fn local_worktree_git_repos_changed( - &mut self, + this: &mut BufferStore, worktree_handle: Model, changed_repos: &UpdatedGitRepositoriesSet, - cx: &mut ModelContext, + cx: &mut ModelContext, ) { debug_assert!(worktree_handle.read(cx).is_local()); - let Some(buffer_store) = self.buffer_store.upgrade() else { - return; - }; // Identify the loading buffers whose containing repository that has changed. - let (future_buffers, current_buffers) = buffer_store.update(cx, |buffer_store, cx| { - let future_buffers = buffer_store - .loading_buffers() - .filter_map(|(project_path, receiver)| { - if project_path.worktree_id != worktree_handle.read(cx).id() { - return None; - } - let path = &project_path.path; - changed_repos - .iter() - .find(|(work_dir, _)| path.starts_with(work_dir))?; - let path = path.clone(); - Some(async move { - BufferStore::wait_for_loading_buffer(receiver) - .await - .ok() - .map(|buffer| (buffer, path)) - }) + let future_buffers = this + .loading_buffers() + .filter_map(|(project_path, receiver)| { + if project_path.worktree_id != worktree_handle.read(cx).id() { + return None; + } + let path = &project_path.path; + changed_repos + .iter() + .find(|(work_dir, _)| path.starts_with(work_dir))?; + let path = path.clone(); + Some(async move { + BufferStore::wait_for_loading_buffer(receiver) + .await + .ok() + .map(|buffer| (buffer, path)) }) - .collect::>(); + }) + .collect::>(); - // Identify the current buffers whose containing repository has changed. - let current_buffers = buffer_store - .buffers() - .filter_map(|buffer| { - let file = File::from_dyn(buffer.read(cx).file())?; - if file.worktree != worktree_handle { - return None; - } - changed_repos - .iter() - .find(|(work_dir, _)| file.path.starts_with(work_dir))?; - Some((buffer, file.path.clone())) - }) - .collect::>(); - (future_buffers, current_buffers) - }); + // Identify the current buffers whose containing repository has changed. + let current_buffers = this + .buffers() + .filter_map(|buffer| { + let file = File::from_dyn(buffer.read(cx).file())?; + if file.worktree != worktree_handle { + return None; + } + changed_repos + .iter() + .find(|(work_dir, _)| file.path.starts_with(work_dir))?; + Some((buffer, file.path.clone())) + }) + .collect::>(); if future_buffers.len() + current_buffers.len() == 0 { return; @@ -603,7 +545,7 @@ impl LocalBufferStore { buffer.set_diff_base(diff_base.clone(), cx); buffer.remote_id().to_proto() }); - if let Some((client, project_id)) = &this.downstream_client(cx) { + if let Some((client, project_id)) = &this.downstream_client.clone() { client .send(proto::UpdateDiffBase { project_id: *project_id, @@ -619,42 +561,44 @@ impl LocalBufferStore { } fn local_worktree_entry_changed( - &mut self, + this: &mut BufferStore, entry_id: ProjectEntryId, path: &Arc, worktree: &Model, snapshot: &worktree::Snapshot, - cx: &mut ModelContext, + cx: &mut ModelContext, ) -> Option<()> { let project_path = ProjectPath { worktree_id: snapshot.id(), path: path.clone(), }; - let buffer_id = match self.local_buffer_ids_by_entry_id.get(&entry_id) { - Some(&buffer_id) => buffer_id, - None => self.local_buffer_ids_by_path.get(&project_path).copied()?, + + let buffer_id = { + let local = this.as_local_mut()?; + match local.local_buffer_ids_by_entry_id.get(&entry_id) { + Some(&buffer_id) => buffer_id, + None => local.local_buffer_ids_by_path.get(&project_path).copied()?, + } }; - let buffer = self - .buffer_store - .update(cx, |buffer_store, _| { - if let Some(buffer) = buffer_store.get(buffer_id) { - Some(buffer) - } else { - buffer_store.opened_buffers.remove(&buffer_id); - None - } - }) - .ok() - .flatten(); + + let buffer = if let Some(buffer) = this.get(buffer_id) { + Some(buffer) + } else { + this.opened_buffers.remove(&buffer_id); + None + }; + let buffer = if let Some(buffer) = buffer { buffer } else { - self.local_buffer_ids_by_path.remove(&project_path); - self.local_buffer_ids_by_entry_id.remove(&entry_id); + let this = this.as_local_mut()?; + this.local_buffer_ids_by_path.remove(&project_path); + this.local_buffer_ids_by_entry_id.remove(&entry_id); return None; }; let events = buffer.update(cx, |buffer, cx| { + let local = this.as_local_mut()?; let file = buffer.file()?; let old_file = File::from_dyn(Some(file))?; if old_file.worktree != *worktree { @@ -695,11 +639,11 @@ impl LocalBufferStore { let mut events = Vec::new(); if new_file.path != old_file.path { - self.local_buffer_ids_by_path.remove(&ProjectPath { + local.local_buffer_ids_by_path.remove(&ProjectPath { path: old_file.path.clone(), worktree_id: old_file.worktree_id(cx), }); - self.local_buffer_ids_by_path.insert( + local.local_buffer_ids_by_path.insert( ProjectPath { worktree_id: new_file.worktree_id(cx), path: new_file.path.clone(), @@ -714,15 +658,16 @@ impl LocalBufferStore { if new_file.entry_id != old_file.entry_id { if let Some(entry_id) = old_file.entry_id { - self.local_buffer_ids_by_entry_id.remove(&entry_id); + local.local_buffer_ids_by_entry_id.remove(&entry_id); } if let Some(entry_id) = new_file.entry_id { - self.local_buffer_ids_by_entry_id + local + .local_buffer_ids_by_entry_id .insert(entry_id, buffer_id); } } - if let Some((client, project_id)) = &self.downstream_client(cx) { + if let Some((client, project_id)) = &this.downstream_client { client .send(proto::UpdateBufferFile { project_id: *project_id, @@ -735,25 +680,14 @@ impl LocalBufferStore { buffer.file_updated(Arc::new(new_file), cx); Some(events) })?; - self.buffer_store - .update(cx, |_buffer_store, cx| { - for event in events { - cx.emit(event); - } - }) - .log_err()?; + + for event in events { + cx.emit(event); + } None } - fn downstream_client(&self, cx: &AppContext) -> Option<(AnyProtoClient, u64)> { - self.buffer_store - .upgrade()? - .read(cx) - .downstream_client - .clone() - } - fn buffer_changed_file(&mut self, buffer: Model, cx: &mut AppContext) -> Option<()> { let file = File::from_dyn(buffer.read(cx).file())?; @@ -779,29 +713,17 @@ impl LocalBufferStore { Some(()) } -} - -impl BufferStoreImpl for Model { - fn as_remote(&self) -> Option> { - None - } - - fn as_local(&self) -> Option> { - Some(self.clone()) - } fn save_buffer( &self, buffer: Model, cx: &mut ModelContext, ) -> Task> { - self.update(cx, |this, cx| { - let Some(file) = File::from_dyn(buffer.read(cx).file()) else { - return Task::ready(Err(anyhow!("buffer doesn't have a file"))); - }; - let worktree = file.worktree.clone(); - this.save_local_buffer(buffer, worktree, file.path.clone(), false, cx) - }) + let Some(file) = File::from_dyn(buffer.read(cx).file()) else { + return Task::ready(Err(anyhow!("buffer doesn't have a file"))); + }; + let worktree = file.worktree.clone(); + self.save_local_buffer(buffer, worktree, file.path.clone(), false, cx) } fn save_buffer_as( @@ -810,16 +732,14 @@ impl BufferStoreImpl for Model { path: ProjectPath, cx: &mut ModelContext, ) -> Task> { - self.update(cx, |this, cx| { - let Some(worktree) = this - .worktree_store - .read(cx) - .worktree_for_id(path.worktree_id, cx) - else { - return Task::ready(Err(anyhow!("no such worktree"))); - }; - this.save_local_buffer(buffer, worktree, path.path.clone(), true, cx) - }) + let Some(worktree) = self + .worktree_store + .read(cx) + .worktree_for_id(path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!("no such worktree"))); + }; + self.save_local_buffer(buffer, worktree, path.path.clone(), true, cx) } fn open_buffer( @@ -828,76 +748,72 @@ impl BufferStoreImpl for Model { worktree: Model, cx: &mut ModelContext, ) -> Task>> { - let buffer_store = cx.weak_model(); - self.update(cx, |_, cx| { - let load_buffer = worktree.update(cx, |worktree, cx| { - let load_file = worktree.load_file(path.as_ref(), cx); - let reservation = cx.reserve_model(); - let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); - cx.spawn(move |_, mut cx| async move { - let loaded = load_file.await?; - let text_buffer = cx - .background_executor() - .spawn(async move { text::Buffer::new(0, buffer_id, loaded.text) }) - .await; - cx.insert_model(reservation, |_| { - Buffer::build( - text_buffer, - loaded.diff_base, - Some(loaded.file), - Capability::ReadWrite, - ) - }) + let load_buffer = worktree.update(cx, |worktree, cx| { + let load_file = worktree.load_file(path.as_ref(), cx); + let reservation = cx.reserve_model(); + let buffer_id = BufferId::from(reservation.entity_id().as_non_zero_u64()); + cx.spawn(move |_, mut cx| async move { + let loaded = load_file.await?; + let text_buffer = cx + .background_executor() + .spawn(async move { text::Buffer::new(0, buffer_id, loaded.text) }) + .await; + cx.insert_model(reservation, |_| { + Buffer::build( + text_buffer, + loaded.diff_base, + Some(loaded.file), + Capability::ReadWrite, + ) }) - }); - - cx.spawn(move |this, mut cx| async move { - let buffer = match load_buffer.await { - Ok(buffer) => Ok(buffer), - Err(error) if is_not_found_error(&error) => cx.new_model(|cx| { - let buffer_id = BufferId::from(cx.entity_id().as_non_zero_u64()); - let text_buffer = text::Buffer::new(0, buffer_id, "".into()); - Buffer::build( - text_buffer, - None, - Some(Arc::new(File { - worktree, - path, - disk_state: DiskState::New, - entry_id: None, - is_local: true, - is_private: false, - })), - Capability::ReadWrite, - ) - }), - Err(e) => Err(e), - }?; - this.update(&mut cx, |this, cx| { - buffer_store.update(cx, |buffer_store, cx| { - buffer_store.add_buffer(buffer.clone(), cx) - })??; - let buffer_id = buffer.read(cx).remote_id(); - if let Some(file) = File::from_dyn(buffer.read(cx).file()) { - this.local_buffer_ids_by_path.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - buffer_id, - ); - - if let Some(entry_id) = file.entry_id { - this.local_buffer_ids_by_entry_id - .insert(entry_id, buffer_id); - } - } - - anyhow::Ok(()) - })??; - - Ok(buffer) }) + }); + + cx.spawn(move |this, mut cx| async move { + let buffer = match load_buffer.await { + Ok(buffer) => Ok(buffer), + Err(error) if is_not_found_error(&error) => cx.new_model(|cx| { + let buffer_id = BufferId::from(cx.entity_id().as_non_zero_u64()); + let text_buffer = text::Buffer::new(0, buffer_id, "".into()); + Buffer::build( + text_buffer, + None, + Some(Arc::new(File { + worktree, + path, + disk_state: DiskState::New, + entry_id: None, + is_local: true, + is_private: false, + })), + Capability::ReadWrite, + ) + }), + Err(e) => Err(e), + }?; + this.update(&mut cx, |this, cx| { + this.add_buffer(buffer.clone(), cx)?; + let buffer_id = buffer.read(cx).remote_id(); + if let Some(file) = File::from_dyn(buffer.read(cx).file()) { + let this = this.as_local_mut().unwrap(); + this.local_buffer_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + buffer_id, + ); + + if let Some(entry_id) = file.entry_id { + this.local_buffer_ids_by_entry_id + .insert(entry_id, buffer_id); + } + } + + anyhow::Ok(()) + })??; + + Ok(buffer) }) } @@ -954,26 +870,18 @@ impl BufferStore { /// Creates a buffer store, optionally retaining its buffers. pub fn local(worktree_store: Model, cx: &mut ModelContext) -> Self { - let this = cx.weak_model(); Self { - state: Box::new(cx.new_model(|cx| { - let subscription = cx.subscribe( - &worktree_store, - |this: &mut LocalBufferStore, _, event, cx| { - if let WorktreeStoreEvent::WorktreeAdded(worktree) = event { - this.subscribe_to_worktree(worktree, cx); - } - }, - ); - - LocalBufferStore { - local_buffer_ids_by_path: Default::default(), - local_buffer_ids_by_entry_id: Default::default(), - buffer_store: this, - worktree_store: worktree_store.clone(), - _subscription: subscription, - } - })), + state: BufferStoreState::Local(LocalBufferStore { + local_buffer_ids_by_path: Default::default(), + local_buffer_ids_by_entry_id: Default::default(), + worktree_store: worktree_store.clone(), + _subscription: cx.subscribe(&worktree_store, |this, _, event, cx| { + if let WorktreeStoreEvent::WorktreeAdded(worktree) = event { + let this = this.as_local_mut().unwrap(); + this.subscribe_to_worktree(worktree, cx); + } + }), + }), downstream_client: None, opened_buffers: Default::default(), shared_buffers: Default::default(), @@ -986,19 +894,17 @@ impl BufferStore { worktree_store: Model, upstream_client: AnyProtoClient, remote_id: u64, - cx: &mut ModelContext, + _cx: &mut ModelContext, ) -> Self { - let this = cx.weak_model(); Self { - state: Box::new(cx.new_model(|_| RemoteBufferStore { + state: BufferStoreState::Remote(RemoteBufferStore { shared_with_me: Default::default(), loading_remote_buffers_by_id: Default::default(), remote_buffer_listeners: Default::default(), project_id: remote_id, upstream_client, worktree_store: worktree_store.clone(), - buffer_store: this, - })), + }), downstream_client: None, opened_buffers: Default::default(), loading_buffers_by_path: Default::default(), @@ -1007,6 +913,27 @@ impl BufferStore { } } + fn as_local_mut(&mut self) -> Option<&mut LocalBufferStore> { + match &mut self.state { + BufferStoreState::Local(state) => Some(state), + _ => None, + } + } + + fn as_remote_mut(&mut self) -> Option<&mut RemoteBufferStore> { + match &mut self.state { + BufferStoreState::Remote(state) => Some(state), + _ => None, + } + } + + fn as_remote(&self) -> Option<&RemoteBufferStore> { + match &self.state { + BufferStoreState::Remote(state) => Some(state), + _ => None, + } + } + pub fn open_buffer( &mut self, project_path: ProjectPath, @@ -1035,10 +962,11 @@ impl BufferStore { let (mut tx, rx) = postage::watch::channel(); entry.insert(rx.clone()); - let project_path = project_path.clone(); - let load_buffer = self - .state - .open_buffer(project_path.path.clone(), worktree, cx); + let path = project_path.path.clone(); + let load_buffer = match &self.state { + BufferStoreState::Local(this) => this.open_buffer(path, worktree, cx), + BufferStoreState::Remote(this) => this.open_buffer(path, worktree, cx), + }; cx.spawn(move |this, mut cx| async move { let load_result = load_buffer.await; @@ -1063,7 +991,10 @@ impl BufferStore { } pub fn create_buffer(&mut self, cx: &mut ModelContext) -> Task>> { - self.state.create_buffer(cx) + match &self.state { + BufferStoreState::Local(this) => this.create_buffer(cx), + BufferStoreState::Remote(this) => this.create_buffer(cx), + } } pub fn save_buffer( @@ -1071,7 +1002,10 @@ impl BufferStore { buffer: Model, cx: &mut ModelContext, ) -> Task> { - self.state.save_buffer(buffer, cx) + match &mut self.state { + BufferStoreState::Local(this) => this.save_buffer(buffer, cx), + BufferStoreState::Remote(this) => this.save_remote_buffer(buffer.clone(), None, cx), + } } pub fn save_buffer_as( @@ -1081,7 +1015,12 @@ impl BufferStore { cx: &mut ModelContext, ) -> Task> { let old_file = buffer.read(cx).file().cloned(); - let task = self.state.save_buffer_as(buffer.clone(), path, cx); + let task = match &self.state { + BufferStoreState::Local(this) => this.save_buffer_as(buffer.clone(), path, cx), + BufferStoreState::Remote(this) => { + this.save_remote_buffer(buffer.clone(), Some(path.to_proto()), cx) + } + }; cx.spawn(|this, mut cx| async move { task.await?; this.update(&mut cx, |_, cx| { @@ -1306,19 +1245,10 @@ impl BufferStore { .ok_or_else(|| anyhow!("unknown buffer id {}", buffer_id)) } - pub fn get_possibly_incomplete( - &self, - buffer_id: BufferId, - cx: &AppContext, - ) -> Option> { + pub fn get_possibly_incomplete(&self, buffer_id: BufferId) -> Option> { self.get(buffer_id).or_else(|| { - self.state.as_remote().and_then(|remote| { - remote - .read(cx) - .loading_remote_buffers_by_id - .get(&buffer_id) - .cloned() - }) + self.as_remote() + .and_then(|remote| remote.loading_remote_buffers_by_id.get(&buffer_id).cloned()) }) } @@ -1337,9 +1267,8 @@ impl BufferStore { }) .collect(); let incomplete_buffer_ids = self - .state .as_remote() - .map(|remote| remote.read(cx).incomplete_buffer_ids()) + .map(|remote| remote.incomplete_buffer_ids()) .unwrap_or_default(); (buffers, incomplete_buffer_ids) } @@ -1357,12 +1286,10 @@ impl BufferStore { }); } - if let Some(remote) = self.state.as_remote() { - remote.update(cx, |remote, _| { - // Wake up all futures currently waiting on a buffer to get opened, - // to give them a chance to fail now that we've disconnected. - remote.remote_buffer_listeners.clear() - }) + if let Some(remote) = self.as_remote_mut() { + // Wake up all futures currently waiting on a buffer to get opened, + // to give them a chance to fail now that we've disconnected. + remote.remote_buffer_listeners.clear() } } @@ -1447,10 +1374,8 @@ impl BufferStore { ) { match event { BufferEvent::FileHandleChanged => { - if let Some(local) = self.state.as_local() { - local.update(cx, |local, cx| { - local.buffer_changed_file(buffer, cx); - }) + if let Some(local) = self.as_local_mut() { + local.buffer_changed_file(buffer, cx); } } BufferEvent::Reloaded => { @@ -1593,13 +1518,13 @@ impl BufferStore { capability: Capability, cx: &mut ModelContext, ) -> Result<()> { - let Some(remote) = self.state.as_remote() else { + let Some(remote) = self.as_remote_mut() else { return Err(anyhow!("buffer store is not a remote")); }; - if let Some(buffer) = remote.update(cx, |remote, cx| { - remote.handle_create_buffer_for_peer(envelope, replica_id, capability, cx) - })? { + if let Some(buffer) = + remote.handle_create_buffer_for_peer(envelope, replica_id, capability, cx)? + { self.add_buffer(buffer, cx)?; } @@ -1616,7 +1541,7 @@ impl BufferStore { this.update(&mut cx, |this, cx| { let payload = envelope.payload.clone(); - if let Some(buffer) = this.get_possibly_incomplete(buffer_id, cx) { + if let Some(buffer) = this.get_possibly_incomplete(buffer_id) { let file = payload.file.ok_or_else(|| anyhow!("invalid file"))?; let worktree = this .worktree_store @@ -1662,7 +1587,7 @@ impl BufferStore { this.update(&mut cx, |this, cx| { let buffer_id = envelope.payload.buffer_id; let buffer_id = BufferId::new(buffer_id)?; - if let Some(buffer) = this.get_possibly_incomplete(buffer_id, cx) { + if let Some(buffer) = this.get_possibly_incomplete(buffer_id) { buffer.update(cx, |buffer, cx| { buffer.set_diff_base(envelope.payload.diff_base.clone(), cx) }); @@ -1756,7 +1681,7 @@ impl BufferStore { let version = deserialize_version(&envelope.payload.version); let mtime = envelope.payload.mtime.clone().map(|time| time.into()); this.update(&mut cx, move |this, cx| { - if let Some(buffer) = this.get_possibly_incomplete(buffer_id, cx) { + if let Some(buffer) = this.get_possibly_incomplete(buffer_id) { buffer.update(cx, |buffer, cx| { buffer.did_save(version, mtime, cx); }); @@ -1788,7 +1713,7 @@ impl BufferStore { .ok_or_else(|| anyhow!("missing line ending"))?, ); this.update(&mut cx, |this, cx| { - if let Some(buffer) = this.get_possibly_incomplete(buffer_id, cx) { + if let Some(buffer) = this.get_possibly_incomplete(buffer_id) { buffer.update(cx, |buffer, cx| { buffer.did_reload(version, line_ending, mtime, cx); }); @@ -1877,8 +1802,10 @@ impl BufferStore { if buffers.is_empty() { return Task::ready(Ok(ProjectTransaction::default())); } - - self.state.reload_buffers(buffers, push_to_history, cx) + match &self.state { + BufferStoreState::Local(this) => this.reload_buffers(buffers, push_to_history, cx), + BufferStoreState::Remote(this) => this.reload_buffers(buffers, push_to_history, cx), + } } async fn handle_reload_buffers( @@ -2000,26 +1927,23 @@ impl BufferStore { self.add_buffer(buffer.clone(), cx).log_err(); let buffer_id = buffer.read(cx).remote_id(); - let local = self - .state - .as_local() + let this = self + .as_local_mut() .expect("local-only method called in a non-local context"); - local.update(cx, |this, cx| { - if let Some(file) = File::from_dyn(buffer.read(cx).file()) { - this.local_buffer_ids_by_path.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - buffer_id, - ); + if let Some(file) = File::from_dyn(buffer.read(cx).file()) { + this.local_buffer_ids_by_path.insert( + ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }, + buffer_id, + ); - if let Some(entry_id) = file.entry_id { - this.local_buffer_ids_by_entry_id - .insert(entry_id, buffer_id); - } + if let Some(entry_id) = file.entry_id { + this.local_buffer_ids_by_entry_id + .insert(entry_id, buffer_id); } - }); + } buffer } @@ -2029,10 +1953,8 @@ impl BufferStore { push_to_history: bool, cx: &mut ModelContext, ) -> Task> { - if let Some(remote) = self.state.as_remote() { - remote.update(cx, |remote, cx| { - remote.deserialize_project_transaction(message, push_to_history, cx) - }) + if let Some(this) = self.as_remote_mut() { + this.deserialize_project_transaction(message, push_to_history, cx) } else { debug_panic!("not a remote buffer store"); Task::ready(Err(anyhow!("not a remote buffer store"))) @@ -2040,12 +1962,12 @@ impl BufferStore { } pub fn wait_for_remote_buffer( - &self, + &mut self, id: BufferId, - cx: &mut AppContext, + cx: &mut ModelContext, ) -> Task>> { - if let Some(remote) = self.state.as_remote() { - remote.update(cx, |remote, cx| remote.wait_for_remote_buffer(id, cx)) + if let Some(this) = self.as_remote_mut() { + this.wait_for_remote_buffer(id, cx) } else { debug_panic!("not a remote buffer store"); Task::ready(Err(anyhow!("not a remote buffer store"))) From 9ee1aba80a31d0bfb8ccb623f60e793d80d8e6e1 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 16:13:27 -0500 Subject: [PATCH 014/215] assistant2: Stream in completion text (#21182) This PR makes it so that the completion text streams into the message list rather than being buffered until the end. Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 5 +- crates/assistant2/src/message_editor.rs | 51 +---------------- crates/assistant2/src/thread.rs | 70 +++++++++++++++++++++++- 3 files changed, 75 insertions(+), 51 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 88a3f73176..abbb2f20db 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -1,7 +1,7 @@ use anyhow::Result; use gpui::{ prelude::*, px, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, - FocusableView, Model, Pixels, Task, View, ViewContext, WeakView, WindowContext, + FocusableView, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, WindowContext, }; use language_model::LanguageModelRegistry; use language_model_selector::LanguageModelSelector; @@ -28,6 +28,7 @@ pub struct AssistantPanel { pane: View, thread: Model, message_editor: View, + _subscriptions: Vec, } impl AssistantPanel { @@ -59,11 +60,13 @@ impl AssistantPanel { }); let thread = cx.new_model(Thread::new); + let subscriptions = vec![cx.observe(&thread, |_, _, cx| cx.notify())]; Self { pane, thread: thread.clone(), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), + _subscriptions: subscriptions, } } } diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index 63f8c869d4..d195682cb3 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,14 +1,11 @@ use editor::{Editor, EditorElement, EditorStyle}; -use futures::StreamExt; use gpui::{AppContext, Model, TextStyle, View}; use language_model::{ - LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, - LanguageModelRequestMessage, MessageContent, Role, StopReason, + LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, }; use settings::Settings; use theme::ThemeSettings; use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; -use util::ResultExt; use crate::thread::{self, Thread}; use crate::Chat; @@ -71,50 +68,8 @@ impl MessageEditor { editor.clear(cx); }); - let task = cx.spawn(|this, mut cx| async move { - let stream = model.stream_completion(request, &cx); - let stream_completion = async { - let mut events = stream.await?; - let mut stop_reason = StopReason::EndTurn; - - let mut text = String::new(); - - while let Some(event) = events.next().await { - let event = event?; - match event { - LanguageModelCompletionEvent::StartMessage { .. } => {} - LanguageModelCompletionEvent::Stop(reason) => { - stop_reason = reason; - } - LanguageModelCompletionEvent::Text(chunk) => { - text.push_str(&chunk); - } - LanguageModelCompletionEvent::ToolUse(_tool_use) => {} - } - - smol::future::yield_now().await; - } - - anyhow::Ok((stop_reason, text)) - }; - - let result = stream_completion.await; - - this.update(&mut cx, |this, cx| { - if let Some((_stop_reason, text)) = result.log_err() { - this.thread.update(cx, |thread, _cx| { - thread.messages.push(thread::Message { - role: Role::Assistant, - text, - }); - }); - } - }) - .ok(); - }); - - self.thread.update(cx, |thread, _cx| { - thread.pending_completion_tasks.push(task); + self.thread.update(cx, |thread, cx| { + thread.stream_completion(request, model, cx) }); None diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 1553eaabb6..a6c870b456 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -1,5 +1,11 @@ -use gpui::{ModelContext, Task}; -use language_model::Role; +use std::sync::Arc; + +use futures::StreamExt as _; +use gpui::{EventEmitter, ModelContext, Task}; +use language_model::{ + LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, Role, StopReason, +}; +use util::ResultExt as _; /// A message in a [`Thread`]. pub struct Message { @@ -20,4 +26,64 @@ impl Thread { pending_completion_tasks: Vec::new(), } } + + pub fn stream_completion( + &mut self, + request: LanguageModelRequest, + model: Arc, + cx: &mut ModelContext, + ) { + let task = cx.spawn(|this, mut cx| async move { + let stream = model.stream_completion(request, &cx); + let stream_completion = async { + let mut events = stream.await?; + let mut stop_reason = StopReason::EndTurn; + + while let Some(event) = events.next().await { + let event = event?; + + this.update(&mut cx, |thread, cx| { + match event { + LanguageModelCompletionEvent::StartMessage { .. } => { + thread.messages.push(Message { + role: Role::Assistant, + text: String::new(), + }); + } + LanguageModelCompletionEvent::Stop(reason) => { + stop_reason = reason; + } + LanguageModelCompletionEvent::Text(chunk) => { + if let Some(last_message) = thread.messages.last_mut() { + if last_message.role == Role::Assistant { + last_message.text.push_str(&chunk); + } + } + } + LanguageModelCompletionEvent::ToolUse(_tool_use) => {} + } + + cx.emit(ThreadEvent::StreamedCompletion); + cx.notify(); + })?; + + smol::future::yield_now().await; + } + + anyhow::Ok(stop_reason) + }; + + let result = stream_completion.await; + let _ = result.log_err(); + }); + + self.pending_completion_tasks.push(task); + } } + +#[derive(Debug, Clone)] +pub enum ThreadEvent { + StreamedCompletion, +} + +impl EventEmitter for Thread {} From e7b004756247b04ecfa031424a6c950c6dfbf0ce Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 16:28:38 -0500 Subject: [PATCH 015/215] assistant2: Remove unnecessary `Pane` (#21183) This PR removes an unnecessary `Pane` that was copied over from `assistant::AssistantPanel` to `assistant2::AssistantPanel`. Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 35 ++---------------------- crates/assistant2/src/message_editor.rs | 8 +++++- 2 files changed, 10 insertions(+), 33 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index abbb2f20db..20ce26fc59 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -7,7 +7,7 @@ use language_model::LanguageModelRegistry; use language_model_selector::LanguageModelSelector; use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; -use workspace::{Pane, Workspace}; +use workspace::Workspace; use crate::message_editor::MessageEditor; use crate::thread::Thread; @@ -25,7 +25,6 @@ pub fn init(cx: &mut AppContext) { } pub struct AssistantPanel { - pane: View, thread: Model, message_editor: View, _subscriptions: Vec, @@ -43,27 +42,11 @@ impl AssistantPanel { }) } - fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let pane = cx.new_view(|cx| { - let mut pane = Pane::new( - workspace.weak_handle(), - workspace.project().clone(), - Default::default(), - None, - NewThread.boxed_clone(), - cx, - ); - pane.set_can_split(false, cx); - pane.set_can_navigate(true, cx); - - pane - }); - + fn new(_workspace: &Workspace, cx: &mut ViewContext) -> Self { let thread = cx.new_model(Thread::new); let subscriptions = vec![cx.observe(&thread, |_, _, cx| cx.notify())]; Self { - pane, thread: thread.clone(), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), _subscriptions: subscriptions, @@ -73,7 +56,7 @@ impl AssistantPanel { impl FocusableView for AssistantPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.pane.focus_handle(cx) + self.message_editor.focus_handle(cx) } } @@ -100,20 +83,8 @@ impl Panel for AssistantPanel { fn set_size(&mut self, _size: Option, _cx: &mut ViewContext) {} - fn is_zoomed(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).is_zoomed() - } - - fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { - self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); - } - fn set_active(&mut self, _active: bool, _cx: &mut ViewContext) {} - fn pane(&self) -> Option> { - Some(self.pane.clone()) - } - fn remote_id() -> Option { Some(proto::PanelId::AssistantPanel) } diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index d195682cb3..e1606ff27a 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,5 +1,5 @@ use editor::{Editor, EditorElement, EditorStyle}; -use gpui::{AppContext, Model, TextStyle, View}; +use gpui::{AppContext, FocusableView, Model, TextStyle, View}; use language_model::{ LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, }; @@ -97,6 +97,12 @@ impl MessageEditor { } } +impl FocusableView for MessageEditor { + fn focus_handle(&self, cx: &AppContext) -> gpui::FocusHandle { + self.editor.focus_handle(cx) + } +} + impl Render for MessageEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let font_size = TextSize::Default.rems(cx); From 2b9250843c110b13644c81b7e3abd17a92edc567 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 16:51:32 -0500 Subject: [PATCH 016/215] assistant2: Include previous messages in the thread in the completion request (#21184) This PR makes it so previous messages in the thread are included when constructing the completion request, instead of only sending up the most recent user message. Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 2 +- crates/assistant2/src/message_editor.rs | 48 +++------------------ crates/assistant2/src/thread.rs | 54 ++++++++++++++++++++++-- 3 files changed, 58 insertions(+), 46 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 20ce26fc59..f3dd42e4d6 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -234,7 +234,7 @@ impl Render for AssistantPanel { .p_2() .overflow_y_scroll() .bg(cx.theme().colors().panel_background) - .children(self.thread.read(cx).messages.iter().map(|message| { + .children(self.thread.read(cx).messages().map(|message| { v_flex() .p_2() .border_1() diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index e1606ff27a..f0a8e260bc 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,20 +1,13 @@ use editor::{Editor, EditorElement, EditorStyle}; use gpui::{AppContext, FocusableView, Model, TextStyle, View}; -use language_model::{ - LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, -}; +use language_model::LanguageModelRegistry; use settings::Settings; use theme::ThemeSettings; use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; -use crate::thread::{self, Thread}; +use crate::thread::{RequestKind, Thread}; use crate::Chat; -#[derive(Debug, Clone, Copy)] -pub enum RequestKind { - Chat, -} - pub struct MessageEditor { thread: Model, editor: View, @@ -54,47 +47,20 @@ impl MessageEditor { let model_registry = LanguageModelRegistry::read_global(cx); let model = model_registry.active_model()?; - let request = self.build_completion_request(request_kind, cx); - - let user_message = self.editor.read(cx).text(cx); - self.thread.update(cx, |thread, _cx| { - thread.messages.push(thread::Message { - role: Role::User, - text: user_message, - }); - }); - - self.editor.update(cx, |editor, cx| { + let user_message = self.editor.update(cx, |editor, cx| { + let text = editor.text(cx); editor.clear(cx); + text }); self.thread.update(cx, |thread, cx| { + thread.insert_user_message(user_message); + let request = thread.to_completion_request(request_kind, cx); thread.stream_completion(request, model, cx) }); None } - - fn build_completion_request( - &self, - _request_kind: RequestKind, - cx: &AppContext, - ) -> LanguageModelRequest { - let text = self.editor.read(cx).text(cx); - - let request = LanguageModelRequest { - messages: vec![LanguageModelRequestMessage { - role: Role::User, - content: vec![MessageContent::Text(text)], - cache: false, - }], - tools: Vec::new(), - stop: Vec::new(), - temperature: None, - }; - - request - } } impl FocusableView for MessageEditor { diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index a6c870b456..a433c10267 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -1,12 +1,18 @@ use std::sync::Arc; use futures::StreamExt as _; -use gpui::{EventEmitter, ModelContext, Task}; +use gpui::{AppContext, EventEmitter, ModelContext, Task}; use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, Role, StopReason, + LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, + MessageContent, Role, StopReason, }; use util::ResultExt as _; +#[derive(Debug, Clone, Copy)] +pub enum RequestKind { + Chat, +} + /// A message in a [`Thread`]. pub struct Message { pub role: Role, @@ -15,8 +21,8 @@ pub struct Message { /// A thread of conversation with the LLM. pub struct Thread { - pub messages: Vec, - pub pending_completion_tasks: Vec>, + messages: Vec, + pending_completion_tasks: Vec>, } impl Thread { @@ -27,6 +33,46 @@ impl Thread { } } + pub fn messages(&self) -> impl Iterator { + self.messages.iter() + } + + pub fn insert_user_message(&mut self, text: impl Into) { + self.messages.push(Message { + role: Role::User, + text: text.into(), + }); + } + + pub fn to_completion_request( + &self, + _request_kind: RequestKind, + _cx: &AppContext, + ) -> LanguageModelRequest { + let mut request = LanguageModelRequest { + messages: vec![], + tools: Vec::new(), + stop: Vec::new(), + temperature: None, + }; + + for message in &self.messages { + let mut request_message = LanguageModelRequestMessage { + role: message.role, + content: Vec::new(), + cache: false, + }; + + request_message + .content + .push(MessageContent::Text(message.text.clone())); + + request.messages.push(request_message); + } + + request + } + pub fn stream_completion( &mut self, request: LanguageModelRequest, From cc5daa22bdf8e549becd5d33e4eb29b72f149c04 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 17:07:55 -0500 Subject: [PATCH 017/215] assistant2: Improve tracking of pending completions (#21186) This PR improves the tracking of pending completions in `assistant2` such that we actually remove ones that have been completed. Release Notes: - N/A --- crates/assistant2/src/thread.rs | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index a433c10267..c1df6c76d3 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -6,7 +6,7 @@ use language_model::{ LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, MessageContent, Role, StopReason, }; -use util::ResultExt as _; +use util::{post_inc, ResultExt as _}; #[derive(Debug, Clone, Copy)] pub enum RequestKind { @@ -19,17 +19,24 @@ pub struct Message { pub text: String, } +struct PendingCompletion { + id: usize, + _task: Task<()>, +} + /// A thread of conversation with the LLM. pub struct Thread { messages: Vec, - pending_completion_tasks: Vec>, + completion_count: usize, + pending_completions: Vec, } impl Thread { pub fn new(_cx: &mut ModelContext) -> Self { Self { messages: Vec::new(), - pending_completion_tasks: Vec::new(), + completion_count: 0, + pending_completions: Vec::new(), } } @@ -79,7 +86,9 @@ impl Thread { model: Arc, cx: &mut ModelContext, ) { - let task = cx.spawn(|this, mut cx| async move { + let pending_completion_id = post_inc(&mut self.completion_count); + + let task = cx.spawn(|thread, mut cx| async move { let stream = model.stream_completion(request, &cx); let stream_completion = async { let mut events = stream.await?; @@ -88,7 +97,7 @@ impl Thread { while let Some(event) = events.next().await { let event = event?; - this.update(&mut cx, |thread, cx| { + thread.update(&mut cx, |thread, cx| { match event { LanguageModelCompletionEvent::StartMessage { .. } => { thread.messages.push(Message { @@ -116,6 +125,12 @@ impl Thread { smol::future::yield_now().await; } + thread.update(&mut cx, |thread, _cx| { + thread + .pending_completions + .retain(|completion| completion.id != pending_completion_id); + })?; + anyhow::Ok(stop_reason) }; @@ -123,7 +138,10 @@ impl Thread { let _ = result.log_err(); }); - self.pending_completion_tasks.push(task); + self.pending_completions.push(PendingCompletion { + id: pending_completion_id, + _task: task, + }); } } From 321fd19763da4526845e47e58ba4e7e706787134 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 17:24:25 -0500 Subject: [PATCH 018/215] assistant2: Wire up `assistant2::NewThread` action (#21187) This PR wires up the `assistant2::NewThread` action so that you can create new threads. Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index f3dd42e4d6..c33e9d520d 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -52,6 +52,17 @@ impl AssistantPanel { _subscriptions: subscriptions, } } + + fn new_thread(&mut self, cx: &mut ViewContext) { + let thread = cx.new_model(Thread::new); + let subscriptions = vec![cx.observe(&thread, |_, _, cx| cx.notify())]; + + self.message_editor = cx.new_view(|cx| MessageEditor::new(thread.clone(), cx)); + self.thread = thread; + self._subscriptions = subscriptions; + + self.message_editor.focus_handle(cx).focus(cx); + } } impl FocusableView for AssistantPanel { @@ -222,8 +233,8 @@ impl Render for AssistantPanel { .key_context("AssistantPanel2") .justify_between() .size_full() - .on_action(cx.listener(|_this, _: &NewThread, _cx| { - println!("Action: New Thread"); + .on_action(cx.listener(|this, _: &NewThread, cx| { + this.new_thread(cx); })) .child(self.render_toolbar(cx)) .child( From 3901d4610115989d1b7e4d5c637a297da8219809 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 18:26:34 -0500 Subject: [PATCH 019/215] Factor tool definitions out of `assistant` (#21189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR factors the tool definitions out of the `assistant` crate so that they can be shared between `assistant` and `assistant2`. `ToolWorkingSet` now lives in `assistant_tool`. The tool definitions themselves live in `assistant_tools`, with the exception of the `ContextServerTool`, which has been moved to the `context_server` crate. As part of this refactoring I needed to extract the `ContextServerSettings` to a separate `context_server_settings` crate so that the `extension_host`—which is referenced by the `remote_server`—can name the `ContextServerSettings` type without pulling in some undesired dependencies. Release Notes: - N/A --- Cargo.lock | 42 +++++++++++-- Cargo.toml | 8 ++- crates/assistant/Cargo.toml | 2 +- crates/assistant/src/assistant.rs | 12 +--- crates/assistant/src/assistant_panel.rs | 4 +- crates/assistant/src/context.rs | 2 +- crates/assistant/src/context/context_tests.rs | 2 +- crates/assistant/src/context_store.rs | 19 +++--- .../slash_command/context_server_command.rs | 12 ++-- crates/assistant/src/tools.rs | 2 - crates/assistant_tool/src/assistant_tool.rs | 4 +- .../src/tool_working_set.rs | 6 +- crates/assistant_tools/Cargo.toml | 22 +++++++ .../LICENSE-GPL | 0 crates/assistant_tools/src/assistant_tools.rs | 13 ++++ .../tools => assistant_tools/src}/now_tool.rs | 0 crates/collab/Cargo.toml | 3 +- crates/collab/src/tests/integration_tests.rs | 7 ++- .../Cargo.toml | 9 ++- crates/context_server/LICENSE-GPL | 1 + .../src/client.rs | 0 .../src/context_server.rs} | 7 ++- .../src}/context_server_tool.rs | 5 +- .../src/extension_context_server.rs | 3 +- .../src/manager.rs | 56 +---------------- .../src/protocol.rs | 0 .../src/registry.rs | 2 +- .../src/types.rs | 0 crates/context_server_settings/Cargo.toml | 21 +++++++ crates/context_server_settings/LICENSE-GPL | 1 + .../src/context_server_settings.rs | 61 +++++++++++++++++++ crates/extension_host/Cargo.toml | 2 +- .../src/wasm_host/wit/since_v0_2_0.rs | 2 +- crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + 35 files changed, 219 insertions(+), 113 deletions(-) delete mode 100644 crates/assistant/src/tools.rs rename crates/{assistant => assistant_tool}/src/tool_working_set.rs (98%) create mode 100644 crates/assistant_tools/Cargo.toml rename crates/{context_servers => assistant_tools}/LICENSE-GPL (100%) create mode 100644 crates/assistant_tools/src/assistant_tools.rs rename crates/{assistant/src/tools => assistant_tools/src}/now_tool.rs (100%) rename crates/{context_servers => context_server}/Cargo.toml (76%) create mode 120000 crates/context_server/LICENSE-GPL rename crates/{context_servers => context_server}/src/client.rs (100%) rename crates/{context_servers/src/context_servers.rs => context_server/src/context_server.rs} (76%) rename crates/{assistant/src/tools => context_server/src}/context_server_tool.rs (97%) rename crates/{context_servers => context_server}/src/extension_context_server.rs (97%) rename crates/{context_servers => context_server}/src/manager.rs (84%) rename crates/{context_servers => context_server}/src/protocol.rs (100%) rename crates/{context_servers => context_server}/src/registry.rs (98%) rename crates/{context_servers => context_server}/src/types.rs (100%) create mode 100644 crates/context_server_settings/Cargo.toml create mode 120000 crates/context_server_settings/LICENSE-GPL create mode 100644 crates/context_server_settings/src/context_server_settings.rs diff --git a/Cargo.lock b/Cargo.lock index b8c24b4594..7152bf8d08 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -383,7 +383,7 @@ dependencies = [ "clock", "collections", "command_palette_hooks", - "context_servers", + "context_server", "ctor", "db", "editor", @@ -506,6 +506,20 @@ dependencies = [ "workspace", ] +[[package]] +name = "assistant_tools" +version = "0.1.0" +dependencies = [ + "anyhow", + "assistant_tool", + "chrono", + "gpui", + "schemars", + "serde", + "serde_json", + "workspace", +] + [[package]] name = "async-attributes" version = "1.1.2" @@ -2613,6 +2627,7 @@ dependencies = [ "anthropic", "anyhow", "assistant", + "assistant_tool", "async-stripe", "async-trait", "async-tungstenite 0.28.0", @@ -2631,7 +2646,7 @@ dependencies = [ "clock", "collab_ui", "collections", - "context_servers", + "context_server", "ctor", "dashmap 6.1.0", "derive_more", @@ -2874,12 +2889,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" [[package]] -name = "context_servers" +name = "context_server" version = "0.1.0" dependencies = [ "anyhow", + "assistant_tool", "collections", "command_palette_hooks", + "context_server_settings", "extension", "futures 0.3.31", "gpui", @@ -2887,13 +2904,27 @@ dependencies = [ "parking_lot", "postage", "project", - "schemars", "serde", "serde_json", "settings", "smol", + "ui", "url", "util", + "workspace", +] + +[[package]] +name = "context_server_settings" +version = "0.1.0" +dependencies = [ + "anyhow", + "collections", + "gpui", + "schemars", + "serde", + "serde_json", + "settings", ] [[package]] @@ -4209,7 +4240,7 @@ dependencies = [ "async-trait", "client", "collections", - "context_servers", + "context_server_settings", "ctor", "env_logger 0.11.5", "extension", @@ -15586,6 +15617,7 @@ dependencies = [ "assets", "assistant", "assistant2", + "assistant_tools", "async-watch", "audio", "auto_update", diff --git a/Cargo.toml b/Cargo.toml index 2e5111e2ff..7c141a1b6c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/assistant2", "crates/assistant_slash_command", "crates/assistant_tool", + "crates/assistant_tools", "crates/audio", "crates/auto_update", "crates/auto_update_ui", @@ -22,7 +23,8 @@ members = [ "crates/collections", "crates/command_palette", "crates/command_palette_hooks", - "crates/context_servers", + "crates/context_server", + "crates/context_server_settings", "crates/copilot", "crates/db", "crates/diagnostics", @@ -191,6 +193,7 @@ assistant = { path = "crates/assistant" } assistant2 = { path = "crates/assistant2" } assistant_slash_command = { path = "crates/assistant_slash_command" } assistant_tool = { path = "crates/assistant_tool" } +assistant_tools = { path = "crates/assistant_tools" } audio = { path = "crates/audio" } auto_update = { path = "crates/auto_update" } auto_update_ui = { path = "crates/auto_update_ui" } @@ -205,7 +208,8 @@ collab_ui = { path = "crates/collab_ui" } collections = { path = "crates/collections" } command_palette = { path = "crates/command_palette" } command_palette_hooks = { path = "crates/command_palette_hooks" } -context_servers = { path = "crates/context_servers" } +context_server = { path = "crates/context_server" } +context_server_settings = { path = "crates/context_server_settings" } copilot = { path = "crates/copilot" } db = { path = "crates/db" } diagnostics = { path = "crates/diagnostics" } diff --git a/crates/assistant/Cargo.toml b/crates/assistant/Cargo.toml index 0799d4bbdb..3b68b5cc9a 100644 --- a/crates/assistant/Cargo.toml +++ b/crates/assistant/Cargo.toml @@ -33,7 +33,7 @@ client.workspace = true clock.workspace = true collections.workspace = true command_palette_hooks.workspace = true -context_servers.workspace = true +context_server.workspace = true db.workspace = true editor.workspace = true feature_flags.workspace = true diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index f6e435bfb8..7e4e38e320 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -14,16 +14,12 @@ pub mod slash_command_settings; mod slash_command_working_set; mod streaming_diff; mod terminal_inline_assistant; -mod tool_working_set; -mod tools; use crate::slash_command::project_command::ProjectSlashCommandFeatureFlag; pub use crate::slash_command_working_set::{SlashCommandId, SlashCommandWorkingSet}; -pub use crate::tool_working_set::{ToolId, ToolWorkingSet}; pub use assistant_panel::{AssistantPanel, AssistantPanelEvent}; use assistant_settings::AssistantSettings; use assistant_slash_command::SlashCommandRegistry; -use assistant_tool::ToolRegistry; use client::{proto, Client}; use command_palette_hooks::CommandPaletteFilter; pub use context::*; @@ -246,7 +242,7 @@ pub fn init( assistant_slash_command::init(cx); assistant_tool::init(cx); assistant_panel::init(cx); - context_servers::init(cx); + context_server::init(cx); let prompt_builder = prompts::PromptBuilder::new(Some(PromptLoadingParams { fs: fs.clone(), @@ -259,7 +255,6 @@ pub fn init( .map(Arc::new) .unwrap_or_else(|| Arc::new(prompts::PromptBuilder::new(None).unwrap())); register_slash_commands(Some(prompt_builder.clone()), cx); - register_tools(cx); inline_assistant::init( fs.clone(), prompt_builder.clone(), @@ -423,11 +418,6 @@ fn update_slash_commands_from_settings(cx: &mut AppContext) { } } -fn register_tools(cx: &mut AppContext) { - let tool_registry = ToolRegistry::global(cx); - tool_registry.register_tool(tools::now_tool::NowTool); -} - pub fn humanize_token_count(count: usize) -> String { match count { 0..=999 => count.to_string(), diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 9a7beb96d2..e1ce7c4ab2 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1,6 +1,5 @@ use crate::slash_command::file_command::codeblock_fence_for_path; use crate::slash_command_working_set::SlashCommandWorkingSet; -use crate::ToolWorkingSet; use crate::{ assistant_settings::{AssistantDockPosition, AssistantSettings}, humanize_token_count, @@ -23,6 +22,7 @@ use crate::{ }; use anyhow::Result; use assistant_slash_command::{SlashCommand, SlashCommandOutputSection}; +use assistant_tool::ToolWorkingSet; use client::{proto, zed_urls, Client, Status}; use collections::{hash_map, BTreeSet, HashMap, HashSet}; use editor::{ @@ -1316,7 +1316,7 @@ impl AssistantPanel { fn restart_context_servers( workspace: &mut Workspace, - _action: &context_servers::Restart, + _action: &context_server::Restart, cx: &mut ViewContext, ) { let Some(assistant_panel) = workspace.panel::(cx) else { diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index 570180ed74..2a7985a8c7 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -2,7 +2,6 @@ mod context_tests; use crate::slash_command_working_set::SlashCommandWorkingSet; -use crate::ToolWorkingSet; use crate::{ prompts::PromptBuilder, slash_command::{file_command::FileCommandMetadata, SlashCommandLine}, @@ -12,6 +11,7 @@ use anyhow::{anyhow, Context as _, Result}; use assistant_slash_command::{ SlashCommandContent, SlashCommandEvent, SlashCommandOutputSection, SlashCommandResult, }; +use assistant_tool::ToolWorkingSet; use client::{self, proto, telemetry::Telemetry}; use clock::ReplicaId; use collections::{HashMap, HashSet}; diff --git a/crates/assistant/src/context/context_tests.rs b/crates/assistant/src/context/context_tests.rs index 84b94c72c3..7f058cc9e7 100644 --- a/crates/assistant/src/context/context_tests.rs +++ b/crates/assistant/src/context/context_tests.rs @@ -1,6 +1,5 @@ use super::{AssistantEdit, MessageCacheMetadata}; use crate::slash_command_working_set::SlashCommandWorkingSet; -use crate::ToolWorkingSet; use crate::{ assistant_panel, prompt_library, slash_command::file_command, AssistantEditKind, CacheStatus, Context, ContextEvent, ContextId, ContextOperation, InvokedSlashCommandId, MessageId, @@ -11,6 +10,7 @@ use assistant_slash_command::{ ArgumentCompletion, SlashCommand, SlashCommandContent, SlashCommandEvent, SlashCommandOutput, SlashCommandOutputSection, SlashCommandRegistry, SlashCommandResult, }; +use assistant_tool::ToolWorkingSet; use collections::{HashMap, HashSet}; use fs::FakeFs; use futures::{ diff --git a/crates/assistant/src/context_store.rs b/crates/assistant/src/context_store.rs index 217d59faa4..34d4e5a700 100644 --- a/crates/assistant/src/context_store.rs +++ b/crates/assistant/src/context_store.rs @@ -1,15 +1,16 @@ use crate::slash_command::context_server_command; +use crate::SlashCommandId; use crate::{ prompts::PromptBuilder, slash_command_working_set::SlashCommandWorkingSet, Context, ContextEvent, ContextId, ContextOperation, ContextVersion, SavedContext, SavedContextMetadata, }; -use crate::{tools, SlashCommandId, ToolId, ToolWorkingSet}; use anyhow::{anyhow, Context as _, Result}; +use assistant_tool::{ToolId, ToolWorkingSet}; use client::{proto, telemetry::Telemetry, Client, TypedEnvelope}; use clock::ReplicaId; use collections::HashMap; -use context_servers::manager::ContextServerManager; -use context_servers::ContextServerFactoryRegistry; +use context_server::manager::ContextServerManager; +use context_server::{ContextServerFactoryRegistry, ContextServerTool}; use fs::Fs; use futures::StreamExt; use fuzzy::StringMatchCandidate; @@ -808,13 +809,13 @@ impl ContextStore { fn handle_context_server_event( &mut self, context_server_manager: Model, - event: &context_servers::manager::Event, + event: &context_server::manager::Event, cx: &mut ModelContext, ) { let slash_command_working_set = self.slash_commands.clone(); let tool_working_set = self.tools.clone(); match event { - context_servers::manager::Event::ServerStarted { server_id } => { + context_server::manager::Event::ServerStarted { server_id } => { if let Some(server) = context_server_manager.read(cx).get_server(server_id) { let context_server_manager = context_server_manager.clone(); cx.spawn({ @@ -825,7 +826,7 @@ impl ContextStore { return; }; - if protocol.capable(context_servers::protocol::ServerCapability::Prompts) { + if protocol.capable(context_server::protocol::ServerCapability::Prompts) { if let Some(prompts) = protocol.list_prompts().await.log_err() { let slash_command_ids = prompts .into_iter() @@ -853,12 +854,12 @@ impl ContextStore { } } - if protocol.capable(context_servers::protocol::ServerCapability::Tools) { + if protocol.capable(context_server::protocol::ServerCapability::Tools) { if let Some(tools) = protocol.list_tools().await.log_err() { let tool_ids = tools.tools.into_iter().map(|tool| { log::info!("registering context server tool: {:?}", tool.name); tool_working_set.insert( - Arc::new(tools::context_server_tool::ContextServerTool::new( + Arc::new(ContextServerTool::new( context_server_manager.clone(), server.id(), tool, @@ -880,7 +881,7 @@ impl ContextStore { .detach(); } } - context_servers::manager::Event::ServerStopped { server_id } => { + context_server::manager::Event::ServerStopped { server_id } => { if let Some(slash_command_ids) = self.context_server_slash_command_ids.remove(server_id) { diff --git a/crates/assistant/src/slash_command/context_server_command.rs b/crates/assistant/src/slash_command/context_server_command.rs index 692b4f6ea7..b183a77f54 100644 --- a/crates/assistant/src/slash_command/context_server_command.rs +++ b/crates/assistant/src/slash_command/context_server_command.rs @@ -4,7 +4,7 @@ use assistant_slash_command::{ SlashCommandOutputSection, SlashCommandResult, }; use collections::HashMap; -use context_servers::{ +use context_server::{ manager::{ContextServer, ContextServerManager}, types::Prompt, }; @@ -95,9 +95,9 @@ impl SlashCommand for ContextServerSlashCommand { let completion_result = protocol .completion( - context_servers::types::CompletionReference::Prompt( - context_servers::types::PromptReference { - r#type: context_servers::types::PromptReferenceType::Prompt, + context_server::types::CompletionReference::Prompt( + context_server::types::PromptReference { + r#type: context_server::types::PromptReferenceType::Prompt, name: prompt_name, }, ), @@ -152,7 +152,7 @@ impl SlashCommand for ContextServerSlashCommand { if result .messages .iter() - .any(|msg| !matches!(msg.role, context_servers::types::Role::User)) + .any(|msg| !matches!(msg.role, context_server::types::Role::User)) { return Err(anyhow!( "Prompt contains non-user roles, which is not supported" @@ -164,7 +164,7 @@ impl SlashCommand for ContextServerSlashCommand { .messages .into_iter() .filter_map(|msg| match msg.content { - context_servers::types::MessageContent::Text { text } => Some(text), + context_server::types::MessageContent::Text { text } => Some(text), _ => None, }) .collect::>() diff --git a/crates/assistant/src/tools.rs b/crates/assistant/src/tools.rs deleted file mode 100644 index 83a396c020..0000000000 --- a/crates/assistant/src/tools.rs +++ /dev/null @@ -1,2 +0,0 @@ -pub mod context_server_tool; -pub mod now_tool; diff --git a/crates/assistant_tool/src/assistant_tool.rs b/crates/assistant_tool/src/assistant_tool.rs index 179bfe8dd1..c993494495 100644 --- a/crates/assistant_tool/src/assistant_tool.rs +++ b/crates/assistant_tool/src/assistant_tool.rs @@ -1,4 +1,5 @@ mod tool_registry; +mod tool_working_set; use std::sync::Arc; @@ -6,7 +7,8 @@ use anyhow::Result; use gpui::{AppContext, Task, WeakView, WindowContext}; use workspace::Workspace; -pub use tool_registry::*; +pub use crate::tool_registry::*; +pub use crate::tool_working_set::*; pub fn init(cx: &mut AppContext) { ToolRegistry::default_global(cx); diff --git a/crates/assistant/src/tool_working_set.rs b/crates/assistant_tool/src/tool_working_set.rs similarity index 98% rename from crates/assistant/src/tool_working_set.rs rename to crates/assistant_tool/src/tool_working_set.rs index aa2bb7a530..f22f0c7881 100644 --- a/crates/assistant/src/tool_working_set.rs +++ b/crates/assistant_tool/src/tool_working_set.rs @@ -1,8 +1,10 @@ -use assistant_tool::{Tool, ToolRegistry}; +use std::sync::Arc; + use collections::HashMap; use gpui::AppContext; use parking_lot::Mutex; -use std::sync::Arc; + +use crate::{Tool, ToolRegistry}; #[derive(Copy, Clone, PartialEq, Eq, Hash, Default)] pub struct ToolId(usize); diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml new file mode 100644 index 0000000000..4e92d67299 --- /dev/null +++ b/crates/assistant_tools/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "assistant_tools" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/assistant_tools.rs" + +[dependencies] +anyhow.workspace = true +assistant_tool.workspace = true +chrono.workspace = true +gpui.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +workspace.workspace = true diff --git a/crates/context_servers/LICENSE-GPL b/crates/assistant_tools/LICENSE-GPL similarity index 100% rename from crates/context_servers/LICENSE-GPL rename to crates/assistant_tools/LICENSE-GPL diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs new file mode 100644 index 0000000000..7d145c61b7 --- /dev/null +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -0,0 +1,13 @@ +mod now_tool; + +use assistant_tool::ToolRegistry; +use gpui::AppContext; + +use crate::now_tool::NowTool; + +pub fn init(cx: &mut AppContext) { + assistant_tool::init(cx); + + let registry = ToolRegistry::global(cx); + registry.register_tool(NowTool); +} diff --git a/crates/assistant/src/tools/now_tool.rs b/crates/assistant_tools/src/now_tool.rs similarity index 100% rename from crates/assistant/src/tools/now_tool.rs rename to crates/assistant_tools/src/now_tool.rs diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index d3da1c2816..e56507c007 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -79,7 +79,8 @@ uuid.workspace = true [dev-dependencies] assistant = { workspace = true, features = ["test-support"] } -context_servers.workspace = true +assistant_tool.workspace = true +context_server.workspace = true async-trait.workspace = true audio.workspace = true call = { workspace = true, features = ["test-support"] } diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 5ec9a574a1..b6a0247424 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -6,7 +6,8 @@ use crate::{ }, }; use anyhow::{anyhow, Result}; -use assistant::{ContextStore, PromptBuilder, SlashCommandWorkingSet, ToolWorkingSet}; +use assistant::{ContextStore, PromptBuilder, SlashCommandWorkingSet}; +use assistant_tool::ToolWorkingSet; use call::{room, ActiveCall, ParticipantLocation, Room}; use client::{User, RECEIVE_TIMEOUT}; use collections::{HashMap, HashSet}; @@ -6486,8 +6487,8 @@ async fn test_context_collaboration_with_reconnect( assert_eq!(project.collaborators().len(), 1); }); - cx_a.update(context_servers::init); - cx_b.update(context_servers::init); + cx_a.update(context_server::init); + cx_b.update(context_server::init); let prompt_builder = Arc::new(PromptBuilder::new(None).unwrap()); let context_store_a = cx_a .update(|cx| { diff --git a/crates/context_servers/Cargo.toml b/crates/context_server/Cargo.toml similarity index 76% rename from crates/context_servers/Cargo.toml rename to crates/context_server/Cargo.toml index cbd762c8c4..410b897f28 100644 --- a/crates/context_servers/Cargo.toml +++ b/crates/context_server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "context_servers" +name = "context_server" version = "0.1.0" edition = "2021" publish = false @@ -9,12 +9,14 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/context_servers.rs" +path = "src/context_server.rs" [dependencies] anyhow.workspace = true +assistant_tool.workspace = true collections.workspace = true command_palette_hooks.workspace = true +context_server_settings.workspace = true extension.workspace = true futures.workspace = true gpui.workspace = true @@ -22,10 +24,11 @@ log.workspace = true parking_lot.workspace = true postage.workspace = true project.workspace = true -schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +ui.workspace = true url = { workspace = true, features = ["serde"] } util.workspace = true +workspace.workspace = true diff --git a/crates/context_server/LICENSE-GPL b/crates/context_server/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/context_server/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/context_servers/src/client.rs b/crates/context_server/src/client.rs similarity index 100% rename from crates/context_servers/src/client.rs rename to crates/context_server/src/client.rs diff --git a/crates/context_servers/src/context_servers.rs b/crates/context_server/src/context_server.rs similarity index 76% rename from crates/context_servers/src/context_servers.rs rename to crates/context_server/src/context_server.rs index e6b52aaee2..84c08d7b2a 100644 --- a/crates/context_servers/src/context_servers.rs +++ b/crates/context_server/src/context_server.rs @@ -1,4 +1,5 @@ pub mod client; +mod context_server_tool; mod extension_context_server; pub mod manager; pub mod protocol; @@ -6,10 +7,10 @@ mod registry; pub mod types; use command_palette_hooks::CommandPaletteFilter; +pub use context_server_settings::{ContextServerSettings, ServerCommand, ServerConfig}; use gpui::{actions, AppContext}; -use settings::Settings; -use crate::manager::ContextServerSettings; +pub use crate::context_server_tool::ContextServerTool; pub use crate::registry::ContextServerFactoryRegistry; actions!(context_servers, [Restart]); @@ -18,7 +19,7 @@ actions!(context_servers, [Restart]); pub const CONTEXT_SERVERS_NAMESPACE: &'static str = "context_servers"; pub fn init(cx: &mut AppContext) { - ContextServerSettings::register(cx); + context_server_settings::init(cx); ContextServerFactoryRegistry::default_global(cx); extension_context_server::init(cx); diff --git a/crates/assistant/src/tools/context_server_tool.rs b/crates/context_server/src/context_server_tool.rs similarity index 97% rename from crates/assistant/src/tools/context_server_tool.rs rename to crates/context_server/src/context_server_tool.rs index 8015d94df9..70740f710a 100644 --- a/crates/assistant/src/tools/context_server_tool.rs +++ b/crates/context_server/src/context_server_tool.rs @@ -2,10 +2,11 @@ use std::sync::Arc; use anyhow::{anyhow, bail}; use assistant_tool::Tool; -use context_servers::manager::ContextServerManager; -use context_servers::types; use gpui::{Model, Task}; +use crate::manager::ContextServerManager; +use crate::types; + pub struct ContextServerTool { server_manager: Model, server_id: Arc, diff --git a/crates/context_servers/src/extension_context_server.rs b/crates/context_server/src/extension_context_server.rs similarity index 97% rename from crates/context_servers/src/extension_context_server.rs rename to crates/context_server/src/extension_context_server.rs index 092816b5e6..36fecd2af3 100644 --- a/crates/context_servers/src/extension_context_server.rs +++ b/crates/context_server/src/extension_context_server.rs @@ -3,8 +3,7 @@ use std::sync::Arc; use extension::{Extension, ExtensionContextServerProxy, ExtensionHostProxy, ProjectDelegate}; use gpui::{AppContext, Model}; -use crate::manager::ServerCommand; -use crate::ContextServerFactoryRegistry; +use crate::{ContextServerFactoryRegistry, ServerCommand}; struct ExtensionProject { worktree_ids: Vec, diff --git a/crates/context_servers/src/manager.rs b/crates/context_server/src/manager.rs similarity index 84% rename from crates/context_servers/src/manager.rs rename to crates/context_server/src/manager.rs index c95fcd239d..febbee1cdf 100644 --- a/crates/context_servers/src/manager.rs +++ b/crates/context_server/src/manager.rs @@ -24,66 +24,16 @@ use gpui::{AsyncAppContext, EventEmitter, Model, ModelContext, Subscription, Tas use log; use parking_lot::RwLock; use project::Project; -use schemars::gen::SchemaGenerator; -use schemars::schema::{InstanceType, Schema, SchemaObject}; -use schemars::JsonSchema; -use serde::{Deserialize, Serialize}; -use settings::{Settings, SettingsSources, SettingsStore}; +use settings::{Settings, SettingsStore}; use util::ResultExt as _; +use crate::{ContextServerSettings, ServerConfig}; + use crate::{ client::{self, Client}, types, ContextServerFactoryRegistry, CONTEXT_SERVERS_NAMESPACE, }; -#[derive(Deserialize, Serialize, Default, Clone, PartialEq, Eq, JsonSchema, Debug)] -pub struct ContextServerSettings { - /// Settings for context servers used in the Assistant. - #[serde(default)] - pub context_servers: HashMap, ServerConfig>, -} - -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug, Default)] -pub struct ServerConfig { - /// The command to run this context server. - /// - /// This will override the command set by an extension. - pub command: Option, - /// The settings for this context server. - /// - /// Consult the documentation for the context server to see what settings - /// are supported. - #[schemars(schema_with = "server_config_settings_json_schema")] - pub settings: Option, -} - -fn server_config_settings_json_schema(_generator: &mut SchemaGenerator) -> Schema { - Schema::Object(SchemaObject { - instance_type: Some(InstanceType::Object.into()), - ..Default::default() - }) -} - -#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)] -pub struct ServerCommand { - pub path: String, - pub args: Vec, - pub env: Option>, -} - -impl Settings for ContextServerSettings { - const KEY: Option<&'static str> = None; - - type FileContent = Self; - - fn load( - sources: SettingsSources, - _: &mut gpui::AppContext, - ) -> anyhow::Result { - sources.json_merge() - } -} - pub struct ContextServer { pub id: Arc, pub config: Arc, diff --git a/crates/context_servers/src/protocol.rs b/crates/context_server/src/protocol.rs similarity index 100% rename from crates/context_servers/src/protocol.rs rename to crates/context_server/src/protocol.rs diff --git a/crates/context_servers/src/registry.rs b/crates/context_server/src/registry.rs similarity index 98% rename from crates/context_servers/src/registry.rs rename to crates/context_server/src/registry.rs index c17c65370a..a4d0f9a804 100644 --- a/crates/context_servers/src/registry.rs +++ b/crates/context_server/src/registry.rs @@ -5,7 +5,7 @@ use collections::HashMap; use gpui::{AppContext, AsyncAppContext, Context, Global, Model, ReadGlobal, Task}; use project::Project; -use crate::manager::ServerCommand; +use crate::ServerCommand; pub type ContextServerFactory = Arc< dyn Fn(Model, &AsyncAppContext) -> Task> + Send + Sync + 'static, diff --git a/crates/context_servers/src/types.rs b/crates/context_server/src/types.rs similarity index 100% rename from crates/context_servers/src/types.rs rename to crates/context_server/src/types.rs diff --git a/crates/context_server_settings/Cargo.toml b/crates/context_server_settings/Cargo.toml new file mode 100644 index 0000000000..ad0d1d9dc0 --- /dev/null +++ b/crates/context_server_settings/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "context_server_settings" +version = "0.1.0" +edition = "2021" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/context_server_settings.rs" + +[dependencies] +anyhow.workspace = true +collections.workspace = true +gpui.workspace = true +schemars.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true diff --git a/crates/context_server_settings/LICENSE-GPL b/crates/context_server_settings/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/context_server_settings/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/context_server_settings/src/context_server_settings.rs b/crates/context_server_settings/src/context_server_settings.rs new file mode 100644 index 0000000000..68969ca795 --- /dev/null +++ b/crates/context_server_settings/src/context_server_settings.rs @@ -0,0 +1,61 @@ +use std::sync::Arc; + +use collections::HashMap; +use gpui::AppContext; +use schemars::gen::SchemaGenerator; +use schemars::schema::{InstanceType, Schema, SchemaObject}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsSources}; + +pub fn init(cx: &mut AppContext) { + ContextServerSettings::register(cx); +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug, Default)] +pub struct ServerConfig { + /// The command to run this context server. + /// + /// This will override the command set by an extension. + pub command: Option, + /// The settings for this context server. + /// + /// Consult the documentation for the context server to see what settings + /// are supported. + #[schemars(schema_with = "server_config_settings_json_schema")] + pub settings: Option, +} + +fn server_config_settings_json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::Object.into()), + ..Default::default() + }) +} + +#[derive(Deserialize, Serialize, Clone, PartialEq, Eq, JsonSchema, Debug)] +pub struct ServerCommand { + pub path: String, + pub args: Vec, + pub env: Option>, +} + +#[derive(Deserialize, Serialize, Default, Clone, PartialEq, Eq, JsonSchema, Debug)] +pub struct ContextServerSettings { + /// Settings for context servers used in the Assistant. + #[serde(default)] + pub context_servers: HashMap, ServerConfig>, +} + +impl Settings for ContextServerSettings { + const KEY: Option<&'static str> = None; + + type FileContent = Self; + + fn load( + sources: SettingsSources, + _: &mut gpui::AppContext, + ) -> anyhow::Result { + sources.json_merge() + } +} diff --git a/crates/extension_host/Cargo.toml b/crates/extension_host/Cargo.toml index 6e78654b7e..53971ade0a 100644 --- a/crates/extension_host/Cargo.toml +++ b/crates/extension_host/Cargo.toml @@ -22,7 +22,7 @@ async-tar.workspace = true async-trait.workspace = true client.workspace = true collections.workspace = true -context_servers.workspace = true +context_server_settings.workspace = true extension.workspace = true fs.workspace = true futures.workspace = true diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs index f7e11e1032..b722d7b235 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_2_0.rs @@ -7,7 +7,7 @@ use anyhow::{anyhow, bail, Context, Result}; use async_compression::futures::bufread::GzipDecoder; use async_tar::Archive; use async_trait::async_trait; -use context_servers::manager::ContextServerSettings; +use context_server_settings::ContextServerSettings; use extension::{ ExtensionLanguageServerProxy, KeyValueStoreDelegate, ProjectDelegate, WorktreeDelegate, }; diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 1959fb0e00..5003ca1b81 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true assets.workspace = true assistant.workspace = true assistant2.workspace = true +assistant_tools.workspace = true async-watch.workspace = true audio.workspace = true auto_update.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index cccd50da96..cfc11ade3f 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -407,6 +407,7 @@ fn main() { cx, ); assistant2::init(cx); + assistant_tools::init(cx); repl::init( app_state.fs.clone(), app_state.client.telemetry().clone(), From f059b6a24bac5a7bd65ea54a8f48b919d928d75e Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 19:44:34 -0500 Subject: [PATCH 020/215] assistant2: Add support for using tools (#21190) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds rudimentary support for using tools to `assistant2`. There are currently no visual affordances for tool use. This is gated behind the `assistant-tool-use` feature flag. Screenshot 2024-11-25 at 7 21 31 PM Release Notes: - N/A --- Cargo.lock | 3 + crates/assistant/src/context.rs | 12 +- crates/assistant2/Cargo.toml | 3 + crates/assistant2/src/assistant_panel.rs | 61 ++++++- crates/assistant2/src/message_editor.rs | 19 ++- crates/assistant2/src/thread.rs | 190 ++++++++++++++++++++-- crates/assistant_tools/src/now_tool.rs | 2 +- crates/feature_flags/src/feature_flags.rs | 10 ++ 8 files changed, 263 insertions(+), 37 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7152bf8d08..5a18caa3d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -455,6 +455,8 @@ name = "assistant2" version = "0.1.0" dependencies = [ "anyhow", + "assistant_tool", + "collections", "command_palette_hooks", "editor", "feature_flags", @@ -463,6 +465,7 @@ dependencies = [ "language_model", "language_model_selector", "proto", + "serde_json", "settings", "smol", "theme", diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index 2a7985a8c7..ac032accc3 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -15,7 +15,7 @@ use assistant_tool::ToolWorkingSet; use client::{self, proto, telemetry::Telemetry}; use clock::ReplicaId; use collections::{HashMap, HashSet}; -use feature_flags::{FeatureFlag, FeatureFlagAppExt}; +use feature_flags::{FeatureFlagAppExt, ToolUseFeatureFlag}; use fs::{Fs, RemoveOptions}; use futures::{future::Shared, FutureExt, StreamExt}; use gpui::{ @@ -3201,16 +3201,6 @@ pub enum PendingSlashCommandStatus { Error(String), } -pub(crate) struct ToolUseFeatureFlag; - -impl FeatureFlag for ToolUseFeatureFlag { - const NAME: &'static str = "assistant-tool-use"; - - fn enabled_for_staff() -> bool { - false - } -} - #[derive(Debug, Clone)] pub struct PendingToolUse { pub id: Arc, diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 02cbdadb62..60c168079d 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -14,6 +14,8 @@ doctest = false [dependencies] anyhow.workspace = true +assistant_tool.workspace = true +collections.workspace = true command_palette_hooks.workspace = true editor.workspace = true feature_flags.workspace = true @@ -23,6 +25,7 @@ language_model.workspace = true language_model_selector.workspace = true proto.workspace = true settings.workspace = true +serde_json.workspace = true smol.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index c33e9d520d..b05a39a1cd 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -1,4 +1,7 @@ +use std::sync::Arc; + use anyhow::Result; +use assistant_tool::ToolWorkingSet; use gpui::{ prelude::*, px, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, FocusableView, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, WindowContext, @@ -10,7 +13,7 @@ use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; -use crate::thread::Thread; +use crate::thread::{Thread, ThreadEvent}; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { @@ -25,8 +28,10 @@ pub fn init(cx: &mut AppContext) { } pub struct AssistantPanel { + workspace: WeakView, thread: Model, message_editor: View, + tools: Arc, _subscriptions: Vec, } @@ -36,26 +41,36 @@ impl AssistantPanel { cx: AsyncWindowContext, ) -> Task>> { cx.spawn(|mut cx| async move { + let tools = Arc::new(ToolWorkingSet::default()); workspace.update(&mut cx, |workspace, cx| { - cx.new_view(|cx| Self::new(workspace, cx)) + cx.new_view(|cx| Self::new(workspace, tools, cx)) }) }) } - fn new(_workspace: &Workspace, cx: &mut ViewContext) -> Self { - let thread = cx.new_model(Thread::new); - let subscriptions = vec![cx.observe(&thread, |_, _, cx| cx.notify())]; + fn new(workspace: &Workspace, tools: Arc, cx: &mut ViewContext) -> Self { + let thread = cx.new_model(|cx| Thread::new(tools.clone(), cx)); + let subscriptions = vec![ + cx.observe(&thread, |_, _, cx| cx.notify()), + cx.subscribe(&thread, Self::handle_thread_event), + ]; Self { + workspace: workspace.weak_handle(), thread: thread.clone(), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), + tools, _subscriptions: subscriptions, } } fn new_thread(&mut self, cx: &mut ViewContext) { - let thread = cx.new_model(Thread::new); - let subscriptions = vec![cx.observe(&thread, |_, _, cx| cx.notify())]; + let tools = self.thread.read(cx).tools().clone(); + let thread = cx.new_model(|cx| Thread::new(tools, cx)); + let subscriptions = vec![ + cx.observe(&thread, |_, _, cx| cx.notify()), + cx.subscribe(&thread, Self::handle_thread_event), + ]; self.message_editor = cx.new_view(|cx| MessageEditor::new(thread.clone(), cx)); self.thread = thread; @@ -63,6 +78,38 @@ impl AssistantPanel { self.message_editor.focus_handle(cx).focus(cx); } + + fn handle_thread_event( + &mut self, + _: Model, + event: &ThreadEvent, + cx: &mut ViewContext, + ) { + match event { + ThreadEvent::StreamedCompletion => {} + ThreadEvent::UsePendingTools => { + let pending_tool_uses = self + .thread + .read(cx) + .pending_tool_uses() + .into_iter() + .filter(|tool_use| tool_use.status.is_idle()) + .cloned() + .collect::>(); + + for tool_use in pending_tool_uses { + if let Some(tool) = self.tools.tool(&tool_use.name, cx) { + let task = tool.run(tool_use.input, self.workspace.clone(), cx); + + self.thread.update(cx, |thread, cx| { + thread.insert_tool_output(tool_use.id.clone(), task, cx); + }); + } + } + } + ThreadEvent::ToolFinished { .. } => {} + } + } } impl FocusableView for AssistantPanel { diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index f0a8e260bc..c42d66a4d7 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,6 +1,7 @@ use editor::{Editor, EditorElement, EditorStyle}; +use feature_flags::{FeatureFlagAppExt, ToolUseFeatureFlag}; use gpui::{AppContext, FocusableView, Model, TextStyle, View}; -use language_model::LanguageModelRegistry; +use language_model::{LanguageModelRegistry, LanguageModelRequestTool}; use settings::Settings; use theme::ThemeSettings; use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; @@ -55,7 +56,21 @@ impl MessageEditor { self.thread.update(cx, |thread, cx| { thread.insert_user_message(user_message); - let request = thread.to_completion_request(request_kind, cx); + let mut request = thread.to_completion_request(request_kind, cx); + + if cx.has_flag::() { + request.tools = thread + .tools() + .tools(cx) + .into_iter() + .map(|tool| LanguageModelRequestTool { + name: tool.name(), + description: tool.description(), + input_schema: tool.input_schema(), + }) + .collect(); + } + thread.stream_completion(request, model, cx) }); diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index c1df6c76d3..067e82a602 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -1,12 +1,16 @@ use std::sync::Arc; -use futures::StreamExt as _; +use anyhow::Result; +use assistant_tool::ToolWorkingSet; +use collections::HashMap; +use futures::future::Shared; +use futures::{FutureExt as _, StreamExt as _}; use gpui::{AppContext, EventEmitter, ModelContext, Task}; use language_model::{ LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, - MessageContent, Role, StopReason, + LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, StopReason, }; -use util::{post_inc, ResultExt as _}; +use util::post_inc; #[derive(Debug, Clone, Copy)] pub enum RequestKind { @@ -14,14 +18,12 @@ pub enum RequestKind { } /// A message in a [`Thread`]. +#[derive(Debug)] pub struct Message { pub role: Role, pub text: String, -} - -struct PendingCompletion { - id: usize, - _task: Task<()>, + pub tool_uses: Vec, + pub tool_results: Vec, } /// A thread of conversation with the LLM. @@ -29,14 +31,20 @@ pub struct Thread { messages: Vec, completion_count: usize, pending_completions: Vec, + tools: Arc, + pending_tool_uses_by_id: HashMap, PendingToolUse>, + completed_tool_uses_by_id: HashMap, String>, } impl Thread { - pub fn new(_cx: &mut ModelContext) -> Self { + pub fn new(tools: Arc, _cx: &mut ModelContext) -> Self { Self { + tools, messages: Vec::new(), completion_count: 0, pending_completions: Vec::new(), + pending_tool_uses_by_id: HashMap::default(), + completed_tool_uses_by_id: HashMap::default(), } } @@ -44,11 +52,31 @@ impl Thread { self.messages.iter() } + pub fn tools(&self) -> &Arc { + &self.tools + } + + pub fn pending_tool_uses(&self) -> Vec<&PendingToolUse> { + self.pending_tool_uses_by_id.values().collect() + } + pub fn insert_user_message(&mut self, text: impl Into) { - self.messages.push(Message { + let mut message = Message { role: Role::User, text: text.into(), - }); + tool_uses: Vec::new(), + tool_results: Vec::new(), + }; + + for (tool_use_id, tool_output) in self.completed_tool_uses_by_id.drain() { + message.tool_results.push(LanguageModelToolResult { + tool_use_id: tool_use_id.to_string(), + content: tool_output, + is_error: false, + }); + } + + self.messages.push(message); } pub fn to_completion_request( @@ -70,9 +98,23 @@ impl Thread { cache: false, }; - request_message - .content - .push(MessageContent::Text(message.text.clone())); + for tool_result in &message.tool_results { + request_message + .content + .push(MessageContent::ToolResult(tool_result.clone())); + } + + if !message.text.is_empty() { + request_message + .content + .push(MessageContent::Text(message.text.clone())); + } + + for tool_use in &message.tool_uses { + request_message + .content + .push(MessageContent::ToolUse(tool_use.clone())); + } request.messages.push(request_message); } @@ -103,6 +145,8 @@ impl Thread { thread.messages.push(Message { role: Role::Assistant, text: String::new(), + tool_uses: Vec::new(), + tool_results: Vec::new(), }); } LanguageModelCompletionEvent::Stop(reason) => { @@ -115,7 +159,24 @@ impl Thread { } } } - LanguageModelCompletionEvent::ToolUse(_tool_use) => {} + LanguageModelCompletionEvent::ToolUse(tool_use) => { + if let Some(last_message) = thread.messages.last_mut() { + if last_message.role == Role::Assistant { + last_message.tool_uses.push(tool_use.clone()); + } + } + + let tool_use_id: Arc = tool_use.id.into(); + thread.pending_tool_uses_by_id.insert( + tool_use_id.clone(), + PendingToolUse { + id: tool_use_id, + name: tool_use.name, + input: tool_use.input, + status: PendingToolUseStatus::Idle, + }, + ); + } } cx.emit(ThreadEvent::StreamedCompletion); @@ -135,7 +196,35 @@ impl Thread { }; let result = stream_completion.await; - let _ = result.log_err(); + + thread + .update(&mut cx, |_thread, cx| { + let error_message = if let Some(error) = result.as_ref().err() { + let error_message = error + .chain() + .map(|err| err.to_string()) + .collect::>() + .join("\n"); + Some(error_message) + } else { + None + }; + + if let Some(error_message) = error_message { + eprintln!("Completion failed: {error_message:?}"); + } + + if let Ok(stop_reason) = result { + match stop_reason { + StopReason::ToolUse => { + cx.emit(ThreadEvent::UsePendingTools); + } + StopReason::EndTurn => {} + StopReason::MaxTokens => {} + } + } + }) + .ok(); }); self.pending_completions.push(PendingCompletion { @@ -143,11 +232,80 @@ impl Thread { _task: task, }); } + + pub fn insert_tool_output( + &mut self, + tool_use_id: Arc, + output: Task>, + cx: &mut ModelContext, + ) { + let insert_output_task = cx.spawn(|thread, mut cx| { + let tool_use_id = tool_use_id.clone(); + async move { + let output = output.await; + thread + .update(&mut cx, |thread, cx| match output { + Ok(output) => { + thread + .completed_tool_uses_by_id + .insert(tool_use_id.clone(), output); + + cx.emit(ThreadEvent::ToolFinished { tool_use_id }); + } + Err(err) => { + if let Some(tool_use) = + thread.pending_tool_uses_by_id.get_mut(&tool_use_id) + { + tool_use.status = PendingToolUseStatus::Error(err.to_string()); + } + } + }) + .ok(); + } + }); + + if let Some(tool_use) = self.pending_tool_uses_by_id.get_mut(&tool_use_id) { + tool_use.status = PendingToolUseStatus::Running { + _task: insert_output_task.shared(), + }; + } + } } #[derive(Debug, Clone)] pub enum ThreadEvent { StreamedCompletion, + UsePendingTools, + ToolFinished { + #[allow(unused)] + tool_use_id: Arc, + }, } impl EventEmitter for Thread {} + +struct PendingCompletion { + id: usize, + _task: Task<()>, +} + +#[derive(Debug, Clone)] +pub struct PendingToolUse { + pub id: Arc, + pub name: String, + pub input: serde_json::Value, + pub status: PendingToolUseStatus, +} + +#[derive(Debug, Clone)] +pub enum PendingToolUseStatus { + Idle, + Running { _task: Shared> }, + Error(#[allow(unused)] String), +} + +impl PendingToolUseStatus { + pub fn is_idle(&self) -> bool { + matches!(self, PendingToolUseStatus::Idle) + } +} diff --git a/crates/assistant_tools/src/now_tool.rs b/crates/assistant_tools/src/now_tool.rs index 99034321b1..707f2be2bd 100644 --- a/crates/assistant_tools/src/now_tool.rs +++ b/crates/assistant_tools/src/now_tool.rs @@ -30,7 +30,7 @@ impl Tool for NowTool { } fn description(&self) -> String { - "Returns the current datetime in RFC 3339 format.".into() + "Returns the current datetime in RFC 3339 format. Only use this tool when the user specifically asks for it or the current task would benefit from knowing the current datetime.".into() } fn input_schema(&self) -> serde_json::Value { diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 416971b36e..48e3cc95b2 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -49,6 +49,16 @@ impl FeatureFlag for Assistant2FeatureFlag { } } +pub struct ToolUseFeatureFlag; + +impl FeatureFlag for ToolUseFeatureFlag { + const NAME: &'static str = "assistant-tool-use"; + + fn enabled_for_staff() -> bool { + false + } +} + pub struct Remoting {} impl FeatureFlag for Remoting { const NAME: &'static str = "remoting"; From 7e418cc8afda7e54fa1098eb36664dbfe25863de Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 20:49:03 -0500 Subject: [PATCH 021/215] assistant2: Style messages (#21191) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR styles the messages in `assistant2` so they don't look quite as rough: Screenshot 2024-11-25 at 8 36 32 PM Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 47 ++++++++++++++++++------ crates/assistant2/src/thread.rs | 2 +- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index b05a39a1cd..4ebf07e9d4 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -6,14 +6,14 @@ use gpui::{ prelude::*, px, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, FocusableView, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, WindowContext, }; -use language_model::LanguageModelRegistry; +use language_model::{LanguageModelRegistry, Role}; use language_model_selector::LanguageModelSelector; use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; -use crate::thread::{Thread, ThreadEvent}; +use crate::thread::{Message, Thread, ThreadEvent}; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { @@ -272,10 +272,39 @@ impl AssistantPanel { .tooltip(move |cx| Tooltip::for_action("Change Model", &ToggleModelSelector, cx)), ) } + + fn render_message(&self, message: Message, cx: &mut ViewContext) -> impl IntoElement { + let (role_icon, role_name) = match message.role { + Role::User => (IconName::Person, "You"), + Role::Assistant => (IconName::ZedAssistant, "Assistant"), + Role::System => (IconName::Settings, "System"), + }; + + v_flex() + .border_1() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .child( + h_flex() + .justify_between() + .p_1p5() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .child(Icon::new(role_icon).size(IconSize::Small)) + .child(Label::new(role_name).size(LabelSize::Small)), + ), + ) + .child(v_flex().p_1p5().child(Label::new(message.text.clone()))) + } } impl Render for AssistantPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let messages = self.thread.read(cx).messages().cloned().collect::>(); + v_flex() .key_context("AssistantPanel2") .justify_between() @@ -292,15 +321,11 @@ impl Render for AssistantPanel { .p_2() .overflow_y_scroll() .bg(cx.theme().colors().panel_background) - .children(self.thread.read(cx).messages().map(|message| { - v_flex() - .p_2() - .border_1() - .border_color(cx.theme().colors().border_variant) - .rounded_md() - .child(Label::new(message.role.to_string())) - .child(Label::new(message.text.clone())) - })), + .children( + messages + .into_iter() + .map(|message| self.render_message(message, cx)), + ), ) .child( h_flex() diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 067e82a602..d8263d15f7 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -18,7 +18,7 @@ pub enum RequestKind { } /// A message in a [`Thread`]. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct Message { pub role: Role, pub text: String, From 968ffaa3fd801b3a436551705db636b0c89609b6 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Nov 2024 21:53:27 -0500 Subject: [PATCH 022/215] assistant2: Restructure storage of tool uses and results (#21194) This PR restructures the storage of the tool uses and results in `assistant2` so that they don't live on the individual messages. It also introduces a `LanguageModelToolUseId` newtype for better type safety. Release Notes: - N/A --- Cargo.lock | 1 + crates/assistant/src/assistant_panel.rs | 2 +- crates/assistant/src/context.rs | 21 ++- crates/assistant2/Cargo.toml | 1 + crates/assistant2/src/assistant_panel.rs | 7 +- crates/assistant2/src/thread.rs | 157 +++++++++++------- crates/language_model/src/language_model.rs | 20 ++- crates/language_model/src/request.rs | 2 +- .../language_models/src/provider/anthropic.rs | 2 +- 9 files changed, 136 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5a18caa3d1..166adb6588 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -465,6 +465,7 @@ dependencies = [ "language_model", "language_model_selector", "proto", + "serde", "serde_json", "settings", "smol", diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index e1ce7c4ab2..7467d5dfd4 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -1925,7 +1925,7 @@ impl ContextEditor { Content::ToolUse { range: tool_use.source_range.clone(), tool_use: LanguageModelToolUse { - id: tool_use.id.to_string(), + id: tool_use.id.clone(), name: tool_use.name.clone(), input: tool_use.input.clone(), }, diff --git a/crates/assistant/src/context.rs b/crates/assistant/src/context.rs index ac032accc3..032a66b4c7 100644 --- a/crates/assistant/src/context.rs +++ b/crates/assistant/src/context.rs @@ -27,8 +27,8 @@ use language::{AnchorRangeExt, Bias, Buffer, LanguageRegistry, OffsetRangeExt, P use language_model::{ LanguageModel, LanguageModelCacheConfiguration, LanguageModelCompletionEvent, LanguageModelImage, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, - StopReason, + LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, Role, StopReason, }; use language_models::{ provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}, @@ -385,7 +385,7 @@ pub enum ContextEvent { }, UsePendingTools, ToolFinished { - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, output_range: Range, }, Operation(ContextOperation), @@ -479,7 +479,7 @@ pub enum Content { }, ToolResult { range: Range, - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, }, } @@ -546,7 +546,7 @@ pub struct Context { pub(crate) slash_commands: Arc, pub(crate) tools: Arc, slash_command_output_sections: Vec>, - pending_tool_uses_by_id: HashMap, PendingToolUse>, + pending_tool_uses_by_id: HashMap, message_anchors: Vec, contents: Vec, messages_metadata: HashMap, @@ -1126,7 +1126,7 @@ impl Context { self.pending_tool_uses_by_id.values().collect() } - pub fn get_tool_use_by_id(&self, id: &Arc) -> Option<&PendingToolUse> { + pub fn get_tool_use_by_id(&self, id: &LanguageModelToolUseId) -> Option<&PendingToolUse> { self.pending_tool_uses_by_id.get(id) } @@ -2153,7 +2153,7 @@ impl Context { pub fn insert_tool_output( &mut self, - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, output: Task>, cx: &mut ModelContext, ) { @@ -2340,11 +2340,10 @@ impl Context { let source_range = buffer.anchor_after(start_ix) ..buffer.anchor_after(end_ix); - let tool_use_id: Arc = tool_use.id.into(); this.pending_tool_uses_by_id.insert( - tool_use_id.clone(), + tool_use.id.clone(), PendingToolUse { - id: tool_use_id, + id: tool_use.id, name: tool_use.name, input: tool_use.input, status: PendingToolUseStatus::Idle, @@ -3203,7 +3202,7 @@ pub enum PendingSlashCommandStatus { #[derive(Debug, Clone)] pub struct PendingToolUse { - pub id: Arc, + pub id: LanguageModelToolUseId, pub name: String, pub input: serde_json::Value, pub status: PendingToolUseStatus, diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 60c168079d..ca563b05c8 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -25,6 +25,7 @@ language_model.workspace = true language_model_selector.workspace = true proto.workspace = true settings.workspace = true +serde.workspace = true serde_json.workspace = true smol.workspace = true theme.workspace = true diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 4ebf07e9d4..bf457d6c71 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -102,7 +102,12 @@ impl AssistantPanel { let task = tool.run(tool_use.input, self.workspace.clone(), cx); self.thread.update(cx, |thread, cx| { - thread.insert_tool_output(tool_use.id.clone(), task, cx); + thread.insert_tool_output( + tool_use.assistant_message_id, + tool_use.id.clone(), + task, + cx, + ); }); } } diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index d8263d15f7..0d2aab6905 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -8,8 +8,10 @@ use futures::{FutureExt as _, StreamExt as _}; use gpui::{AppContext, EventEmitter, ModelContext, Task}; use language_model::{ LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelToolResult, LanguageModelToolUse, MessageContent, Role, StopReason, + LanguageModelToolResult, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, + StopReason, }; +use serde::{Deserialize, Serialize}; use util::post_inc; #[derive(Debug, Clone, Copy)] @@ -17,34 +19,46 @@ pub enum RequestKind { Chat, } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] +pub struct MessageId(usize); + +impl MessageId { + fn post_inc(&mut self) -> Self { + Self(post_inc(&mut self.0)) + } +} + /// A message in a [`Thread`]. #[derive(Debug, Clone)] pub struct Message { + pub id: MessageId, pub role: Role, pub text: String, - pub tool_uses: Vec, - pub tool_results: Vec, } /// A thread of conversation with the LLM. pub struct Thread { messages: Vec, + next_message_id: MessageId, completion_count: usize, pending_completions: Vec, tools: Arc, - pending_tool_uses_by_id: HashMap, PendingToolUse>, - completed_tool_uses_by_id: HashMap, String>, + tool_uses_by_message: HashMap>, + tool_results_by_message: HashMap>, + pending_tool_uses_by_id: HashMap, } impl Thread { pub fn new(tools: Arc, _cx: &mut ModelContext) -> Self { Self { - tools, messages: Vec::new(), + next_message_id: MessageId(0), completion_count: 0, pending_completions: Vec::new(), + tools, + tool_uses_by_message: HashMap::default(), + tool_results_by_message: HashMap::default(), pending_tool_uses_by_id: HashMap::default(), - completed_tool_uses_by_id: HashMap::default(), } } @@ -61,22 +75,11 @@ impl Thread { } pub fn insert_user_message(&mut self, text: impl Into) { - let mut message = Message { + self.messages.push(Message { + id: self.next_message_id.post_inc(), role: Role::User, text: text.into(), - tool_uses: Vec::new(), - tool_results: Vec::new(), - }; - - for (tool_use_id, tool_output) in self.completed_tool_uses_by_id.drain() { - message.tool_results.push(LanguageModelToolResult { - tool_use_id: tool_use_id.to_string(), - content: tool_output, - is_error: false, - }); - } - - self.messages.push(message); + }); } pub fn to_completion_request( @@ -98,10 +101,12 @@ impl Thread { cache: false, }; - for tool_result in &message.tool_results { - request_message - .content - .push(MessageContent::ToolResult(tool_result.clone())); + if let Some(tool_results) = self.tool_results_by_message.get(&message.id) { + for tool_result in tool_results { + request_message + .content + .push(MessageContent::ToolResult(tool_result.clone())); + } } if !message.text.is_empty() { @@ -110,10 +115,12 @@ impl Thread { .push(MessageContent::Text(message.text.clone())); } - for tool_use in &message.tool_uses { - request_message - .content - .push(MessageContent::ToolUse(tool_use.clone())); + if let Some(tool_uses) = self.tool_uses_by_message.get(&message.id) { + for tool_use in tool_uses { + request_message + .content + .push(MessageContent::ToolUse(tool_use.clone())); + } } request.messages.push(request_message); @@ -143,10 +150,9 @@ impl Thread { match event { LanguageModelCompletionEvent::StartMessage { .. } => { thread.messages.push(Message { + id: thread.next_message_id.post_inc(), role: Role::Assistant, text: String::new(), - tool_uses: Vec::new(), - tool_results: Vec::new(), }); } LanguageModelCompletionEvent::Stop(reason) => { @@ -160,22 +166,28 @@ impl Thread { } } LanguageModelCompletionEvent::ToolUse(tool_use) => { - if let Some(last_message) = thread.messages.last_mut() { - if last_message.role == Role::Assistant { - last_message.tool_uses.push(tool_use.clone()); - } - } + if let Some(last_assistant_message) = thread + .messages + .iter() + .rfind(|message| message.role == Role::Assistant) + { + thread + .tool_uses_by_message + .entry(last_assistant_message.id) + .or_default() + .push(tool_use.clone()); - let tool_use_id: Arc = tool_use.id.into(); - thread.pending_tool_uses_by_id.insert( - tool_use_id.clone(), - PendingToolUse { - id: tool_use_id, - name: tool_use.name, - input: tool_use.input, - status: PendingToolUseStatus::Idle, - }, - ); + thread.pending_tool_uses_by_id.insert( + tool_use.id.clone(), + PendingToolUse { + assistant_message_id: last_assistant_message.id, + id: tool_use.id, + name: tool_use.name, + input: tool_use.input, + status: PendingToolUseStatus::Idle, + }, + ); + } } } @@ -235,7 +247,8 @@ impl Thread { pub fn insert_tool_output( &mut self, - tool_use_id: Arc, + assistant_message_id: MessageId, + tool_use_id: LanguageModelToolUseId, output: Task>, cx: &mut ModelContext, ) { @@ -244,19 +257,39 @@ impl Thread { async move { let output = output.await; thread - .update(&mut cx, |thread, cx| match output { - Ok(output) => { - thread - .completed_tool_uses_by_id - .insert(tool_use_id.clone(), output); + .update(&mut cx, |thread, cx| { + // The tool use was requested by an Assistant message, + // so we want to attach the tool results to the next + // user message. + let next_user_message = MessageId(assistant_message_id.0 + 1); - cx.emit(ThreadEvent::ToolFinished { tool_use_id }); - } - Err(err) => { - if let Some(tool_use) = - thread.pending_tool_uses_by_id.get_mut(&tool_use_id) - { - tool_use.status = PendingToolUseStatus::Error(err.to_string()); + let tool_results = thread + .tool_results_by_message + .entry(next_user_message) + .or_default(); + + match output { + Ok(output) => { + tool_results.push(LanguageModelToolResult { + tool_use_id: tool_use_id.to_string(), + content: output, + is_error: false, + }); + + cx.emit(ThreadEvent::ToolFinished { tool_use_id }); + } + Err(err) => { + tool_results.push(LanguageModelToolResult { + tool_use_id: tool_use_id.to_string(), + content: err.to_string(), + is_error: true, + }); + + if let Some(tool_use) = + thread.pending_tool_uses_by_id.get_mut(&tool_use_id) + { + tool_use.status = PendingToolUseStatus::Error(err.to_string()); + } } } }) @@ -278,7 +311,7 @@ pub enum ThreadEvent { UsePendingTools, ToolFinished { #[allow(unused)] - tool_use_id: Arc, + tool_use_id: LanguageModelToolUseId, }, } @@ -291,7 +324,9 @@ struct PendingCompletion { #[derive(Debug, Clone)] pub struct PendingToolUse { - pub id: Arc, + pub id: LanguageModelToolUseId, + /// The ID of the Assistant message in which the tool use was requested. + pub assistant_message_id: MessageId, pub name: String, pub input: serde_json::Value, pub status: PendingToolUseStatus, diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index f9df34a2d1..3c5a00bd85 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -63,9 +63,27 @@ pub enum StopReason { ToolUse, } +#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] +pub struct LanguageModelToolUseId(Arc); + +impl fmt::Display for LanguageModelToolUseId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl From for LanguageModelToolUseId +where + T: Into>, +{ + fn from(value: T) -> Self { + Self(value.into()) + } +} + #[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize)] pub struct LanguageModelToolUse { - pub id: String, + pub id: LanguageModelToolUseId, pub name: String, pub input: serde_json::Value, } diff --git a/crates/language_model/src/request.rs b/crates/language_model/src/request.rs index 06dde1862a..e6f7f210c7 100644 --- a/crates/language_model/src/request.rs +++ b/crates/language_model/src/request.rs @@ -347,7 +347,7 @@ impl LanguageModelRequest { } MessageContent::ToolUse(tool_use) => { Some(anthropic::RequestContent::ToolUse { - id: tool_use.id, + id: tool_use.id.to_string(), name: tool_use.name, input: tool_use.input, cache_control, diff --git a/crates/language_models/src/provider/anthropic.rs b/crates/language_models/src/provider/anthropic.rs index 87460b824e..e882bb900d 100644 --- a/crates/language_models/src/provider/anthropic.rs +++ b/crates/language_models/src/provider/anthropic.rs @@ -498,7 +498,7 @@ pub fn map_to_language_model_completion_events( Some(maybe!({ Ok(LanguageModelCompletionEvent::ToolUse( LanguageModelToolUse { - id: tool_use.id, + id: tool_use.id.into(), name: tool_use.name, input: if tool_use.input_json.is_empty() { serde_json::Value::Null From 7d67bb4cf69e6d860d39911cc858d3f969d0ed3a Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 26 Nov 2024 12:23:38 +0200 Subject: [PATCH 023/215] Properly use lsp::CompletionList defaults (#21202) - Closes https://github.com/zed-industries/zed/issues/21185 Release Notes: - Fixed incorrect handling of the completion list defaults --- crates/editor/src/editor_tests.rs | 194 ++++++++++++++++++++++++++++++ crates/lsp/src/lsp.rs | 1 + crates/project/src/lsp_command.rs | 45 ++++++- 3 files changed, 234 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 01507c4e31..669134ef10 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -10541,6 +10541,200 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) { cx.assert_editor_state(indoc! {"fn main() { let a = Some(2)ˇ; }"}); } +#[gpui::test] +async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {"fn main() { let a = 2ˇ; }"}); + cx.simulate_keystroke("."); + + let default_commit_characters = vec!["?".to_string()]; + let default_data = json!({ "very": "special"}); + let default_insert_text_format = lsp::InsertTextFormat::SNIPPET; + let default_insert_text_mode = lsp::InsertTextMode::AS_IS; + let default_edit_range = lsp::Range { + start: lsp::Position { + line: 0, + character: 5, + }, + end: lsp::Position { + line: 0, + character: 5, + }, + }; + + let completion_data = default_data.clone(); + let completion_characters = default_commit_characters.clone(); + cx.handle_request::(move |_, _, _| { + let default_data = completion_data.clone(); + let default_commit_characters = completion_characters.clone(); + async move { + Ok(Some(lsp::CompletionResponse::List(lsp::CompletionList { + items: vec![ + lsp::CompletionItem { + label: "Some(2)".into(), + insert_text: Some("Some(2)".into()), + data: Some(json!({ "very": "special"})), + insert_text_mode: Some(lsp::InsertTextMode::ADJUST_INDENTATION), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: "Some(2)".to_string(), + insert: lsp::Range::default(), + replace: lsp::Range::default(), + }, + )), + ..lsp::CompletionItem::default() + }, + lsp::CompletionItem { + label: "vec![2]".into(), + insert_text: Some("vec![2]".into()), + insert_text_format: Some(lsp::InsertTextFormat::PLAIN_TEXT), + ..lsp::CompletionItem::default() + }, + ], + item_defaults: Some(lsp::CompletionListItemDefaults { + data: Some(default_data.clone()), + commit_characters: Some(default_commit_characters.clone()), + edit_range: Some(lsp::CompletionListItemDefaultsEditRange::Range( + default_edit_range, + )), + insert_text_format: Some(default_insert_text_format), + insert_text_mode: Some(default_insert_text_mode), + }), + ..lsp::CompletionList::default() + }))) + } + }) + .next() + .await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + + cx.update_editor(|editor, _| { + let menu = editor.context_menu.read(); + match menu.as_ref().expect("should have the completions menu") { + ContextMenu::Completions(completions_menu) => { + assert_eq!( + completions_menu + .matches + .iter() + .map(|c| c.string.as_str()) + .collect::>(), + vec!["Some(2)", "vec![2]"] + ); + } + ContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"), + } + }); + + cx.update_editor(|editor, cx| { + editor.context_menu_first(&ContextMenuFirst, cx); + }); + let first_item_resolve_characters = default_commit_characters.clone(); + cx.handle_request::(move |_, item_to_resolve, _| { + let default_commit_characters = first_item_resolve_characters.clone(); + + async move { + assert_eq!( + item_to_resolve.label, "Some(2)", + "Should have selected the first item" + ); + assert_eq!( + item_to_resolve.data, + Some(json!({ "very": "special"})), + "First item should bring its own data for resolving" + ); + assert_eq!( + item_to_resolve.commit_characters, + Some(default_commit_characters), + "First item had no own commit characters and should inherit the default ones" + ); + assert!( + matches!( + item_to_resolve.text_edit, + Some(lsp::CompletionTextEdit::InsertAndReplace { .. }) + ), + "First item should bring its own edit range for resolving" + ); + assert_eq!( + item_to_resolve.insert_text_format, + Some(default_insert_text_format), + "First item had no own insert text format and should inherit the default one" + ); + assert_eq!( + item_to_resolve.insert_text_mode, + Some(lsp::InsertTextMode::ADJUST_INDENTATION), + "First item should bring its own insert text mode for resolving" + ); + Ok(item_to_resolve) + } + }) + .next() + .await + .unwrap(); + + cx.update_editor(|editor, cx| { + editor.context_menu_last(&ContextMenuLast, cx); + }); + cx.handle_request::(move |_, item_to_resolve, _| { + let default_data = default_data.clone(); + let default_commit_characters = default_commit_characters.clone(); + async move { + assert_eq!( + item_to_resolve.label, "vec![2]", + "Should have selected the last item" + ); + assert_eq!( + item_to_resolve.data, + Some(default_data), + "Last item has no own resolve data and should inherit the default one" + ); + assert_eq!( + item_to_resolve.commit_characters, + Some(default_commit_characters), + "Last item had no own commit characters and should inherit the default ones" + ); + assert_eq!( + item_to_resolve.text_edit, + Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: default_edit_range, + new_text: "vec![2]".to_string() + })), + "Last item had no own edit range and should inherit the default one" + ); + assert_eq!( + item_to_resolve.insert_text_format, + Some(lsp::InsertTextFormat::PLAIN_TEXT), + "Last item should bring its own insert text format for resolving" + ); + assert_eq!( + item_to_resolve.insert_text_mode, + Some(default_insert_text_mode), + "Last item had no own insert text mode and should inherit the default one" + ); + + Ok(item_to_resolve) + } + }) + .next() + .await + .unwrap(); +} + #[gpui::test] async fn test_completions_in_languages_with_extra_word_characters(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 87c04030bd..98755583e3 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -697,6 +697,7 @@ impl LanguageServer { "commitCharacters".to_owned(), "editRange".to_owned(), "insertTextMode".to_owned(), + "insertTextFormat".to_owned(), "data".to_owned(), ]), }), diff --git a/crates/project/src/lsp_command.rs b/crates/project/src/lsp_command.rs index 6de4902746..d317f5a4d4 100644 --- a/crates/project/src/lsp_command.rs +++ b/crates/project/src/lsp_command.rs @@ -1775,21 +1775,54 @@ impl LspCommand for GetCompletions { if let Some(item_defaults) = item_defaults { let default_data = item_defaults.data.as_ref(); let default_commit_characters = item_defaults.commit_characters.as_ref(); + let default_edit_range = item_defaults.edit_range.as_ref(); + let default_insert_text_format = item_defaults.insert_text_format.as_ref(); let default_insert_text_mode = item_defaults.insert_text_mode.as_ref(); if default_data.is_some() || default_commit_characters.is_some() + || default_edit_range.is_some() + || default_insert_text_format.is_some() || default_insert_text_mode.is_some() { for item in completions.iter_mut() { - if let Some(data) = default_data { - item.data = Some(data.clone()) + if item.data.is_none() && default_data.is_some() { + item.data = default_data.cloned() } - if let Some(characters) = default_commit_characters { - item.commit_characters = Some(characters.clone()) + if item.commit_characters.is_none() && default_commit_characters.is_some() { + item.commit_characters = default_commit_characters.cloned() } - if let Some(text_mode) = default_insert_text_mode { - item.insert_text_mode = Some(*text_mode) + if item.text_edit.is_none() { + if let Some(default_edit_range) = default_edit_range { + match default_edit_range { + CompletionListItemDefaultsEditRange::Range(range) => { + item.text_edit = + Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: *range, + new_text: item.label.clone(), + })) + } + CompletionListItemDefaultsEditRange::InsertAndReplace { + insert, + replace, + } => { + item.text_edit = + Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: item.label.clone(), + insert: *insert, + replace: *replace, + }, + )) + } + } + } + } + if item.insert_text_format.is_none() && default_insert_text_format.is_some() { + item.insert_text_format = default_insert_text_format.cloned() + } + if item.insert_text_mode.is_none() && default_insert_text_mode.is_some() { + item.insert_text_mode = default_insert_text_mode.cloned() } } } From 9999c31859210654dd572d54dfa42b67c00b33b0 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 26 Nov 2024 14:29:54 +0200 Subject: [PATCH 024/215] Avoid endless loop of the diagnostic updates (#21209) Follow-up of https://github.com/zed-industries/zed/pull/21173 Rust-analyzer with `checkOnSave` enabled will push diagnostics for a file after each diagnostics refresh (e.g. save, file open, file close). If there's a file that is not open in any pane and has only warnings, and the diagnostics editor has warnings toggled off, then 0. rust-analyzer will push the corresponding warnings after initial load 1. the diagnostics editor code registers `project::Event::DiagnosticsUpdated` for the corresponding file path and opens the corresponding buffer to read its associated diagnostics from the snapshot 2. opening the buffer would send `textDocument/didOpen` which would trigger rust-analyzer to push the same diagnostics 3. meanwhile, the diagnostics editor would filter out all diagnostics for that buffer, dropping the open buffer and effectively closing it 4. closing the buffer will send `textDocument/didClose` which would trigger rust-analyzer to push the same diagnostics again, as those are `cargo check` ones, still present in the file 5. GOTO 1 Release Notes: - Fixed diagnostics editor not scrolling properly under certain conditions --- crates/diagnostics/src/diagnostics.rs | 41 ++++++++++++++++++--------- crates/project/src/lsp_store.rs | 15 ++++++++++ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index be8da5c130..6db831c1ff 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -134,16 +134,27 @@ impl ProjectDiagnosticsEditor { language_server_id, path, } => { - this.paths_to_update - .insert((path.clone(), Some(*language_server_id))); - this.summary = project.read(cx).diagnostic_summary(false, cx); - cx.emit(EditorEvent::TitleChanged); + let max_severity = this.max_severity(); + let has_diagnostics_to_display = project.read(cx).lsp_store().read(cx).diagnostics_for_buffer(path) + .into_iter().flatten() + .filter(|(server_id, _)| language_server_id == server_id) + .flat_map(|(_, diagnostics)| diagnostics) + .any(|diagnostic| diagnostic.diagnostic.severity <= max_severity); - if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { - log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + if has_diagnostics_to_display { + this.paths_to_update + .insert((path.clone(), Some(*language_server_id))); + this.summary = project.read(cx).diagnostic_summary(false, cx); + cx.emit(EditorEvent::TitleChanged); + + if this.editor.focus_handle(cx).contains_focused(cx) || this.focus_handle.contains_focused(cx) { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. recording change"); + } else { + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); + this.update_stale_excerpts(cx); + } } else { - log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. updating excerpts"); - this.update_stale_excerpts(cx); + log::debug!("diagnostics updated for server {language_server_id}, path {path:?}. no diagnostics to display"); } } _ => {} @@ -329,16 +340,12 @@ impl ProjectDiagnosticsEditor { ExcerptId::min() }; + let max_severity = self.max_severity(); let path_state = &mut self.path_states[path_ix]; let mut new_group_ixs = Vec::new(); let mut blocks_to_add = Vec::new(); let mut blocks_to_remove = HashSet::default(); let mut first_excerpt_id = None; - let max_severity = if self.include_warnings { - DiagnosticSeverity::WARNING - } else { - DiagnosticSeverity::ERROR - }; let excerpts_snapshot = self.excerpts.update(cx, |excerpts, cx| { let mut old_groups = mem::take(&mut path_state.diagnostic_groups) .into_iter() @@ -627,6 +634,14 @@ impl ProjectDiagnosticsEditor { prev_path = Some(path); } } + + fn max_severity(&self) -> DiagnosticSeverity { + if self.include_warnings { + DiagnosticSeverity::WARNING + } else { + DiagnosticSeverity::ERROR + } + } } impl FocusableView for ProjectDiagnosticsEditor { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index cc326285cb..29a0afcfe5 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -2919,6 +2919,21 @@ impl LspStore { }) } + pub fn diagnostics_for_buffer( + &self, + path: &ProjectPath, + ) -> Option< + &[( + LanguageServerId, + Vec>>, + )], + > { + self.diagnostics + .get(&path.worktree_id)? + .get(&path.path) + .map(|diagnostics| diagnostics.as_slice()) + } + pub fn started_language_servers(&self) -> Vec<(WorktreeId, LanguageServerName)> { self.language_server_ids.keys().cloned().collect() } From fdc17c57d71d7b9d5cd1dfa0eb7dc65566f602a0 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 26 Nov 2024 12:58:45 +0000 Subject: [PATCH 025/215] macos: Keybind improvements for binds involving shift (#21207) Fix cmd-pipe Remove redudnant jetbrains/sublime keybinds (these exist as `cmd-{` and `cmd-}` under default vscode keymap) and were broken as part of the recent keybind changes. Remove excess JSON whitespace from tests to make them more readable. --- assets/keymaps/default-macos.json | 2 +- crates/zed/src/zed.rs | 88 +++++-------------------------- 2 files changed, 15 insertions(+), 75 deletions(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index c8bc80a9c0..ddbbdd3faf 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -355,7 +355,7 @@ "alt-cmd-f12": "editor::GoToTypeDefinitionSplit", "alt-shift-f12": "editor::FindAllReferences", "ctrl-m": "editor::MoveToEnclosingBracket", - "cmd-shift-\\": "editor::MoveToEnclosingBracket", + "cmd-|": "editor::MoveToEnclosingBracket", "alt-cmd-[": "editor::Fold", "alt-cmd-]": "editor::UnfoldLines", "cmd-k cmd-l": "editor::ToggleFold", diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 5ba63b9c1f..4e3d05d2fb 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3190,12 +3190,7 @@ mod tests { .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "Atom" - } - "# - .into(), + &r#"{"base_keymap": "Atom"}"#.into(), Default::default(), ) .await @@ -3205,16 +3200,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": "test1::A" - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": "test1::A"}}]"#.into(), Default::default(), ) .await @@ -3257,16 +3243,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": "test1::B" - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": "test1::B"}}]"#.into(), Default::default(), ) .await @@ -3286,12 +3263,7 @@ mod tests { .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "JetBrains" - } - "# - .into(), + &r#"{"base_keymap": "JetBrains"}"#.into(), Default::default(), ) .await @@ -3318,24 +3290,20 @@ mod tests { // From the Atom keymap use workspace::ActivatePreviousPane; // From the JetBrains keymap - use pane::ActivatePrevItem; + use diagnostics::Deploy; + workspace .update(cx, |workspace, _| { - workspace - .register_action(|_, _: &A, _| {}) - .register_action(|_, _: &B, _| {}); + workspace.register_action(|_, _: &A, _cx| {}); + workspace.register_action(|_, _: &B, _cx| {}); + workspace.register_action(|_, _: &Deploy, _cx| {}); }) .unwrap(); app_state .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "Atom" - } - "# - .into(), + &r#"{"base_keymap": "Atom"}"#.into(), Default::default(), ) .await @@ -3344,16 +3312,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": "test2::A" - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": "test2::A"}}]"#.into(), Default::default(), ) .await @@ -3391,16 +3350,7 @@ mod tests { .fs .save( "/keymap.json".as_ref(), - &r#" - [ - { - "bindings": { - "backspace": null - } - } - ] - "# - .into(), + &r#"[{"bindings": {"backspace": null}}]"#.into(), Default::default(), ) .await @@ -3420,12 +3370,7 @@ mod tests { .fs .save( "/settings.json".as_ref(), - &r#" - { - "base_keymap": "JetBrains" - } - "# - .into(), + &r#"{"base_keymap": "JetBrains"}"#.into(), Default::default(), ) .await @@ -3433,12 +3378,7 @@ mod tests { cx.background_executor.run_until_parked(); - assert_key_bindings_for( - workspace.into(), - cx, - vec![("[", &ActivatePrevItem)], - line!(), - ); + assert_key_bindings_for(workspace.into(), cx, vec![("6", &Deploy)], line!()); } #[gpui::test] From 8f1ec3d11b76399473ac76be374443ee9692b58d Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 26 Nov 2024 10:48:48 -0500 Subject: [PATCH 026/215] assistant2: Add a checkbox to control tool use (#21215) This PR adds a checkbox to the `assistant2` message editor to control whether tools should be used for a given message. Release Notes: - N/A --- crates/assistant2/src/message_editor.rs | 31 ++++++++++++++++++------- 1 file changed, 22 insertions(+), 9 deletions(-) diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index c42d66a4d7..7f789587c6 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,10 +1,9 @@ use editor::{Editor, EditorElement, EditorStyle}; -use feature_flags::{FeatureFlagAppExt, ToolUseFeatureFlag}; use gpui::{AppContext, FocusableView, Model, TextStyle, View}; use language_model::{LanguageModelRegistry, LanguageModelRequestTool}; use settings::Settings; use theme::ThemeSettings; -use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; +use ui::{prelude::*, ButtonLike, CheckboxWithLabel, ElevationIndex, KeyBinding}; use crate::thread::{RequestKind, Thread}; use crate::Chat; @@ -12,6 +11,7 @@ use crate::Chat; pub struct MessageEditor { thread: Model, editor: View, + use_tools: bool, } impl MessageEditor { @@ -24,6 +24,7 @@ impl MessageEditor { editor }), + use_tools: false, } } @@ -58,7 +59,7 @@ impl MessageEditor { thread.insert_user_message(user_message); let mut request = thread.to_completion_request(request_kind, cx); - if cx.has_flag::() { + if self.use_tools { request.tools = thread .tools() .tools(cx) @@ -123,12 +124,24 @@ impl Render for MessageEditor { h_flex() .justify_between() .child( - h_flex().child( - Button::new("add-context", "Add Context") - .style(ButtonStyle::Filled) - .icon(IconName::Plus) - .icon_position(IconPosition::Start), - ), + h_flex() + .child( + Button::new("add-context", "Add Context") + .style(ButtonStyle::Filled) + .icon(IconName::Plus) + .icon_position(IconPosition::Start), + ) + .child(CheckboxWithLabel::new( + "use-tools", + Label::new("Tools"), + self.use_tools.into(), + cx.listener(|this, selection, _cx| { + this.use_tools = match selection { + Selection::Selected => true, + Selection::Unselected | Selection::Indeterminate => false, + }; + }), + )), ) .child( h_flex() From 884748038e9c99b83b943d4550dd3cf515563071 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 26 Nov 2024 11:09:43 -0500 Subject: [PATCH 027/215] Styling for Apply/Discard buttons (#21017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change the "Apply" and "Discard" buttons to match @danilo-leal's design! Here are some different states: ### Cursor in the first hunk Now that the cursor is in a particular hunk, we show the "Apply" and "Discard" names, and the keyboard shortcut. If I press the keyboard shortcut, it will only apply to this hunk. Screenshot 2024-11-23 at 10 54 45 PM ### Cursor in the second hunk Moving the cursor to a different hunk changes which buttons get the keyboard shortcut treatment. Now the keyboard shortcut is shown next to the hunk that will actually be affected if you press that shortcut. Screenshot 2024-11-23 at 10 56 27 PM Release Notes: - Restyled Apply/Discard buttons --------- Co-authored-by: Max Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Co-authored-by: Danilo Leal Co-authored-by: Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> --- assets/keymaps/default-linux.json | 2 +- assets/keymaps/default-macos.json | 2 +- crates/editor/src/actions.rs | 2 +- crates/editor/src/editor.rs | 64 ++- crates/editor/src/editor_tests.rs | 2 +- crates/editor/src/element.rs | 23 +- crates/editor/src/hunk_diff.rs | 483 ++++++++++--------- crates/editor/src/proposed_changes_editor.rs | 118 ++++- crates/zed/src/zed.rs | 8 +- 9 files changed, 411 insertions(+), 293 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2eedc1c839..9ba416c210 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -522,7 +522,7 @@ { "context": "ProposedChangesEditor", "bindings": { - "ctrl-shift-y": "editor::ApplyDiffHunk", + "ctrl-shift-y": "editor::ApplySelectedDiffHunks", "ctrl-alt-a": "editor::ApplyAllDiffHunks" } }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ddbbdd3faf..a4eae2af52 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -562,7 +562,7 @@ { "context": "ProposedChangesEditor", "bindings": { - "cmd-shift-y": "editor::ApplyDiffHunk", + "cmd-shift-y": "editor::ApplySelectedDiffHunks", "cmd-shift-a": "editor::ApplyAllDiffHunks" } }, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 5b11b18bc2..719a35a009 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -209,7 +209,7 @@ gpui::actions!( AddSelectionAbove, AddSelectionBelow, ApplyAllDiffHunks, - ApplyDiffHunk, + ApplySelectedDiffHunks, Backspace, Cancel, CancelLanguageServerWork, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 78f0aab5a5..eeaaeb5c2b 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -99,7 +99,8 @@ use language::{ use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange}; use linked_editing_ranges::refresh_linked_ranges; pub use proposed_changes_editor::{ - ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, + ProposedChangeLocation, ProposedChangesEditor, ProposedChangesToolbar, + ProposedChangesToolbarControls, }; use similar::{ChangeTag, TextDiff}; use std::iter::Peekable; @@ -160,7 +161,7 @@ use theme::{ }; use ui::{ h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, - ListItem, Popover, PopoverMenuHandle, Tooltip, + ListItem, Popover, Tooltip, }; use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt}; use workspace::item::{ItemHandle, PreviewTabsSettings}; @@ -590,7 +591,6 @@ pub struct Editor { nav_history: Option, context_menu: RwLock>, mouse_context_menu: Option, - hunk_controls_menu_handle: PopoverMenuHandle, completion_tasks: Vec<(CompletionId, Task>)>, signature_help_state: SignatureHelpState, auto_signature_help: Option, @@ -2112,7 +2112,6 @@ impl Editor { nav_history: None, context_menu: RwLock::new(None), mouse_context_menu: None, - hunk_controls_menu_handle: PopoverMenuHandle::default(), completion_tasks: Default::default(), signature_help_state: SignatureHelpState::default(), auto_signature_help: None, @@ -13558,20 +13557,24 @@ fn test_wrap_with_prefix() { ); } +fn is_hunk_selected(hunk: &MultiBufferDiffHunk, selections: &[Selection]) -> bool { + let mut buffer_rows_for_selections = selections.iter().map(|selection| { + let start = MultiBufferRow(selection.start.row); + let end = MultiBufferRow(selection.end.row); + start..end + }); + + buffer_rows_for_selections.any(|range| does_selection_touch_hunk(&range, hunk)) +} + fn hunks_for_selections( multi_buffer_snapshot: &MultiBufferSnapshot, selections: &[Selection], ) -> Vec { let buffer_rows_for_selections = selections.iter().map(|selection| { - let head = selection.head(); - let tail = selection.tail(); - let start = MultiBufferRow(tail.to_point(multi_buffer_snapshot).row); - let end = MultiBufferRow(head.to_point(multi_buffer_snapshot).row); - if start > end { - end..start - } else { - start..end - } + let start = MultiBufferRow(selection.start.to_point(multi_buffer_snapshot).row); + let end = MultiBufferRow(selection.end.to_point(multi_buffer_snapshot).row); + start..end }); hunks_for_rows(buffer_rows_for_selections, multi_buffer_snapshot) @@ -13588,19 +13591,8 @@ pub fn hunks_for_rows( let query_rows = selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row(); for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) { - // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it - // when the caret is just above or just below the deleted hunk. - let allow_adjacent = hunk_status(&hunk) == DiffHunkStatus::Removed; - let related_to_selection = if allow_adjacent { - hunk.row_range.overlaps(&query_rows) - || hunk.row_range.start == query_rows.end - || hunk.row_range.end == query_rows.start - } else { - // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) - // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected) - hunk.row_range.overlaps(&selected_multi_buffer_rows) - || selected_multi_buffer_rows.end == hunk.row_range.start - }; + let related_to_selection = + does_selection_touch_hunk(&selected_multi_buffer_rows, &hunk); if related_to_selection { if !processed_buffer_rows .entry(hunk.buffer_id) @@ -13617,6 +13609,26 @@ pub fn hunks_for_rows( hunks } +fn does_selection_touch_hunk( + selected_multi_buffer_rows: &Range, + hunk: &MultiBufferDiffHunk, +) -> bool { + let query_rows = selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row(); + // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it + // when the caret is just above or just below the deleted hunk. + let allow_adjacent = hunk_status(hunk) == DiffHunkStatus::Removed; + if allow_adjacent { + hunk.row_range.overlaps(&query_rows) + || hunk.row_range.start == query_rows.end + || hunk.row_range.end == query_rows.start + } else { + // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) + // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected) + hunk.row_range.overlaps(selected_multi_buffer_rows) + || selected_multi_buffer_rows.end == hunk.row_range.start + } +} + pub trait CollaborationHub { fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap; fn user_participant_indices<'a>( diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 669134ef10..397d5e46d4 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -12552,7 +12552,7 @@ async fn test_edits_around_expanded_insertion_hunks( executor.run_until_parked(); cx.assert_diff_hunks( r#" - use some::mod1; + - use some::mod1; - use some::mod2; - - const A: u32 = 42; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7f4bc3fb77..19c1f3bf39 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -2509,6 +2509,7 @@ impl EditorElement { element, available_space: size(AvailableSpace::MinContent, element_size.height.into()), style: BlockStyle::Fixed, + is_zero_height: block.height() == 0, }); } for (row, block) in non_fixed_blocks { @@ -2555,6 +2556,7 @@ impl EditorElement { element, available_space: size(width.into(), element_size.height.into()), style, + is_zero_height: block.height() == 0, }); } @@ -2602,6 +2604,7 @@ impl EditorElement { element, available_space: size(width, element_size.height.into()), style, + is_zero_height: block.height() == 0, }); } } @@ -3947,8 +3950,23 @@ impl EditorElement { } fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { - for mut block in layout.blocks.drain(..) { - block.element.paint(cx); + cx.paint_layer(layout.text_hitbox.bounds, |cx| { + layout.blocks.retain_mut(|block| { + if !block.is_zero_height { + block.element.paint(cx); + } + + block.is_zero_height + }); + }); + + // Paint all the zero-height blocks in a higher layer (if there were any remaining to paint). + if !layout.blocks.is_empty() { + cx.paint_layer(layout.text_hitbox.bounds, |cx| { + for mut block in layout.blocks.drain(..) { + block.element.paint(cx); + } + }); } } @@ -6011,6 +6029,7 @@ struct BlockLayout { element: AnyElement, available_space: Size, style: BlockStyle, + is_zero_height: bool, } fn layout_line( diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index 27bb8ac557..5c6d5ff7a3 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -1,6 +1,8 @@ use collections::{hash_map, HashMap, HashSet}; use git::diff::DiffHunkStatus; -use gpui::{Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Task, View}; +use gpui::{ + AppContext, ClickEvent, CursorStyle, FocusableView, Hsla, Model, MouseButton, Task, View, +}; use language::{Buffer, BufferId, Point}; use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferDiffHunk, MultiBufferRow, @@ -9,17 +11,18 @@ use multi_buffer::{ use std::{ops::Range, sync::Arc}; use text::OffsetRangeExt; use ui::{ - prelude::*, ActiveTheme, ContextMenu, IconButtonShape, InteractiveElement, IntoElement, - ParentElement, PopoverMenu, Styled, Tooltip, ViewContext, VisualContext, + prelude::*, ActiveTheme, IconButtonShape, InteractiveElement, IntoElement, KeyBinding, + ParentElement, Styled, TintColor, Tooltip, ViewContext, VisualContext, }; use util::RangeExt; use workspace::Item; use crate::{ - editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections, ApplyAllDiffHunks, - ApplyDiffHunk, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, DiffRowHighlight, - DisplayRow, DisplaySnapshot, Editor, EditorElement, ExpandAllHunkDiffs, GoToHunk, GoToPrevHunk, - RevertFile, RevertSelectedHunks, ToDisplayPoint, ToggleHunkDiff, + editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections, is_hunk_selected, + ApplyAllDiffHunks, ApplySelectedDiffHunks, BlockPlacement, BlockProperties, BlockStyle, + CustomBlockId, DiffRowHighlight, DisplayRow, DisplaySnapshot, Editor, EditorElement, + ExpandAllHunkDiffs, GoToHunk, GoToPrevHunk, RevertSelectedHunks, ToDisplayPoint, + ToggleHunkDiff, }; #[derive(Debug, Clone)] @@ -57,7 +60,6 @@ pub enum DisplayDiffHunk { Folded { display_row: DisplayRow, }, - Unfolded { diff_base_byte_range: Range, display_row_range: Range, @@ -371,26 +373,35 @@ impl Editor { pub(crate) fn apply_selected_diff_hunks( &mut self, - _: &ApplyDiffHunk, + _: &ApplySelectedDiffHunks, cx: &mut ViewContext, ) { let snapshot = self.buffer.read(cx).snapshot(cx); let hunks = hunks_for_selections(&snapshot, &self.selections.disjoint_anchors()); - let mut ranges_by_buffer = HashMap::default(); - self.transact(cx, |editor, cx| { - for hunk in hunks { - if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { - ranges_by_buffer - .entry(buffer.clone()) - .or_insert_with(Vec::new) - .push(hunk.buffer_range.to_offset(buffer.read(cx))); - } - } - for (buffer, ranges) in ranges_by_buffer { - buffer.update(cx, |buffer, cx| { - buffer.merge_into_base(ranges, cx); - }); + self.transact(cx, |editor, cx| { + if hunks.is_empty() { + // If there are no selected hunks, e.g. because we're using the keybinding with nothing selected, apply the first hunk. + if let Some(first_hunk) = editor.expanded_hunks.hunks.first() { + editor.apply_diff_hunks_in_range(first_hunk.hunk_range.clone(), cx); + } + } else { + let mut ranges_by_buffer = HashMap::default(); + + for hunk in hunks { + if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { + ranges_by_buffer + .entry(buffer.clone()) + .or_insert_with(Vec::new) + .push(hunk.buffer_range.to_offset(buffer.read(cx))); + } + } + + for (buffer, ranges) in ranges_by_buffer { + buffer.update(cx, |buffer, cx| { + buffer.merge_into_base(ranges, cx); + }); + } } }); @@ -412,246 +423,238 @@ impl Editor { buffer.read(cx).diff_base_buffer().is_some() }); - let border_color = cx.theme().colors().border_variant; - let bg_color = cx.theme().colors().editor_background; - let gutter_color = match hunk.status { - DiffHunkStatus::Added => cx.theme().status().created, - DiffHunkStatus::Modified => cx.theme().status().modified, - DiffHunkStatus::Removed => cx.theme().status().deleted, - }; - BlockProperties { placement: BlockPlacement::Above(hunk.multi_buffer_range.start), - height: 1, + height: 0, style: BlockStyle::Sticky, - priority: 0, + priority: 1, render: Arc::new({ let editor = cx.view().clone(); let hunk = hunk.clone(); move |cx| { - let hunk_controls_menu_handle = - editor.read(cx).hunk_controls_menu_handle.clone(); + let is_hunk_selected = editor.update(&mut **cx, |editor, cx| { + let snapshot = editor.buffer.read(cx).snapshot(cx); + let selections = &editor.selections.all::(cx); + + if editor.focus_handle(cx).is_focused(cx) && !selections.is_empty() { + if let Some(hunk) = to_diff_hunk(&hunk, &snapshot) { + is_hunk_selected(&hunk, selections) + } else { + false + } + } else { + // If we have no cursor, or aren't focused, then default to the first hunk + // because that's what the keyboard shortcuts do. + editor + .expanded_hunks + .hunks + .first() + .map(|first_hunk| first_hunk.hunk_range == hunk.multi_buffer_range) + .unwrap_or(false) + } + }); + + let focus_handle = editor.focus_handle(cx); + + let handle_discard_click = { + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event: &ClickEvent, cx: &mut WindowContext| { + let multi_buffer = editor.read(cx).buffer().clone(); + let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); + let mut revert_changes = HashMap::default(); + if let Some(hunk) = + crate::hunk_diff::to_diff_hunk(&hunk, &multi_buffer_snapshot) + { + Editor::prepare_revert_change( + &mut revert_changes, + &multi_buffer, + &hunk, + cx, + ); + } + if !revert_changes.is_empty() { + editor.update(cx, |editor, cx| editor.revert(revert_changes, cx)); + } + } + }; + + let handle_apply_click = { + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event: &ClickEvent, cx: &mut WindowContext| { + editor.update(cx, |editor, cx| { + editor + .apply_diff_hunks_in_range(hunk.multi_buffer_range.clone(), cx); + }); + } + }; + + let discard_key_binding = + KeyBinding::for_action_in(&RevertSelectedHunks, &focus_handle, cx); + + let discard_tooltip = { + let focus_handle = editor.focus_handle(cx); + move |cx: &mut WindowContext| { + Tooltip::for_action_in( + "Discard Hunk", + &RevertSelectedHunks, + &focus_handle, + cx, + ) + } + }; h_flex() .id(cx.block_id) - .block_mouse_down() - .h(cx.line_height()) + .pr_5() .w_full() - .border_t_1() - .border_color(border_color) - .bg(bg_color) - .child( - div() - .id("gutter-strip") - .w(EditorElement::diff_hunk_strip_width(cx.line_height())) - .h_full() - .bg(gutter_color) - .cursor(CursorStyle::PointingHand) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - editor.update(cx, |editor, cx| { - editor.toggle_hovered_hunk(&hunk, cx); - }); - } - }), - ) + .justify_end() .child( h_flex() - .px_6() - .size_full() - .justify_end() - .child( - h_flex() - .gap_1() - .when(!is_branch_buffer, |row| { - row.child( - IconButton::new("next-hunk", IconName::ArrowDown) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - editor.update(cx, |editor, cx| { - editor.go_to_subsequent_hunk( - hunk.multi_buffer_range.end, - cx, - ); - }); - } - }), - ) - .child( - IconButton::new("prev-hunk", IconName::ArrowUp) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |cx| { - Tooltip::for_action_in( - "Previous Hunk", - &GoToPrevHunk, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - editor.update(cx, |editor, cx| { - editor.go_to_preceding_hunk( - hunk.multi_buffer_range.start, - cx, - ); - }); - } - }), - ) - }) - .child( - IconButton::new("discard", IconName::Undo) + .h(cx.line_height()) + .gap_1() + .px_1() + .pb_1() + .border_x_1() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .rounded_b_lg() + .bg(cx.theme().colors().editor_background) + .shadow(smallvec::smallvec![gpui::BoxShadow { + color: gpui::hsla(0.0, 0.0, 0.0, 0.1), + blur_radius: px(1.0), + spread_radius: px(1.0), + offset: gpui::point(px(0.), px(1.0)), + }]) + .when(!is_branch_buffer, |row| { + row.child( + IconButton::new("next-hunk", IconName::ArrowDown) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |cx| { + Tooltip::for_action_in( + "Next Hunk", + &GoToHunk, + &focus_handle.clone(), + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + editor.update(cx, |editor, cx| { + editor.go_to_subsequent_hunk( + hunk.multi_buffer_range.end, + cx, + ); + }); + } + }), + ) + .child( + IconButton::new("prev-hunk", IconName::ArrowUp) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |cx| { + Tooltip::for_action_in( + "Previous Hunk", + &GoToPrevHunk, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + editor.update(cx, |editor, cx| { + editor.go_to_preceding_hunk( + hunk.multi_buffer_range.start, + cx, + ); + }); + } + }), + ) + }) + .child(if is_branch_buffer { + if is_hunk_selected { + Button::new("discard", "Discard") + .style(ButtonStyle::Tinted(TintColor::Negative)) + .label_size(LabelSize::Small) + .key_binding(discard_key_binding) + .on_click(handle_discard_click.clone()) + .into_any_element() + } else { + IconButton::new("discard", IconName::Close) + .style(ButtonStyle::Tinted(TintColor::Negative)) + .shape(IconButtonShape::Square) + .icon_size(IconSize::XSmall) + .tooltip(discard_tooltip.clone()) + .on_click(handle_discard_click.clone()) + .into_any_element() + } + } else { + if is_hunk_selected { + Button::new("undo", "Undo") + .style(ButtonStyle::Tinted(TintColor::Negative)) + .label_size(LabelSize::Small) + .key_binding(discard_key_binding) + .on_click(handle_discard_click.clone()) + .into_any_element() + } else { + IconButton::new("undo", IconName::Undo) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip(discard_tooltip.clone()) + .on_click(handle_discard_click.clone()) + .into_any_element() + } + }) + .when(is_branch_buffer, |this| { + this.child({ + let button = Button::new("apply", "Apply") + .style(ButtonStyle::Tinted(TintColor::Positive)) + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action_in( + &ApplySelectedDiffHunks, + &focus_handle, + cx, + )) + .on_click(handle_apply_click.clone()) + .into_any_element(); + if is_hunk_selected { + button + } else { + IconButton::new("apply", IconName::Check) + .style(ButtonStyle::Tinted(TintColor::Positive)) .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) + .icon_size(IconSize::XSmall) .tooltip({ let focus_handle = editor.focus_handle(cx); move |cx| { Tooltip::for_action_in( - "Discard Hunk", - &RevertSelectedHunks, + "Apply Hunk", + &ApplySelectedDiffHunks, &focus_handle, cx, ) } }) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - let multi_buffer = - editor.read(cx).buffer().clone(); - let multi_buffer_snapshot = - multi_buffer.read(cx).snapshot(cx); - let mut revert_changes = HashMap::default(); - if let Some(hunk) = - crate::hunk_diff::to_diff_hunk( - &hunk, - &multi_buffer_snapshot, - ) - { - Editor::prepare_revert_change( - &mut revert_changes, - &multi_buffer, - &hunk, - cx, - ); - } - if !revert_changes.is_empty() { - editor.update(cx, |editor, cx| { - editor.revert(revert_changes, cx) - }); - } - } - }), - ) - .map(|this| { - if is_branch_buffer { - this.child( - IconButton::new("apply", IconName::Check) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = - editor.focus_handle(cx); - move |cx| { - Tooltip::for_action_in( - "Apply Hunk", - &ApplyDiffHunk, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - editor.update(cx, |editor, cx| { - editor - .apply_diff_hunks_in_range( - hunk.multi_buffer_range - .clone(), - cx, - ); - }); - } - }), - ) - } else { - this.child({ - let focus = editor.focus_handle(cx); - PopoverMenu::new("hunk-controls-dropdown") - .trigger( - IconButton::new( - "toggle_editor_selections_icon", - IconName::EllipsisVertical, - ) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .style(ButtonStyle::Subtle) - .selected( - hunk_controls_menu_handle - .is_deployed(), - ) - .when( - !hunk_controls_menu_handle - .is_deployed(), - |this| { - this.tooltip(|cx| { - Tooltip::text( - "Hunk Controls", - cx, - ) - }) - }, - ), - ) - .anchor(AnchorCorner::TopRight) - .with_handle(hunk_controls_menu_handle) - .menu(move |cx| { - let focus = focus.clone(); - let menu = ContextMenu::build( - cx, - move |menu, _| { - menu.context(focus.clone()) - .action( - "Discard All Hunks", - RevertFile - .boxed_clone(), - ) - }, - ); - Some(menu) - }) - }) - } - }), - ) + .on_click(handle_apply_click.clone()) + .into_any_element() + } + }) + }) .when(!is_branch_buffer, |div| { div.child( IconButton::new("collapse", IconName::Close) @@ -707,7 +710,7 @@ impl Editor { placement: BlockPlacement::Above(hunk.multi_buffer_range.start), height, style: BlockStyle::Flex, - priority: 0, + priority: 1, render: Arc::new(move |cx| { let width = EditorElement::diff_hunk_strip_width(cx.line_height()); let gutter_dimensions = editor.read(cx.context).gutter_dimensions; diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs index ac97fe18da..3a9509eb39 100644 --- a/crates/editor/src/proposed_changes_editor.rs +++ b/crates/editor/src/proposed_changes_editor.rs @@ -5,10 +5,11 @@ use gpui::{AppContext, EventEmitter, FocusableView, Model, Render, Subscription, use language::{Buffer, BufferEvent, Capability}; use multi_buffer::{ExcerptRange, MultiBuffer}; use project::Project; +use settings::Settings; use smol::stream::StreamExt; use std::{any::TypeId, ops::Range, rc::Rc, time::Duration}; use text::ToOffset; -use ui::{prelude::*, ButtonLike, KeyBinding}; +use ui::{prelude::*, KeyBinding}; use workspace::{ searchable::SearchableItemHandle, Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -34,7 +35,11 @@ struct BufferEntry { _subscription: Subscription, } -pub struct ProposedChangesEditorToolbar { +pub struct ProposedChangesToolbarControls { + current_editor: Option>, +} + +pub struct ProposedChangesToolbar { current_editor: Option>, } @@ -228,6 +233,10 @@ impl ProposedChangesEditor { _ => (), } } + + fn all_changes_accepted(&self) -> bool { + false // In the future, we plan to compute this based on the current state of patches. + } } impl Render for ProposedChangesEditor { @@ -251,7 +260,11 @@ impl Item for ProposedChangesEditor { type Event = EditorEvent; fn tab_icon(&self, _cx: &ui::WindowContext) -> Option { - Some(Icon::new(IconName::Diff)) + if self.all_changes_accepted() { + Some(Icon::new(IconName::Check).color(Color::Success)) + } else { + Some(Icon::new(IconName::ZedAssistant)) + } } fn tab_content_text(&self, _cx: &WindowContext) -> Option { @@ -317,7 +330,7 @@ impl Item for ProposedChangesEditor { } } -impl ProposedChangesEditorToolbar { +impl ProposedChangesToolbarControls { pub fn new() -> Self { Self { current_editor: None, @@ -333,28 +346,97 @@ impl ProposedChangesEditorToolbar { } } -impl Render for ProposedChangesEditorToolbar { +impl Render for ProposedChangesToolbarControls { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All")); + if let Some(editor) = &self.current_editor { + let focus_handle = editor.focus_handle(cx); + let action = &ApplyAllDiffHunks; + let keybinding = KeyBinding::for_action_in(action, &focus_handle, cx); - match &self.current_editor { - Some(editor) => { - let focus_handle = editor.focus_handle(cx); - let keybinding = KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, cx) - .map(|binding| binding.into_any_element()); + let editor = editor.read(cx); - button_like.children(keybinding).on_click({ - move |_event, cx| focus_handle.dispatch_action(&ApplyAllDiffHunks, cx) - }) - } - None => button_like.disabled(true), + let apply_all_button = if editor.all_changes_accepted() { + None + } else { + Some( + Button::new("apply-changes", "Apply All") + .style(ButtonStyle::Filled) + .key_binding(keybinding) + .on_click(move |_event, cx| focus_handle.dispatch_action(action, cx)), + ) + }; + + h_flex() + .gap_1() + .children([apply_all_button].into_iter().flatten()) + .into_any_element() + } else { + gpui::Empty.into_any_element() } } } -impl EventEmitter for ProposedChangesEditorToolbar {} +impl EventEmitter for ProposedChangesToolbarControls {} -impl ToolbarItemView for ProposedChangesEditorToolbar { +impl ToolbarItemView for ProposedChangesToolbarControls { + fn set_active_pane_item( + &mut self, + active_pane_item: Option<&dyn workspace::ItemHandle>, + _cx: &mut ViewContext, + ) -> workspace::ToolbarItemLocation { + self.current_editor = + active_pane_item.and_then(|item| item.downcast::()); + self.get_toolbar_item_location() + } +} + +impl ProposedChangesToolbar { + pub fn new() -> Self { + Self { + current_editor: None, + } + } + + fn get_toolbar_item_location(&self) -> ToolbarItemLocation { + if self.current_editor.is_some() { + ToolbarItemLocation::PrimaryLeft + } else { + ToolbarItemLocation::Hidden + } + } +} + +impl Render for ProposedChangesToolbar { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + if let Some(editor) = &self.current_editor { + let editor = editor.read(cx); + let all_changes_accepted = editor.all_changes_accepted(); + let icon = if all_changes_accepted { + Icon::new(IconName::Check).color(Color::Success) + } else { + Icon::new(IconName::ZedAssistant) + }; + + h_flex() + .gap_2p5() + .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) + .child(icon.size(IconSize::Small)) + .child( + Label::new(editor.title.clone()) + .color(Color::Muted) + .single_line() + .strikethrough(all_changes_accepted), + ) + .into_any_element() + } else { + gpui::Empty.into_any_element() + } + } +} + +impl EventEmitter for ProposedChangesToolbar {} + +impl ToolbarItemView for ProposedChangesToolbar { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn workspace::ItemHandle>, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e3d05d2fb..f5c0259b1a 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -17,8 +17,8 @@ use breadcrumbs::Breadcrumbs; use client::{zed_urls, ZED_URL_SCHEME}; use collections::VecDeque; use command_palette_hooks::CommandPaletteFilter; -use editor::ProposedChangesEditorToolbar; use editor::{scroll::Autoscroll, Editor, MultiBuffer}; +use editor::{ProposedChangesToolbar, ProposedChangesToolbarControls}; use feature_flags::FeatureFlagAppExt; use futures::{channel::mpsc, select_biased, StreamExt}; use gpui::{ @@ -644,8 +644,10 @@ fn initialize_pane(workspace: &Workspace, pane: &View, cx: &mut ViewContex let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); toolbar.add_item(buffer_search_bar.clone(), cx); - let proposed_change_bar = cx.new_view(|_| ProposedChangesEditorToolbar::new()); - toolbar.add_item(proposed_change_bar, cx); + let proposed_changes_bar = cx.new_view(|_| ProposedChangesToolbar::new()); + toolbar.add_item(proposed_changes_bar, cx); + let proposed_changes_controls = cx.new_view(|_| ProposedChangesToolbarControls::new()); + toolbar.add_item(proposed_changes_controls, cx); let quick_action_bar = cx.new_view(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx)); toolbar.add_item(quick_action_bar, cx); From 6dbe2ef10c52d040a2d0419dbd43b371cd52491c Mon Sep 17 00:00:00 2001 From: yoleuh Date: Tue, 26 Nov 2024 11:10:28 -0500 Subject: [PATCH 028/215] docs: Fix default value for `relative_line_numbers` in vim (#21196) ![image](https://github.com/user-attachments/assets/91c00938-f056-4778-8999-6a805bc12247) Changes: `true` to `false` Reasoning: matches zed default settings as well as the settings changes portion of the vim docs ![image](https://github.com/user-attachments/assets/cb3240bc-8c55-4802-88c0-dd069992ca30) ![image](https://github.com/user-attachments/assets/747fbe8a-b24c-45f2-b3ab-f09bccdb4ec3) Release Notes: - N/A --- docs/src/vim.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/vim.md b/docs/src/vim.md index 8bfa6aa73f..254c5a0934 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -445,7 +445,7 @@ Here are a few general Zed settings that can help you fine-tune your Vim experie | Property | Description | Default Value | | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | | cursor_blink | If `true`, the cursor blinks. | `true` | -| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `true` | +| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `false` | | scrollbar | Object that controls the scrollbar display. Set to `{ "show": "never" }` to hide the scroll bar. | `{ "show": "always" }` | | scroll_beyond_last_line | If set to `"one_page"`, allows scrolling up to one page beyond the last line. Set to `"off"` to prevent this behavior. | `"one_page"` | | vertical_scroll_margin | The number of lines to keep above or below the cursor when scrolling. Set to `0` to allow the cursor to go up to the edges of the screen vertically. | `3` | From 64708527e7a994401076b367f67eebc5280c13a3 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 26 Nov 2024 10:19:13 -0800 Subject: [PATCH 029/215] Revert "Styling for Apply/Discard buttons (#21017)" This reverts commit 884748038e9c99b83b943d4550dd3cf515563071. --- assets/keymaps/default-linux.json | 2 +- assets/keymaps/default-macos.json | 2 +- crates/editor/src/actions.rs | 2 +- crates/editor/src/editor.rs | 64 +-- crates/editor/src/editor_tests.rs | 2 +- crates/editor/src/element.rs | 23 +- crates/editor/src/hunk_diff.rs | 479 +++++++++---------- crates/editor/src/proposed_changes_editor.rs | 118 +---- crates/zed/src/zed.rs | 8 +- 9 files changed, 291 insertions(+), 409 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 9ba416c210..2eedc1c839 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -522,7 +522,7 @@ { "context": "ProposedChangesEditor", "bindings": { - "ctrl-shift-y": "editor::ApplySelectedDiffHunks", + "ctrl-shift-y": "editor::ApplyDiffHunk", "ctrl-alt-a": "editor::ApplyAllDiffHunks" } }, diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index a4eae2af52..ddbbdd3faf 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -562,7 +562,7 @@ { "context": "ProposedChangesEditor", "bindings": { - "cmd-shift-y": "editor::ApplySelectedDiffHunks", + "cmd-shift-y": "editor::ApplyDiffHunk", "cmd-shift-a": "editor::ApplyAllDiffHunks" } }, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 719a35a009..5b11b18bc2 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -209,7 +209,7 @@ gpui::actions!( AddSelectionAbove, AddSelectionBelow, ApplyAllDiffHunks, - ApplySelectedDiffHunks, + ApplyDiffHunk, Backspace, Cancel, CancelLanguageServerWork, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index eeaaeb5c2b..78f0aab5a5 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -99,8 +99,7 @@ use language::{ use language::{point_to_lsp, BufferRow, CharClassifier, Runnable, RunnableRange}; use linked_editing_ranges::refresh_linked_ranges; pub use proposed_changes_editor::{ - ProposedChangeLocation, ProposedChangesEditor, ProposedChangesToolbar, - ProposedChangesToolbarControls, + ProposedChangeLocation, ProposedChangesEditor, ProposedChangesEditorToolbar, }; use similar::{ChangeTag, TextDiff}; use std::iter::Peekable; @@ -161,7 +160,7 @@ use theme::{ }; use ui::{ h_flex, prelude::*, ButtonSize, ButtonStyle, Disclosure, IconButton, IconName, IconSize, - ListItem, Popover, Tooltip, + ListItem, Popover, PopoverMenuHandle, Tooltip, }; use util::{defer, maybe, post_inc, RangeExt, ResultExt, TryFutureExt}; use workspace::item::{ItemHandle, PreviewTabsSettings}; @@ -591,6 +590,7 @@ pub struct Editor { nav_history: Option, context_menu: RwLock>, mouse_context_menu: Option, + hunk_controls_menu_handle: PopoverMenuHandle, completion_tasks: Vec<(CompletionId, Task>)>, signature_help_state: SignatureHelpState, auto_signature_help: Option, @@ -2112,6 +2112,7 @@ impl Editor { nav_history: None, context_menu: RwLock::new(None), mouse_context_menu: None, + hunk_controls_menu_handle: PopoverMenuHandle::default(), completion_tasks: Default::default(), signature_help_state: SignatureHelpState::default(), auto_signature_help: None, @@ -13557,24 +13558,20 @@ fn test_wrap_with_prefix() { ); } -fn is_hunk_selected(hunk: &MultiBufferDiffHunk, selections: &[Selection]) -> bool { - let mut buffer_rows_for_selections = selections.iter().map(|selection| { - let start = MultiBufferRow(selection.start.row); - let end = MultiBufferRow(selection.end.row); - start..end - }); - - buffer_rows_for_selections.any(|range| does_selection_touch_hunk(&range, hunk)) -} - fn hunks_for_selections( multi_buffer_snapshot: &MultiBufferSnapshot, selections: &[Selection], ) -> Vec { let buffer_rows_for_selections = selections.iter().map(|selection| { - let start = MultiBufferRow(selection.start.to_point(multi_buffer_snapshot).row); - let end = MultiBufferRow(selection.end.to_point(multi_buffer_snapshot).row); - start..end + let head = selection.head(); + let tail = selection.tail(); + let start = MultiBufferRow(tail.to_point(multi_buffer_snapshot).row); + let end = MultiBufferRow(head.to_point(multi_buffer_snapshot).row); + if start > end { + end..start + } else { + start..end + } }); hunks_for_rows(buffer_rows_for_selections, multi_buffer_snapshot) @@ -13591,8 +13588,19 @@ pub fn hunks_for_rows( let query_rows = selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row(); for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) { - let related_to_selection = - does_selection_touch_hunk(&selected_multi_buffer_rows, &hunk); + // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it + // when the caret is just above or just below the deleted hunk. + let allow_adjacent = hunk_status(&hunk) == DiffHunkStatus::Removed; + let related_to_selection = if allow_adjacent { + hunk.row_range.overlaps(&query_rows) + || hunk.row_range.start == query_rows.end + || hunk.row_range.end == query_rows.start + } else { + // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) + // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected) + hunk.row_range.overlaps(&selected_multi_buffer_rows) + || selected_multi_buffer_rows.end == hunk.row_range.start + }; if related_to_selection { if !processed_buffer_rows .entry(hunk.buffer_id) @@ -13609,26 +13617,6 @@ pub fn hunks_for_rows( hunks } -fn does_selection_touch_hunk( - selected_multi_buffer_rows: &Range, - hunk: &MultiBufferDiffHunk, -) -> bool { - let query_rows = selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row(); - // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it - // when the caret is just above or just below the deleted hunk. - let allow_adjacent = hunk_status(hunk) == DiffHunkStatus::Removed; - if allow_adjacent { - hunk.row_range.overlaps(&query_rows) - || hunk.row_range.start == query_rows.end - || hunk.row_range.end == query_rows.start - } else { - // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) - // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected) - hunk.row_range.overlaps(selected_multi_buffer_rows) - || selected_multi_buffer_rows.end == hunk.row_range.start - } -} - pub trait CollaborationHub { fn collaborators<'a>(&self, cx: &'a AppContext) -> &'a HashMap; fn user_participant_indices<'a>( diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 397d5e46d4..669134ef10 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -12552,7 +12552,7 @@ async fn test_edits_around_expanded_insertion_hunks( executor.run_until_parked(); cx.assert_diff_hunks( r#" - - use some::mod1; + use some::mod1; - use some::mod2; - - const A: u32 = 42; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 19c1f3bf39..7f4bc3fb77 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -2509,7 +2509,6 @@ impl EditorElement { element, available_space: size(AvailableSpace::MinContent, element_size.height.into()), style: BlockStyle::Fixed, - is_zero_height: block.height() == 0, }); } for (row, block) in non_fixed_blocks { @@ -2556,7 +2555,6 @@ impl EditorElement { element, available_space: size(width.into(), element_size.height.into()), style, - is_zero_height: block.height() == 0, }); } @@ -2604,7 +2602,6 @@ impl EditorElement { element, available_space: size(width, element_size.height.into()), style, - is_zero_height: block.height() == 0, }); } } @@ -3950,23 +3947,8 @@ impl EditorElement { } fn paint_blocks(&mut self, layout: &mut EditorLayout, cx: &mut WindowContext) { - cx.paint_layer(layout.text_hitbox.bounds, |cx| { - layout.blocks.retain_mut(|block| { - if !block.is_zero_height { - block.element.paint(cx); - } - - block.is_zero_height - }); - }); - - // Paint all the zero-height blocks in a higher layer (if there were any remaining to paint). - if !layout.blocks.is_empty() { - cx.paint_layer(layout.text_hitbox.bounds, |cx| { - for mut block in layout.blocks.drain(..) { - block.element.paint(cx); - } - }); + for mut block in layout.blocks.drain(..) { + block.element.paint(cx); } } @@ -6029,7 +6011,6 @@ struct BlockLayout { element: AnyElement, available_space: Size, style: BlockStyle, - is_zero_height: bool, } fn layout_line( diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index 5c6d5ff7a3..27bb8ac557 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -1,8 +1,6 @@ use collections::{hash_map, HashMap, HashSet}; use git::diff::DiffHunkStatus; -use gpui::{ - AppContext, ClickEvent, CursorStyle, FocusableView, Hsla, Model, MouseButton, Task, View, -}; +use gpui::{Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Task, View}; use language::{Buffer, BufferId, Point}; use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferDiffHunk, MultiBufferRow, @@ -11,18 +9,17 @@ use multi_buffer::{ use std::{ops::Range, sync::Arc}; use text::OffsetRangeExt; use ui::{ - prelude::*, ActiveTheme, IconButtonShape, InteractiveElement, IntoElement, KeyBinding, - ParentElement, Styled, TintColor, Tooltip, ViewContext, VisualContext, + prelude::*, ActiveTheme, ContextMenu, IconButtonShape, InteractiveElement, IntoElement, + ParentElement, PopoverMenu, Styled, Tooltip, ViewContext, VisualContext, }; use util::RangeExt; use workspace::Item; use crate::{ - editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections, is_hunk_selected, - ApplyAllDiffHunks, ApplySelectedDiffHunks, BlockPlacement, BlockProperties, BlockStyle, - CustomBlockId, DiffRowHighlight, DisplayRow, DisplaySnapshot, Editor, EditorElement, - ExpandAllHunkDiffs, GoToHunk, GoToPrevHunk, RevertSelectedHunks, ToDisplayPoint, - ToggleHunkDiff, + editor_settings::CurrentLineHighlight, hunk_status, hunks_for_selections, ApplyAllDiffHunks, + ApplyDiffHunk, BlockPlacement, BlockProperties, BlockStyle, CustomBlockId, DiffRowHighlight, + DisplayRow, DisplaySnapshot, Editor, EditorElement, ExpandAllHunkDiffs, GoToHunk, GoToPrevHunk, + RevertFile, RevertSelectedHunks, ToDisplayPoint, ToggleHunkDiff, }; #[derive(Debug, Clone)] @@ -60,6 +57,7 @@ pub enum DisplayDiffHunk { Folded { display_row: DisplayRow, }, + Unfolded { diff_base_byte_range: Range, display_row_range: Range, @@ -373,35 +371,26 @@ impl Editor { pub(crate) fn apply_selected_diff_hunks( &mut self, - _: &ApplySelectedDiffHunks, + _: &ApplyDiffHunk, cx: &mut ViewContext, ) { let snapshot = self.buffer.read(cx).snapshot(cx); let hunks = hunks_for_selections(&snapshot, &self.selections.disjoint_anchors()); - + let mut ranges_by_buffer = HashMap::default(); self.transact(cx, |editor, cx| { - if hunks.is_empty() { - // If there are no selected hunks, e.g. because we're using the keybinding with nothing selected, apply the first hunk. - if let Some(first_hunk) = editor.expanded_hunks.hunks.first() { - editor.apply_diff_hunks_in_range(first_hunk.hunk_range.clone(), cx); + for hunk in hunks { + if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { + ranges_by_buffer + .entry(buffer.clone()) + .or_insert_with(Vec::new) + .push(hunk.buffer_range.to_offset(buffer.read(cx))); } - } else { - let mut ranges_by_buffer = HashMap::default(); + } - for hunk in hunks { - if let Some(buffer) = editor.buffer.read(cx).buffer(hunk.buffer_id) { - ranges_by_buffer - .entry(buffer.clone()) - .or_insert_with(Vec::new) - .push(hunk.buffer_range.to_offset(buffer.read(cx))); - } - } - - for (buffer, ranges) in ranges_by_buffer { - buffer.update(cx, |buffer, cx| { - buffer.merge_into_base(ranges, cx); - }); - } + for (buffer, ranges) in ranges_by_buffer { + buffer.update(cx, |buffer, cx| { + buffer.merge_into_base(ranges, cx); + }); } }); @@ -423,238 +412,246 @@ impl Editor { buffer.read(cx).diff_base_buffer().is_some() }); + let border_color = cx.theme().colors().border_variant; + let bg_color = cx.theme().colors().editor_background; + let gutter_color = match hunk.status { + DiffHunkStatus::Added => cx.theme().status().created, + DiffHunkStatus::Modified => cx.theme().status().modified, + DiffHunkStatus::Removed => cx.theme().status().deleted, + }; + BlockProperties { placement: BlockPlacement::Above(hunk.multi_buffer_range.start), - height: 0, + height: 1, style: BlockStyle::Sticky, - priority: 1, + priority: 0, render: Arc::new({ let editor = cx.view().clone(); let hunk = hunk.clone(); move |cx| { - let is_hunk_selected = editor.update(&mut **cx, |editor, cx| { - let snapshot = editor.buffer.read(cx).snapshot(cx); - let selections = &editor.selections.all::(cx); - - if editor.focus_handle(cx).is_focused(cx) && !selections.is_empty() { - if let Some(hunk) = to_diff_hunk(&hunk, &snapshot) { - is_hunk_selected(&hunk, selections) - } else { - false - } - } else { - // If we have no cursor, or aren't focused, then default to the first hunk - // because that's what the keyboard shortcuts do. - editor - .expanded_hunks - .hunks - .first() - .map(|first_hunk| first_hunk.hunk_range == hunk.multi_buffer_range) - .unwrap_or(false) - } - }); - - let focus_handle = editor.focus_handle(cx); - - let handle_discard_click = { - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event: &ClickEvent, cx: &mut WindowContext| { - let multi_buffer = editor.read(cx).buffer().clone(); - let multi_buffer_snapshot = multi_buffer.read(cx).snapshot(cx); - let mut revert_changes = HashMap::default(); - if let Some(hunk) = - crate::hunk_diff::to_diff_hunk(&hunk, &multi_buffer_snapshot) - { - Editor::prepare_revert_change( - &mut revert_changes, - &multi_buffer, - &hunk, - cx, - ); - } - if !revert_changes.is_empty() { - editor.update(cx, |editor, cx| editor.revert(revert_changes, cx)); - } - } - }; - - let handle_apply_click = { - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event: &ClickEvent, cx: &mut WindowContext| { - editor.update(cx, |editor, cx| { - editor - .apply_diff_hunks_in_range(hunk.multi_buffer_range.clone(), cx); - }); - } - }; - - let discard_key_binding = - KeyBinding::for_action_in(&RevertSelectedHunks, &focus_handle, cx); - - let discard_tooltip = { - let focus_handle = editor.focus_handle(cx); - move |cx: &mut WindowContext| { - Tooltip::for_action_in( - "Discard Hunk", - &RevertSelectedHunks, - &focus_handle, - cx, - ) - } - }; + let hunk_controls_menu_handle = + editor.read(cx).hunk_controls_menu_handle.clone(); h_flex() .id(cx.block_id) - .pr_5() + .block_mouse_down() + .h(cx.line_height()) .w_full() - .justify_end() + .border_t_1() + .border_color(border_color) + .bg(bg_color) + .child( + div() + .id("gutter-strip") + .w(EditorElement::diff_hunk_strip_width(cx.line_height())) + .h_full() + .bg(gutter_color) + .cursor(CursorStyle::PointingHand) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + editor.update(cx, |editor, cx| { + editor.toggle_hovered_hunk(&hunk, cx); + }); + } + }), + ) .child( h_flex() - .h(cx.line_height()) - .gap_1() - .px_1() - .pb_1() - .border_x_1() - .border_b_1() - .border_color(cx.theme().colors().border_variant) - .rounded_b_lg() - .bg(cx.theme().colors().editor_background) - .shadow(smallvec::smallvec![gpui::BoxShadow { - color: gpui::hsla(0.0, 0.0, 0.0, 0.1), - blur_radius: px(1.0), - spread_radius: px(1.0), - offset: gpui::point(px(0.), px(1.0)), - }]) - .when(!is_branch_buffer, |row| { - row.child( - IconButton::new("next-hunk", IconName::ArrowDown) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |cx| { - Tooltip::for_action_in( - "Next Hunk", - &GoToHunk, - &focus_handle.clone(), - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - editor.update(cx, |editor, cx| { - editor.go_to_subsequent_hunk( - hunk.multi_buffer_range.end, - cx, - ); - }); - } - }), - ) - .child( - IconButton::new("prev-hunk", IconName::ArrowUp) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip({ - let focus_handle = editor.focus_handle(cx); - move |cx| { - Tooltip::for_action_in( - "Previous Hunk", - &GoToPrevHunk, - &focus_handle, - cx, - ) - } - }) - .on_click({ - let editor = editor.clone(); - let hunk = hunk.clone(); - move |_event, cx| { - editor.update(cx, |editor, cx| { - editor.go_to_preceding_hunk( - hunk.multi_buffer_range.start, - cx, - ); - }); - } - }), - ) - }) - .child(if is_branch_buffer { - if is_hunk_selected { - Button::new("discard", "Discard") - .style(ButtonStyle::Tinted(TintColor::Negative)) - .label_size(LabelSize::Small) - .key_binding(discard_key_binding) - .on_click(handle_discard_click.clone()) - .into_any_element() - } else { - IconButton::new("discard", IconName::Close) - .style(ButtonStyle::Tinted(TintColor::Negative)) - .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) - .tooltip(discard_tooltip.clone()) - .on_click(handle_discard_click.clone()) - .into_any_element() - } - } else { - if is_hunk_selected { - Button::new("undo", "Undo") - .style(ButtonStyle::Tinted(TintColor::Negative)) - .label_size(LabelSize::Small) - .key_binding(discard_key_binding) - .on_click(handle_discard_click.clone()) - .into_any_element() - } else { - IconButton::new("undo", IconName::Undo) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small) - .tooltip(discard_tooltip.clone()) - .on_click(handle_discard_click.clone()) - .into_any_element() - } - }) - .when(is_branch_buffer, |this| { - this.child({ - let button = Button::new("apply", "Apply") - .style(ButtonStyle::Tinted(TintColor::Positive)) - .label_size(LabelSize::Small) - .key_binding(KeyBinding::for_action_in( - &ApplySelectedDiffHunks, - &focus_handle, - cx, - )) - .on_click(handle_apply_click.clone()) - .into_any_element(); - if is_hunk_selected { - button - } else { - IconButton::new("apply", IconName::Check) - .style(ButtonStyle::Tinted(TintColor::Positive)) + .px_6() + .size_full() + .justify_end() + .child( + h_flex() + .gap_1() + .when(!is_branch_buffer, |row| { + row.child( + IconButton::new("next-hunk", IconName::ArrowDown) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |cx| { + Tooltip::for_action_in( + "Next Hunk", + &GoToHunk, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + editor.update(cx, |editor, cx| { + editor.go_to_subsequent_hunk( + hunk.multi_buffer_range.end, + cx, + ); + }); + } + }), + ) + .child( + IconButton::new("prev-hunk", IconName::ArrowUp) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = editor.focus_handle(cx); + move |cx| { + Tooltip::for_action_in( + "Previous Hunk", + &GoToPrevHunk, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + editor.update(cx, |editor, cx| { + editor.go_to_preceding_hunk( + hunk.multi_buffer_range.start, + cx, + ); + }); + } + }), + ) + }) + .child( + IconButton::new("discard", IconName::Undo) .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) + .icon_size(IconSize::Small) .tooltip({ let focus_handle = editor.focus_handle(cx); move |cx| { Tooltip::for_action_in( - "Apply Hunk", - &ApplySelectedDiffHunks, + "Discard Hunk", + &RevertSelectedHunks, &focus_handle, cx, ) } }) - .on_click(handle_apply_click.clone()) - .into_any_element() - } - }) - }) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + let multi_buffer = + editor.read(cx).buffer().clone(); + let multi_buffer_snapshot = + multi_buffer.read(cx).snapshot(cx); + let mut revert_changes = HashMap::default(); + if let Some(hunk) = + crate::hunk_diff::to_diff_hunk( + &hunk, + &multi_buffer_snapshot, + ) + { + Editor::prepare_revert_change( + &mut revert_changes, + &multi_buffer, + &hunk, + cx, + ); + } + if !revert_changes.is_empty() { + editor.update(cx, |editor, cx| { + editor.revert(revert_changes, cx) + }); + } + } + }), + ) + .map(|this| { + if is_branch_buffer { + this.child( + IconButton::new("apply", IconName::Check) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .tooltip({ + let focus_handle = + editor.focus_handle(cx); + move |cx| { + Tooltip::for_action_in( + "Apply Hunk", + &ApplyDiffHunk, + &focus_handle, + cx, + ) + } + }) + .on_click({ + let editor = editor.clone(); + let hunk = hunk.clone(); + move |_event, cx| { + editor.update(cx, |editor, cx| { + editor + .apply_diff_hunks_in_range( + hunk.multi_buffer_range + .clone(), + cx, + ); + }); + } + }), + ) + } else { + this.child({ + let focus = editor.focus_handle(cx); + PopoverMenu::new("hunk-controls-dropdown") + .trigger( + IconButton::new( + "toggle_editor_selections_icon", + IconName::EllipsisVertical, + ) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small) + .style(ButtonStyle::Subtle) + .selected( + hunk_controls_menu_handle + .is_deployed(), + ) + .when( + !hunk_controls_menu_handle + .is_deployed(), + |this| { + this.tooltip(|cx| { + Tooltip::text( + "Hunk Controls", + cx, + ) + }) + }, + ), + ) + .anchor(AnchorCorner::TopRight) + .with_handle(hunk_controls_menu_handle) + .menu(move |cx| { + let focus = focus.clone(); + let menu = ContextMenu::build( + cx, + move |menu, _| { + menu.context(focus.clone()) + .action( + "Discard All Hunks", + RevertFile + .boxed_clone(), + ) + }, + ); + Some(menu) + }) + }) + } + }), + ) .when(!is_branch_buffer, |div| { div.child( IconButton::new("collapse", IconName::Close) @@ -710,7 +707,7 @@ impl Editor { placement: BlockPlacement::Above(hunk.multi_buffer_range.start), height, style: BlockStyle::Flex, - priority: 1, + priority: 0, render: Arc::new(move |cx| { let width = EditorElement::diff_hunk_strip_width(cx.line_height()); let gutter_dimensions = editor.read(cx.context).gutter_dimensions; diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs index 3a9509eb39..ac97fe18da 100644 --- a/crates/editor/src/proposed_changes_editor.rs +++ b/crates/editor/src/proposed_changes_editor.rs @@ -5,11 +5,10 @@ use gpui::{AppContext, EventEmitter, FocusableView, Model, Render, Subscription, use language::{Buffer, BufferEvent, Capability}; use multi_buffer::{ExcerptRange, MultiBuffer}; use project::Project; -use settings::Settings; use smol::stream::StreamExt; use std::{any::TypeId, ops::Range, rc::Rc, time::Duration}; use text::ToOffset; -use ui::{prelude::*, KeyBinding}; +use ui::{prelude::*, ButtonLike, KeyBinding}; use workspace::{ searchable::SearchableItemHandle, Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace, @@ -35,11 +34,7 @@ struct BufferEntry { _subscription: Subscription, } -pub struct ProposedChangesToolbarControls { - current_editor: Option>, -} - -pub struct ProposedChangesToolbar { +pub struct ProposedChangesEditorToolbar { current_editor: Option>, } @@ -233,10 +228,6 @@ impl ProposedChangesEditor { _ => (), } } - - fn all_changes_accepted(&self) -> bool { - false // In the future, we plan to compute this based on the current state of patches. - } } impl Render for ProposedChangesEditor { @@ -260,11 +251,7 @@ impl Item for ProposedChangesEditor { type Event = EditorEvent; fn tab_icon(&self, _cx: &ui::WindowContext) -> Option { - if self.all_changes_accepted() { - Some(Icon::new(IconName::Check).color(Color::Success)) - } else { - Some(Icon::new(IconName::ZedAssistant)) - } + Some(Icon::new(IconName::Diff)) } fn tab_content_text(&self, _cx: &WindowContext) -> Option { @@ -330,7 +317,7 @@ impl Item for ProposedChangesEditor { } } -impl ProposedChangesToolbarControls { +impl ProposedChangesEditorToolbar { pub fn new() -> Self { Self { current_editor: None, @@ -346,97 +333,28 @@ impl ProposedChangesToolbarControls { } } -impl Render for ProposedChangesToolbarControls { +impl Render for ProposedChangesEditorToolbar { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - if let Some(editor) = &self.current_editor { - let focus_handle = editor.focus_handle(cx); - let action = &ApplyAllDiffHunks; - let keybinding = KeyBinding::for_action_in(action, &focus_handle, cx); + let button_like = ButtonLike::new("apply-changes").child(Label::new("Apply All")); - let editor = editor.read(cx); + match &self.current_editor { + Some(editor) => { + let focus_handle = editor.focus_handle(cx); + let keybinding = KeyBinding::for_action_in(&ApplyAllDiffHunks, &focus_handle, cx) + .map(|binding| binding.into_any_element()); - let apply_all_button = if editor.all_changes_accepted() { - None - } else { - Some( - Button::new("apply-changes", "Apply All") - .style(ButtonStyle::Filled) - .key_binding(keybinding) - .on_click(move |_event, cx| focus_handle.dispatch_action(action, cx)), - ) - }; - - h_flex() - .gap_1() - .children([apply_all_button].into_iter().flatten()) - .into_any_element() - } else { - gpui::Empty.into_any_element() + button_like.children(keybinding).on_click({ + move |_event, cx| focus_handle.dispatch_action(&ApplyAllDiffHunks, cx) + }) + } + None => button_like.disabled(true), } } } -impl EventEmitter for ProposedChangesToolbarControls {} +impl EventEmitter for ProposedChangesEditorToolbar {} -impl ToolbarItemView for ProposedChangesToolbarControls { - fn set_active_pane_item( - &mut self, - active_pane_item: Option<&dyn workspace::ItemHandle>, - _cx: &mut ViewContext, - ) -> workspace::ToolbarItemLocation { - self.current_editor = - active_pane_item.and_then(|item| item.downcast::()); - self.get_toolbar_item_location() - } -} - -impl ProposedChangesToolbar { - pub fn new() -> Self { - Self { - current_editor: None, - } - } - - fn get_toolbar_item_location(&self) -> ToolbarItemLocation { - if self.current_editor.is_some() { - ToolbarItemLocation::PrimaryLeft - } else { - ToolbarItemLocation::Hidden - } - } -} - -impl Render for ProposedChangesToolbar { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - if let Some(editor) = &self.current_editor { - let editor = editor.read(cx); - let all_changes_accepted = editor.all_changes_accepted(); - let icon = if all_changes_accepted { - Icon::new(IconName::Check).color(Color::Success) - } else { - Icon::new(IconName::ZedAssistant) - }; - - h_flex() - .gap_2p5() - .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) - .child(icon.size(IconSize::Small)) - .child( - Label::new(editor.title.clone()) - .color(Color::Muted) - .single_line() - .strikethrough(all_changes_accepted), - ) - .into_any_element() - } else { - gpui::Empty.into_any_element() - } - } -} - -impl EventEmitter for ProposedChangesToolbar {} - -impl ToolbarItemView for ProposedChangesToolbar { +impl ToolbarItemView for ProposedChangesEditorToolbar { fn set_active_pane_item( &mut self, active_pane_item: Option<&dyn workspace::ItemHandle>, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index f5c0259b1a..4e3d05d2fb 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -17,8 +17,8 @@ use breadcrumbs::Breadcrumbs; use client::{zed_urls, ZED_URL_SCHEME}; use collections::VecDeque; use command_palette_hooks::CommandPaletteFilter; +use editor::ProposedChangesEditorToolbar; use editor::{scroll::Autoscroll, Editor, MultiBuffer}; -use editor::{ProposedChangesToolbar, ProposedChangesToolbarControls}; use feature_flags::FeatureFlagAppExt; use futures::{channel::mpsc, select_biased, StreamExt}; use gpui::{ @@ -644,10 +644,8 @@ fn initialize_pane(workspace: &Workspace, pane: &View, cx: &mut ViewContex let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); toolbar.add_item(buffer_search_bar.clone(), cx); - let proposed_changes_bar = cx.new_view(|_| ProposedChangesToolbar::new()); - toolbar.add_item(proposed_changes_bar, cx); - let proposed_changes_controls = cx.new_view(|_| ProposedChangesToolbarControls::new()); - toolbar.add_item(proposed_changes_controls, cx); + let proposed_change_bar = cx.new_view(|_| ProposedChangesEditorToolbar::new()); + toolbar.add_item(proposed_change_bar, cx); let quick_action_bar = cx.new_view(|cx| QuickActionBar::new(buffer_search_bar, workspace, cx)); toolbar.add_item(quick_action_bar, cx); From 597e5f8304ee3ffc74c7a312edf70108e93d59e2 Mon Sep 17 00:00:00 2001 From: vultix Date: Tue, 26 Nov 2024 13:54:36 -0700 Subject: [PATCH 030/215] vim: Add indent text object (#21121) Added support for the popular vim [indent-text-object](https://github.com/michaeljsmith/vim-indent-object). This is especially useful in indentation-sensitive languages like python. Release Notes: - vim: Added `vii`, `vai` and `vaI` for selecting [indent-text-object](https://github.com/michaeljsmith/vim-indent-object)s. --- assets/keymaps/vim.json | 4 +- crates/vim/src/object.rs | 169 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 168 insertions(+), 5 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 1be3e8c9c1..d0c7ae192b 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -381,7 +381,9 @@ "shift-b": "vim::CurlyBrackets", "<": "vim::AngleBrackets", ">": "vim::AngleBrackets", - "a": "vim::Argument" + "a": "vim::Argument", + "i": "vim::IndentObj", + "shift-i": ["vim::IndentObj", { "includeBelow": true }] } }, { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index f97312e7f8..7ed97358ff 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -28,6 +28,7 @@ pub enum Object { CurlyBrackets, AngleBrackets, Argument, + IndentObj { include_below: bool }, Tag, } @@ -37,8 +38,14 @@ struct Word { #[serde(default)] ignore_punctuation: bool, } +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct IndentObj { + #[serde(default)] + include_below: bool, +} -impl_actions!(vim, [Word]); +impl_actions!(vim, [Word, IndentObj]); actions!( vim, @@ -100,6 +107,13 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Argument, cx| { vim.object(Object::Argument, cx) }); + Vim::action( + editor, + cx, + |vim, &IndentObj { include_below }: &IndentObj, cx| { + vim.object(Object::IndentObj { include_below }, cx) + }, + ); } impl Vim { @@ -129,13 +143,18 @@ impl Object { | Object::AngleBrackets | Object::CurlyBrackets | Object::SquareBrackets - | Object::Argument => true, + | Object::Argument + | Object::IndentObj { .. } => true, } } pub fn always_expands_both_ways(self) -> bool { match self { - Object::Word { .. } | Object::Sentence | Object::Paragraph | Object::Argument => false, + Object::Word { .. } + | Object::Sentence + | Object::Paragraph + | Object::Argument + | Object::IndentObj { .. } => false, Object::Quotes | Object::BackQuotes | Object::DoubleQuotes @@ -167,7 +186,8 @@ impl Object { | Object::AngleBrackets | Object::VerticalBars | Object::Tag - | Object::Argument => Mode::Visual, + | Object::Argument + | Object::IndentObj { .. } => Mode::Visual, Object::Paragraph => Mode::VisualLine, } } @@ -219,6 +239,7 @@ impl Object { surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>') } Object::Argument => argument(map, relative_to, around), + Object::IndentObj { include_below } => indent(map, relative_to, around, include_below), } } @@ -569,6 +590,58 @@ fn argument( } } +fn indent( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + around: bool, + include_below: bool, +) -> Option> { + let point = relative_to.to_point(map); + let row = point.row; + + let desired_indent = map.line_indent_for_buffer_row(MultiBufferRow(row)); + + // Loop backwards until we find a non-blank line with less indent + let mut start_row = row; + for prev_row in (0..row).rev() { + let indent = map.line_indent_for_buffer_row(MultiBufferRow(prev_row)); + if indent.is_line_empty() { + continue; + } + if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs { + if around { + // When around is true, include the first line with less indent + start_row = prev_row; + } + break; + } + start_row = prev_row; + } + + // Loop forwards until we find a non-blank line with less indent + let mut end_row = row; + let max_rows = map.max_buffer_row().0; + for next_row in (row + 1)..=max_rows { + let indent = map.line_indent_for_buffer_row(MultiBufferRow(next_row)); + if indent.is_line_empty() { + continue; + } + if indent.spaces < desired_indent.spaces || indent.tabs < desired_indent.tabs { + if around && include_below { + // When around is true and including below, include this line + end_row = next_row; + } + break; + } + end_row = next_row; + } + + let end_len = map.buffer_snapshot.line_len(MultiBufferRow(end_row)); + let start = map.point_to_display_point(Point::new(start_row, 0), Bias::Right); + let end = map.point_to_display_point(Point::new(end_row, end_len), Bias::Left); + Some(start..end) +} + fn sentence( map: &DisplaySnapshot, relative_to: DisplayPoint, @@ -1458,6 +1531,94 @@ mod test { cx.assert_state("let a = [«test::call(first_arg)ˇ»]", Mode::Visual); } + #[gpui::test] + async fn test_indent_object(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Base use case + cx.set_state( + indoc! {" + fn boop() { + // Comment + baz();ˇ + + loop { + bar(1); + bar(2); + } + + result + } + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v i i"); + cx.assert_state( + indoc! {" + fn boop() { + « // Comment + baz(); + + loop { + bar(1); + bar(2); + } + + resultˇ» + } + "}, + Mode::Visual, + ); + + // Around indent (include line above) + cx.set_state( + indoc! {" + const ABOVE: str = true; + fn boop() { + + hello(); + worˇld() + } + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("v a i"); + cx.assert_state( + indoc! {" + const ABOVE: str = true; + «fn boop() { + + hello(); + world()ˇ» + } + "}, + Mode::Visual, + ); + + // Around indent (include line above & below) + cx.set_state( + indoc! {" + const ABOVE: str = true; + fn boop() { + hellˇo(); + world() + + } + const BELOW: str = true; + "}, + Mode::Normal, + ); + cx.simulate_keystrokes("c a shift-i"); + cx.assert_state( + indoc! {" + const ABOVE: str = true; + ˇ + const BELOW: str = true; + "}, + Mode::Insert, + ); + } + #[gpui::test] async fn test_delete_surrounding_character_objects(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new(cx).await; From 57e4540759734d5be46a075141b69ed75a3b946e Mon Sep 17 00:00:00 2001 From: Helge Mahrt <5497139+helgemahrt@users.noreply.github.com> Date: Tue, 26 Nov 2024 23:08:54 +0100 Subject: [PATCH 031/215] vim: Add "unmatched" motions `]}`, `])`, `[{` and `[(` (#21098) Closes #20791 Release Notes: - Added vim ["unmatched" motions](https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1238-L1255) `]}`, `])`, `[{` and `[(` --------- Co-authored-by: Conrad Irwin --- assets/keymaps/vim.json | 4 + crates/vim/src/motion.rs | 234 +++++++++++++++++- .../test_data/test_unmatched_backward.json | 24 ++ .../vim/test_data/test_unmatched_forward.json | 28 +++ 4 files changed, 289 insertions(+), 1 deletion(-) create mode 100644 crates/vim/test_data/test_unmatched_backward.json create mode 100644 crates/vim/test_data/test_unmatched_forward.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index d0c7ae192b..67db22b5e2 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -55,6 +55,10 @@ "n": "vim::MoveToNextMatch", "shift-n": "vim::MoveToPrevMatch", "%": "vim::Matching", + "] }": ["vim::UnmatchedForward", { "char": "}" } ], + "[ {": ["vim::UnmatchedBackward", { "char": "{" } ], + "] )": ["vim::UnmatchedForward", { "char": ")" } ], + "[ (": ["vim::UnmatchedBackward", { "char": "(" } ], "f": ["vim::PushOperator", { "FindForward": { "before": false } }], "t": ["vim::PushOperator", { "FindForward": { "before": true } }], "shift-f": ["vim::PushOperator", { "FindBackward": { "after": false } }], diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 9f7a30afe9..7c628626cb 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -72,6 +72,12 @@ pub enum Motion { StartOfDocument, EndOfDocument, Matching, + UnmatchedForward { + char: char, + }, + UnmatchedBackward { + char: char, + }, FindForward { before: bool, char: char, @@ -203,6 +209,20 @@ pub struct StartOfLine { pub(crate) display_lines: bool, } +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct UnmatchedForward { + #[serde(default)] + char: char, +} + +#[derive(Clone, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +struct UnmatchedBackward { + #[serde(default)] + char: char, +} + impl_actions!( vim, [ @@ -219,6 +239,8 @@ impl_actions!( NextSubwordEnd, PreviousSubwordStart, PreviousSubwordEnd, + UnmatchedForward, + UnmatchedBackward ] ); @@ -326,7 +348,20 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Matching, cx| { vim.motion(Motion::Matching, cx) }); - + Vim::action( + editor, + cx, + |vim, &UnmatchedForward { char }: &UnmatchedForward, cx| { + vim.motion(Motion::UnmatchedForward { char }, cx) + }, + ); + Vim::action( + editor, + cx, + |vim, &UnmatchedBackward { char }: &UnmatchedBackward, cx| { + vim.motion(Motion::UnmatchedBackward { char }, cx) + }, + ); Vim::action( editor, cx, @@ -504,6 +539,8 @@ impl Motion { | Jump { line: true, .. } => true, EndOfLine { .. } | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | Left | Backspace @@ -537,6 +574,8 @@ impl Motion { | Up { .. } | EndOfLine { .. } | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | RepeatFind { .. } | Left @@ -583,6 +622,8 @@ impl Motion { | EndOfLine { .. } | EndOfLineDownward | Matching + | UnmatchedForward { .. } + | UnmatchedBackward { .. } | FindForward { .. } | WindowTop | WindowMiddle @@ -707,6 +748,14 @@ impl Motion { SelectionGoal::None, ), Matching => (matching(map, point), SelectionGoal::None), + UnmatchedForward { char } => ( + unmatched_forward(map, point, *char, times), + SelectionGoal::None, + ), + UnmatchedBackward { char } => ( + unmatched_backward(map, point, *char, times), + SelectionGoal::None, + ), // t f FindForward { before, @@ -1792,6 +1841,92 @@ fn matching(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint } } +fn unmatched_forward( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + char: char, + times: usize, +) -> DisplayPoint { + for _ in 0..times { + // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1245 + let point = display_point.to_point(map); + let offset = point.to_offset(&map.buffer_snapshot); + + let ranges = map.buffer_snapshot.enclosing_bracket_ranges(point..point); + let Some(ranges) = ranges else { break }; + let mut closest_closing_destination = None; + let mut closest_distance = usize::MAX; + + for (_, close_range) in ranges { + if close_range.start > offset { + let mut chars = map.buffer_snapshot.chars_at(close_range.start); + if Some(char) == chars.next() { + let distance = close_range.start - offset; + if distance < closest_distance { + closest_closing_destination = Some(close_range.start); + closest_distance = distance; + continue; + } + } + } + } + + let new_point = closest_closing_destination + .map(|destination| destination.to_display_point(map)) + .unwrap_or(display_point); + if new_point == display_point { + break; + } + display_point = new_point; + } + return display_point; +} + +fn unmatched_backward( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + char: char, + times: usize, +) -> DisplayPoint { + for _ in 0..times { + // https://github.com/vim/vim/blob/1d87e11a1ef201b26ed87585fba70182ad0c468a/runtime/doc/motion.txt#L1239 + let point = display_point.to_point(map); + let offset = point.to_offset(&map.buffer_snapshot); + + let ranges = map.buffer_snapshot.enclosing_bracket_ranges(point..point); + let Some(ranges) = ranges else { + break; + }; + + let mut closest_starting_destination = None; + let mut closest_distance = usize::MAX; + + for (start_range, _) in ranges { + if start_range.start < offset { + let mut chars = map.buffer_snapshot.chars_at(start_range.start); + if Some(char) == chars.next() { + let distance = offset - start_range.start; + if distance < closest_distance { + closest_starting_destination = Some(start_range.start); + closest_distance = distance; + continue; + } + } + } + } + + let new_point = closest_starting_destination + .map(|destination| destination.to_display_point(map)) + .unwrap_or(display_point); + if new_point == display_point { + break; + } else { + display_point = new_point; + } + } + display_point +} + fn find_forward( map: &DisplaySnapshot, from: DisplayPoint, @@ -2118,6 +2253,103 @@ mod test { cx.shared_state().await.assert_eq("func boop(ˇ) {\n}"); } + #[gpui::test] + async fn test_unmatched_forward(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // test it works with curly braces + cx.set_shared_state(indoc! {r"func (a string) { + do(something(with.anˇd_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + do(something(with.and_arrays[0, 2])) + ˇ}"}); + + // test it works with brackets + cx.set_shared_state(indoc! {r"func (a string) { + do(somethiˇng(with.and_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + do(something(with.and_arrays[0, 2])ˇ) + }"}); + + cx.set_shared_state(indoc! {r"func (a string) { a((b, cˇ))}"}) + .await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { a((b, c)ˇ)}"}); + + // test it works on immediate nesting + cx.set_shared_state("{ˇ {}{}}").await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state().await.assert_eq("{ {}{}ˇ}"); + cx.set_shared_state("(ˇ ()())").await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state().await.assert_eq("( ()()ˇ)"); + + // test it works on immediate nesting inside braces + cx.set_shared_state("{\n ˇ {()}\n}").await; + cx.simulate_shared_keystrokes("] }").await; + cx.shared_state().await.assert_eq("{\n {()}\nˇ}"); + cx.set_shared_state("(\n ˇ {()}\n)").await; + cx.simulate_shared_keystrokes("] )").await; + cx.shared_state().await.assert_eq("(\n {()}\nˇ)"); + } + + #[gpui::test] + async fn test_unmatched_backward(cx: &mut gpui::TestAppContext) { + let mut cx = NeovimBackedTestContext::new(cx).await; + + // test it works with curly braces + cx.set_shared_state(indoc! {r"func (a string) { + do(something(with.anˇd_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) ˇ{ + do(something(with.and_arrays[0, 2])) + }"}); + + // test it works with brackets + cx.set_shared_state(indoc! {r"func (a string) { + do(somethiˇng(with.and_arrays[0, 2])) + }"}) + .await; + cx.simulate_shared_keystrokes("[ (").await; + cx.shared_state() + .await + .assert_eq(indoc! {r"func (a string) { + doˇ(something(with.and_arrays[0, 2])) + }"}); + + // test it works on immediate nesting + cx.set_shared_state("{{}{} ˇ }").await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state().await.assert_eq("ˇ{{}{} }"); + cx.set_shared_state("(()() ˇ )").await; + cx.simulate_shared_keystrokes("[ (").await; + cx.shared_state().await.assert_eq("ˇ(()() )"); + + // test it works on immediate nesting inside braces + cx.set_shared_state("{\n {()} ˇ\n}").await; + cx.simulate_shared_keystrokes("[ {").await; + cx.shared_state().await.assert_eq("ˇ{\n {()} \n}"); + cx.set_shared_state("(\n {()} ˇ\n)").await; + cx.simulate_shared_keystrokes("[ (").await; + cx.shared_state().await.assert_eq("ˇ(\n {()} \n)"); + } + #[gpui::test] async fn test_matching_tags(cx: &mut gpui::TestAppContext) { let mut cx = NeovimBackedTestContext::new_html(cx).await; diff --git a/crates/vim/test_data/test_unmatched_backward.json b/crates/vim/test_data/test_unmatched_backward.json new file mode 100644 index 0000000000..bb3825dcd2 --- /dev/null +++ b/crates/vim/test_data/test_unmatched_backward.json @@ -0,0 +1,24 @@ +{"Put":{"state":"func (a string) {\n do(something(with.anˇd_arrays[0, 2]))\n}"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"func (a string) ˇ{\n do(something(with.and_arrays[0, 2]))\n}","mode":"Normal"}} +{"Put":{"state":"func (a string) {\n do(somethiˇng(with.and_arrays[0, 2]))\n}"}} +{"Key":"["} +{"Key":"("} +{"Get":{"state":"func (a string) {\n doˇ(something(with.and_arrays[0, 2]))\n}","mode":"Normal"}} +{"Put":{"state":"{{}{} ˇ }"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"ˇ{{}{} }","mode":"Normal"}} +{"Put":{"state":"(()() ˇ )"}} +{"Key":"["} +{"Key":"("} +{"Get":{"state":"ˇ(()() )","mode":"Normal"}} +{"Put":{"state":"{\n {()} ˇ\n}"}} +{"Key":"["} +{"Key":"{"} +{"Get":{"state":"ˇ{\n {()} \n}","mode":"Normal"}} +{"Put":{"state":"(\n {()} ˇ\n)"}} +{"Key":"["} +{"Key":"("} +{"Get":{"state":"ˇ(\n {()} \n)","mode":"Normal"}} diff --git a/crates/vim/test_data/test_unmatched_forward.json b/crates/vim/test_data/test_unmatched_forward.json new file mode 100644 index 0000000000..a6b4a38f29 --- /dev/null +++ b/crates/vim/test_data/test_unmatched_forward.json @@ -0,0 +1,28 @@ +{"Put":{"state":"func (a string) {\n do(something(with.anˇd_arrays[0, 2]))\n}"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"func (a string) {\n do(something(with.and_arrays[0, 2]))\nˇ}","mode":"Normal"}} +{"Put":{"state":"func (a string) {\n do(somethiˇng(with.and_arrays[0, 2]))\n}"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"func (a string) {\n do(something(with.and_arrays[0, 2])ˇ)\n}","mode":"Normal"}} +{"Put":{"state":"func (a string) { a((b, cˇ))}"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"func (a string) { a((b, c)ˇ)}","mode":"Normal"}} +{"Put":{"state":"{ˇ {}{}}"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"{ {}{}ˇ}","mode":"Normal"}} +{"Put":{"state":"(ˇ ()())"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"( ()()ˇ)","mode":"Normal"}} +{"Put":{"state":"{\n ˇ {()}\n}"}} +{"Key":"]"} +{"Key":"}"} +{"Get":{"state":"{\n {()}\nˇ}","mode":"Normal"}} +{"Put":{"state":"(\n ˇ {()}\n)"}} +{"Key":"]"} +{"Key":")"} +{"Get":{"state":"(\n {()}\nˇ)","mode":"Normal"}} From d75d34576a5ed80142666dddc68cbbc2652aeb61 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Wed, 27 Nov 2024 04:53:01 +0530 Subject: [PATCH 032/215] Fix file missing or duplicated when copying multiple items in project panel + Fix marked files not being deselected after selecting a directory (#20859) Closes #20858 This fix depends on the sanitization logic implemented in PR #20577. Since that branch may undergo further changes, this branch will be periodically rebased on it. Once #20577 is merged, the dependency will no longer apply. Release Notes: - Fix missing or duplicated files when copying multiple items in the project panel. - Fix marked files not being deselected after selecting a directory on primary click. - Fix "copy path" and "copy path relative" with multiple items selected in project panel. **Problem**: In this case, `dir1` is selected while `dir2`, `dir3`, and `dir1/file` are marked. Using the `marked_entries` function results in only `dir1`, which is incorrect. Currently, the `marked_entries` function is used in five actions, which all produce incorrect results: 1. Delete (via the disjoint function) 2. Copy 3. Cut 4. Copy Path 5. Copy Path Relative **Solution**: 1. `marked_entries` function should not use "When currently selected entry is not marked, it's treated as the only marked entry." logic. There is no grand scheme behind this logic as confirmed by piotr [here](https://github.com/zed-industries/zed/issues/17746#issuecomment-2464765963). 2. `copy` and `cut` actions should use the disjoint function to prevent obivous failures. 3. `copy path` and `copy path relative` should keep using *fixed* `marked_entries` as that is expected behavior for these actions. --- 1. Before/After: Partial Copy Select `dir1` and `c.txt` (in that order, reverse order works!), and copy it and paste in `dir2`. `c.txt` is not copied in `dir2`. --- 2. Before/After: Duplicate Copy Select `a.txt`, `dir1` and `c.txt` (in that order), and copy it and paste in `dir2`. `a.txt` is duplicated in `dir2`. --- 3. Before/After: Directory Selection Simply primary click on any file, now primary click on any dir. That previous file is still marked. --- 4. Before/After: Copy Path and Copy Path Relative Upon `copy path` (ctrl + alt + c): Before: Only `/home/tims/test/dir2/a.txt` was copied. After: All three paths `/home/tims/test/dir2`, `/home/tims/test/c.txt` and `/home/tims/test/dir2/a.txt` are copied. This is also how VSCode also copies path when multiple are selected. --- crates/project_panel/src/project_panel.rs | 203 +++++++++++++++++++--- 1 file changed, 181 insertions(+), 22 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c757924727..9803742966 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1185,7 +1185,7 @@ impl ProjectPanel { fn remove(&mut self, trash: bool, skip_prompt: bool, cx: &mut ViewContext<'_, ProjectPanel>) { maybe!({ - let items_to_delete = self.disjoint_entries_for_removal(cx); + let items_to_delete = self.disjoint_entries(cx); if items_to_delete.is_empty() { return None; } @@ -1546,7 +1546,7 @@ impl ProjectPanel { } fn cut(&mut self, _: &Cut, cx: &mut ViewContext) { - let entries = self.marked_entries(); + let entries = self.disjoint_entries(cx); if !entries.is_empty() { self.clipboard = Some(ClipboardEntry::Cut(entries)); cx.notify(); @@ -1554,7 +1554,7 @@ impl ProjectPanel { } fn copy(&mut self, _: &Copy, cx: &mut ViewContext) { - let entries = self.marked_entries(); + let entries = self.disjoint_entries(cx); if !entries.is_empty() { self.clipboard = Some(ClipboardEntry::Copied(entries)); cx.notify(); @@ -1928,7 +1928,7 @@ impl ProjectPanel { None } - fn disjoint_entries_for_removal(&self, cx: &AppContext) -> BTreeSet { + fn disjoint_entries(&self, cx: &AppContext) -> BTreeSet { let marked_entries = self.marked_entries(); let mut sanitized_entries = BTreeSet::new(); if marked_entries.is_empty() { @@ -1976,25 +1976,25 @@ impl ProjectPanel { sanitized_entries } - // Returns list of entries that should be affected by an operation. - // When currently selected entry is not marked, it's treated as the only marked entry. + // Returns the union of the currently selected entry and all marked entries. fn marked_entries(&self) -> BTreeSet { - let Some(mut selection) = self.selection else { - return Default::default(); - }; - if self.marked_entries.contains(&selection) { - self.marked_entries - .iter() - .copied() - .map(|mut entry| { - entry.entry_id = self.resolve_entry(entry.entry_id); - entry - }) - .collect() - } else { - selection.entry_id = self.resolve_entry(selection.entry_id); - BTreeSet::from_iter([selection]) + let mut entries = self + .marked_entries + .iter() + .map(|entry| SelectedEntry { + entry_id: self.resolve_entry(entry.entry_id), + worktree_id: entry.worktree_id, + }) + .collect::>(); + + if let Some(selection) = self.selection { + entries.insert(SelectedEntry { + entry_id: self.resolve_entry(selection.entry_id), + worktree_id: selection.worktree_id, + }); } + + entries } /// Finds the currently selected subentry for a given leaf entry id. If a given entry @@ -2915,6 +2915,7 @@ impl ProjectPanel { this.marked_entries.remove(&selection); } } else if kind.is_dir() { + this.marked_entries.clear(); this.toggle_expanded(entry_id, cx); } else { let preview_tabs_enabled = PreviewTabsSettings::get_global(cx).enabled; @@ -3051,7 +3052,8 @@ impl ProjectPanel { .single_line() .color(filename_text_color) .when( - is_active && index == active_index, + index == active_index + && (is_active || is_marked), |this| this.underline(true), ), ); @@ -5177,6 +5179,163 @@ mod tests { ); } + #[gpui::test] + async fn test_copy_paste_directory_with_sibling_file(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/test", + json!({ + "dir1": { + "a.txt": "", + "b.txt": "", + }, + "dir2": {}, + "c.txt": "", + "d.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "test/dir1", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "test/dir1", cx); + select_path_with_mark(&panel, "test/c.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " > dir2", + " c.txt <== selected <== marked", + " d.txt", + ], + "Initial state before copying dir1 and c.txt" + ); + + panel.update(cx, |panel, cx| { + panel.copy(&Default::default(), cx); + }); + select_path(&panel, "test/dir2", cx); + panel.update(cx, |panel, cx| { + panel.paste(&Default::default(), cx); + }); + cx.executor().run_until_parked(); + + toggle_expand_dir(&panel, "test/dir2/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt", + " b.txt", + " v dir2", + " v dir1 <== selected", + " a.txt", + " b.txt", + " c.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 as well as c.txt into dir2" + ); + } + + #[gpui::test] + async fn test_copy_paste_nested_and_root_entries(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/test", + json!({ + "dir1": { + "a.txt": "", + "b.txt": "", + }, + "dir2": {}, + "c.txt": "", + "d.txt": "", + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/test".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + toggle_expand_dir(&panel, "test/dir1", cx); + + cx.simulate_modifiers_change(gpui::Modifiers { + control: true, + ..Default::default() + }); + + select_path_with_mark(&panel, "test/dir1/a.txt", cx); + select_path_with_mark(&panel, "test/dir1", cx); + select_path_with_mark(&panel, "test/c.txt", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..15, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt <== marked", + " b.txt", + " > dir2", + " c.txt <== selected <== marked", + " d.txt", + ], + "Initial state before copying a.txt, dir1 and c.txt" + ); + + panel.update(cx, |panel, cx| { + panel.copy(&Default::default(), cx); + }); + select_path(&panel, "test/dir2", cx); + panel.update(cx, |panel, cx| { + panel.paste(&Default::default(), cx); + }); + cx.executor().run_until_parked(); + + toggle_expand_dir(&panel, "test/dir2/dir1", cx); + + assert_eq!( + visible_entries_as_strings(&panel, 0..20, cx), + &[ + "v test", + " v dir1 <== marked", + " a.txt <== marked", + " b.txt", + " v dir2", + " v dir1 <== selected", + " a.txt", + " b.txt", + " c.txt", + " c.txt <== marked", + " d.txt", + ], + "Should copy dir1 and c.txt into dir2. a.txt is already present in copied dir1." + ); + } + #[gpui::test] async fn test_remove_opened_file(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); From f702575255f54d7abe7b41a73ad0ac9d06a9c3bd Mon Sep 17 00:00:00 2001 From: AidanV <84053180+AidanV@users.noreply.github.com> Date: Tue, 26 Nov 2024 16:24:29 -0800 Subject: [PATCH 033/215] Add support for resizing panes using vim motions (#21038) Closes #8628 Release Notes: - Added support for resizing the current pane using vim keybinds with the intention to follow the functionality of vim - "ctrl-w +" to make a pane taller - "ctrl-w -" to make the pane shorter - "ctrl-w >" to make a pane wider - "ctrl-w <" to make the pane narrower - Changed vim pre_count and post_count to globals to allow for other crates to use the vim count. In this case, it allows for resizing by more than one unit. For example, "10 ctrl-w -" will decrease the height of the pane 10 times more than "ctrl-w -" - This pr does **not** add keybinds for making all panes in an axis equal size and does **not** add support for resizing docks. This is mentioned because these could be implied by the original issue --------- Co-authored-by: Conrad Irwin --- Cargo.lock | 1 + assets/keymaps/vim.json | 4 + crates/vim/Cargo.toml | 1 + crates/vim/src/change_list.rs | 2 +- crates/vim/src/command.rs | 2 +- crates/vim/src/indent.rs | 4 +- crates/vim/src/insert.rs | 2 +- crates/vim/src/mode_indicator.rs | 14 ++- crates/vim/src/motion.rs | 4 +- crates/vim/src/normal.rs | 18 ++-- crates/vim/src/normal/case.rs | 2 +- crates/vim/src/normal/increment.rs | 4 +- crates/vim/src/normal/paste.rs | 2 +- crates/vim/src/normal/repeat.rs | 4 +- crates/vim/src/normal/scroll.rs | 2 +- crates/vim/src/normal/search.rs | 6 +- crates/vim/src/normal/substitute.rs | 4 +- crates/vim/src/replace.rs | 2 +- crates/vim/src/rewrap.rs | 2 +- crates/vim/src/state.rs | 5 + crates/vim/src/surrounds.rs | 2 +- crates/vim/src/vim.rs | 77 +++++++++++----- crates/vim/src/visual.rs | 12 +-- crates/workspace/src/pane_group.rs | 137 +++++++++++++++++++++++++++- crates/workspace/src/workspace.rs | 6 ++ 25 files changed, 251 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 166adb6588..41532b9773 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13832,6 +13832,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "theme", "tokio", "ui", "util", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 67db22b5e2..858a1b8d31 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -557,6 +557,10 @@ "ctrl-w shift-l": ["workspace::SwapPaneInDirection", "Right"], "ctrl-w shift-k": ["workspace::SwapPaneInDirection", "Up"], "ctrl-w shift-j": ["workspace::SwapPaneInDirection", "Down"], + "ctrl-w >": ["vim::ResizePane", "Widen"], + "ctrl-w <": ["vim::ResizePane", "Narrow"], + "ctrl-w -": ["vim::ResizePane", "Shorten"], + "ctrl-w +": ["vim::ResizePane", "Lengthen"], "ctrl-w g t": "pane::ActivateNextItem", "ctrl-w ctrl-g t": "pane::ActivateNextItem", "ctrl-w g shift-t": "pane::ActivatePrevItem", diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index ddf738d067..02d4136faa 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -36,6 +36,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +theme.workspace = true tokio = { version = "1.15", features = ["full"], optional = true } ui.workspace = true util.workspace = true diff --git a/crates/vim/src/change_list.rs b/crates/vim/src/change_list.rs index 69fcdd8319..adf553983b 100644 --- a/crates/vim/src/change_list.rs +++ b/crates/vim/src/change_list.rs @@ -16,7 +16,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { impl Vim { fn move_to_change(&mut self, direction: Direction, cx: &mut ViewContext) { - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); if self.change_list.is_empty() { return; } diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 2fa75c8579..5a958da012 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -101,7 +101,7 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { let Some(workspace) = vim.workspace(cx) else { return; }; - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); workspace.update(cx, |workspace, cx| { command_palette::CommandPalette::toggle( workspace, diff --git a/crates/vim/src/indent.rs b/crates/vim/src/indent.rs index b6ca2de34c..8e4f27271b 100644 --- a/crates/vim/src/indent.rs +++ b/crates/vim/src/indent.rs @@ -16,7 +16,7 @@ actions!(vim, [Indent, Outdent,]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Indent, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); vim.store_visual_marks(cx); vim.update_editor(cx, |vim, editor, cx| { editor.transact(cx, |editor, cx| { @@ -34,7 +34,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Outdent, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); vim.store_visual_marks(cx); vim.update_editor(cx, |vim, editor, cx| { editor.transact(cx, |editor, cx| { diff --git a/crates/vim/src/insert.rs b/crates/vim/src/insert.rs index ba83e2125b..b1e7af9b10 100644 --- a/crates/vim/src/insert.rs +++ b/crates/vim/src/insert.rs @@ -17,7 +17,7 @@ impl Vim { self.sync_vim_settings(cx); return; } - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); self.stop_recording_immediately(action.boxed_clone(), cx); if count <= 1 || Vim::globals(cx).dot_replaying { self.create_mark("^".into(), false, cx); diff --git a/crates/vim/src/mode_indicator.rs b/crates/vim/src/mode_indicator.rs index 619bb6e1f4..8b608fdfe3 100644 --- a/crates/vim/src/mode_indicator.rs +++ b/crates/vim/src/mode_indicator.rs @@ -2,7 +2,7 @@ use gpui::{div, Element, Render, Subscription, View, ViewContext, WeakView}; use itertools::Itertools; use workspace::{item::ItemHandle, ui::prelude::*, StatusItemView}; -use crate::{Vim, VimEvent}; +use crate::{Vim, VimEvent, VimGlobals}; /// The ModeIndicator displays the current mode in the status bar. pub struct ModeIndicator { @@ -68,14 +68,22 @@ impl ModeIndicator { let vim = vim.read(cx); recording - .chain(vim.pre_count.map(|count| format!("{}", count))) + .chain( + cx.global::() + .pre_count + .map(|count| format!("{}", count)), + ) .chain(vim.selected_register.map(|reg| format!("\"{reg}"))) .chain( vim.operator_stack .iter() .map(|item| item.status().to_string()), ) - .chain(vim.post_count.map(|count| format!("{}", count))) + .chain( + cx.global::() + .post_count + .map(|count| format!("{}", count)), + ) .collect::>() .join("") } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 7c628626cb..9c770fb63f 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -490,7 +490,7 @@ impl Vim { self.pop_operator(cx); } - let count = self.take_count(cx); + let count = Vim::take_count(cx); let active_operator = self.active_operator(); let mut waiting_operator: Option = None; match self.mode { @@ -510,7 +510,7 @@ impl Vim { self.clear_operator(cx); if let Some(operator) = waiting_operator { self.push_operator(operator, cx); - self.pre_count = count + Vim::globals(cx).pre_count = count } } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 37a8115e33..24e8e7bed4 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -77,17 +77,17 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &DeleteLeft, cx| { vim.record_current_action(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.delete_motion(Motion::Left, times, cx); }); Vim::action(editor, cx, |vim, _: &DeleteRight, cx| { vim.record_current_action(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.delete_motion(Motion::Right, times, cx); }); Vim::action(editor, cx, |vim, _: &ChangeToEndOfLine, cx| { vim.start_recording(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.change_motion( Motion::EndOfLine { display_lines: false, @@ -98,7 +98,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); Vim::action(editor, cx, |vim, _: &DeleteToEndOfLine, cx| { vim.record_current_action(cx); - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.delete_motion( Motion::EndOfLine { display_lines: false, @@ -109,7 +109,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); Vim::action(editor, cx, |vim, _: &JoinLines, cx| { vim.record_current_action(cx); - let mut times = vim.take_count(cx).unwrap_or(1); + let mut times = Vim::take_count(cx).unwrap_or(1); if vim.mode.is_visual() { times = 1; } else if times > 1 { @@ -130,7 +130,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); Vim::action(editor, cx, |vim, _: &Undo, cx| { - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.update_editor(cx, |_, editor, cx| { for _ in 0..times.unwrap_or(1) { editor.undo(&editor::actions::Undo, cx); @@ -138,7 +138,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { }); }); Vim::action(editor, cx, |vim, _: &Redo, cx| { - let times = vim.take_count(cx); + let times = Vim::take_count(cx); vim.update_editor(cx, |_, editor, cx| { for _ in 0..times.unwrap_or(1) { editor.redo(&editor::actions::Redo, cx); @@ -396,7 +396,7 @@ impl Vim { } fn yank_line(&mut self, _: &YankLine, cx: &mut ViewContext) { - let count = self.take_count(cx); + let count = Vim::take_count(cx); self.yank_motion(motion::Motion::CurrentLine, count, cx) } @@ -416,7 +416,7 @@ impl Vim { } pub(crate) fn normal_replace(&mut self, text: Arc, cx: &mut ViewContext) { - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); self.stop_recording(cx); self.update_editor(cx, |_, editor, cx| { editor.transact(cx, |editor, cx| { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 2c591a1f1f..0aeb4c7e98 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -118,7 +118,7 @@ impl Vim { { self.record_current_action(cx); self.store_visual_marks(cx); - let count = self.take_count(cx).unwrap_or(1) as u32; + let count = Vim::take_count(cx).unwrap_or(1) as u32; self.update_editor(cx, |vim, editor, cx| { let mut ranges = Vec::new(); diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index ec24064b31..ca300fc1be 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -26,13 +26,13 @@ impl_actions!(vim, [Increment, Decrement]); pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, action: &Increment, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let step = if action.step { 1 } else { 0 }; vim.increment(count as i64, step, cx) }); Vim::action(editor, cx, |vim, action: &Decrement, cx| { vim.record_current_action(cx); - let count = vim.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let step = if action.step { -1 } else { 0 }; vim.increment(-(count as i64), step, cx) }); diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index feb060d594..8d49a6802c 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -25,7 +25,7 @@ impl Vim { pub fn paste(&mut self, action: &Paste, cx: &mut ViewContext) { self.record_current_action(cx); self.store_visual_marks(cx); - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); self.update_editor(cx, |vim, editor, cx| { let text_layout_details = editor.text_layout_details(cx); diff --git a/crates/vim/src/normal/repeat.rs b/crates/vim/src/normal/repeat.rs index c89b63ecc6..41c89269f1 100644 --- a/crates/vim/src/normal/repeat.rs +++ b/crates/vim/src/normal/repeat.rs @@ -158,7 +158,7 @@ impl Vim { } pub(crate) fn replay_register(&mut self, mut register: char, cx: &mut ViewContext) { - let mut count = self.take_count(cx).unwrap_or(1); + let mut count = Vim::take_count(cx).unwrap_or(1); self.clear_operator(cx); let globals = Vim::globals(cx); @@ -184,7 +184,7 @@ impl Vim { } pub(crate) fn repeat(&mut self, from_insert_mode: bool, cx: &mut ViewContext) { - let count = self.take_count(cx); + let count = Vim::take_count(cx); let Some((mut actions, selection, mode)) = Vim::update_globals(cx, |globals, _| { let actions = globals.recorded_actions.clone(); if actions.is_empty() { diff --git a/crates/vim/src/normal/scroll.rs b/crates/vim/src/normal/scroll.rs index 8d1443e633..3f71401e2e 100644 --- a/crates/vim/src/normal/scroll.rs +++ b/crates/vim/src/normal/scroll.rs @@ -53,7 +53,7 @@ impl Vim { cx: &mut ViewContext, by: fn(c: Option) -> ScrollAmount, ) { - let amount = by(self.take_count(cx).map(|c| c as f32)); + let amount = by(Vim::take_count(cx).map(|c| c as f32)); self.update_editor(cx, |_, editor, cx| { scroll_editor(editor, move_cursor, &amount, cx) }); diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 5d78c8937e..103d33f8af 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -120,7 +120,7 @@ impl Vim { } else { Direction::Next }; - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let prior_selections = self.editor_selections(cx); pane.update(cx, |pane, cx| { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { @@ -226,7 +226,7 @@ impl Vim { pub fn move_to_match_internal(&mut self, direction: Direction, cx: &mut ViewContext) { let Some(pane) = self.pane(cx) else { return }; - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let prior_selections = self.editor_selections(cx); let success = pane.update(cx, |pane, cx| { @@ -264,7 +264,7 @@ impl Vim { cx: &mut ViewContext, ) { let Some(pane) = self.pane(cx) else { return }; - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let prior_selections = self.editor_selections(cx); let vim = cx.view().clone(); diff --git a/crates/vim/src/normal/substitute.rs b/crates/vim/src/normal/substitute.rs index dc27e2b219..c2b27227ca 100644 --- a/crates/vim/src/normal/substitute.rs +++ b/crates/vim/src/normal/substitute.rs @@ -9,7 +9,7 @@ actions!(vim, [Substitute, SubstituteLine]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Substitute, cx| { vim.start_recording(cx); - let count = vim.take_count(cx); + let count = Vim::take_count(cx); vim.substitute(count, vim.mode == Mode::VisualLine, cx); }); @@ -18,7 +18,7 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { if matches!(vim.mode, Mode::VisualBlock | Mode::Visual) { vim.switch_mode(Mode::VisualLine, false, cx) } - let count = vim.take_count(cx); + let count = Vim::take_count(cx); vim.substitute(count, true, cx) }); } diff --git a/crates/vim/src/replace.rs b/crates/vim/src/replace.rs index 753eec0971..8b84849043 100644 --- a/crates/vim/src/replace.rs +++ b/crates/vim/src/replace.rs @@ -22,7 +22,7 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { if vim.mode != Mode::Replace { return; } - let count = vim.take_count(cx); + let count = Vim::take_count(cx); vim.undo_replace(count, cx) }); } diff --git a/crates/vim/src/rewrap.rs b/crates/vim/src/rewrap.rs index db54c4ed57..1ef4a3fc03 100644 --- a/crates/vim/src/rewrap.rs +++ b/crates/vim/src/rewrap.rs @@ -10,7 +10,7 @@ actions!(vim, [Rewrap]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Rewrap, cx| { vim.record_current_action(cx); - vim.take_count(cx); + Vim::take_count(cx); vim.store_visual_marks(cx); vim.update_editor(cx, |vim, editor, cx| { editor.transact(cx, |editor, cx| { diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 510ed6557d..47742fb0c3 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -150,6 +150,11 @@ pub struct VimGlobals { pub dot_recording: bool, pub dot_replaying: bool, + /// pre_count is the number before an operator is specified (3 in 3d2d) + pub pre_count: Option, + /// post_count is the number after an operator is specified (2 in 3d2d) + pub post_count: Option, + pub stop_recording_after_next_action: bool, pub ignore_current_insertion: bool, pub recorded_count: Option, diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 88bcb6a2e1..719a147062 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -35,7 +35,7 @@ impl Vim { cx: &mut ViewContext, ) { self.stop_recording(cx); - let count = self.take_count(cx); + let count = Vim::take_count(cx); let mode = self.mode; self.update_editor(cx, |_, editor, cx| { let text_layout_details = editor.text_layout_details(cx); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index dd3bf297cb..0f206a88cc 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -25,8 +25,8 @@ use editor::{ Anchor, Bias, Editor, EditorEvent, EditorMode, ToPoint, }; use gpui::{ - actions, impl_actions, Action, AppContext, Entity, EventEmitter, KeyContext, KeystrokeEvent, - Render, Subscription, View, ViewContext, WeakView, + actions, impl_actions, Action, AppContext, Axis, Entity, EventEmitter, KeyContext, + KeystrokeEvent, Render, Subscription, View, ViewContext, WeakView, }; use insert::{NormalBefore, TemporaryNormal}; use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId}; @@ -40,12 +40,17 @@ use settings::{update_settings_file, Settings, SettingsSources, SettingsStore}; use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals}; use std::{mem, ops::Range, sync::Arc}; use surrounds::SurroundsType; +use theme::ThemeSettings; use ui::{IntoElement, VisualContext}; use vim_mode_setting::VimModeSetting; -use workspace::{self, Pane, Workspace}; +use workspace::{self, Pane, ResizeIntent, Workspace}; use crate::state::ReplayableAction; +/// Used to resize the current pane +#[derive(Clone, Deserialize, PartialEq)] +pub struct ResizePane(pub ResizeIntent); + /// An Action to Switch between modes #[derive(Clone, Deserialize, PartialEq)] pub struct SwitchMode(pub Mode); @@ -81,7 +86,10 @@ actions!( // in the workspace namespace so it's not filtered out when vim is disabled. actions!(workspace, [ToggleVimMode]); -impl_actions!(vim, [SwitchMode, PushOperator, Number, SelectRegister]); +impl_actions!( + vim, + [ResizePane, SwitchMode, PushOperator, Number, SelectRegister] +); /// Initializes the `vim` crate. pub fn init(cx: &mut AppContext) { @@ -109,6 +117,30 @@ pub fn init(cx: &mut AppContext) { }); }); + workspace.register_action(|workspace, action: &ResizePane, cx| { + let count = Vim::take_count(cx.window_context()).unwrap_or(1) as f32; + let theme = ThemeSettings::get_global(cx); + let Ok(font_id) = cx.text_system().font_id(&theme.buffer_font) else { + return; + }; + let Ok(width) = cx + .text_system() + .advance(font_id, theme.buffer_font_size(cx), 'm') + else { + return; + }; + let height = theme.buffer_font_size(cx) * theme.buffer_line_height.value(); + + let (axis, amount) = match action.0 { + ResizeIntent::Lengthen => (Axis::Vertical, height), + ResizeIntent::Shorten => (Axis::Vertical, height * -1.), + ResizeIntent::Widen => (Axis::Horizontal, width.width), + ResizeIntent::Narrow => (Axis::Horizontal, width.width * -1.), + }; + + workspace.resize_pane(axis, amount * count, cx); + }); + workspace.register_action(|workspace, _: &SearchSubmit, cx| { let vim = workspace .focused_pane(cx) @@ -131,7 +163,7 @@ pub(crate) struct VimAddon { impl editor::Addon for VimAddon { fn extend_key_context(&self, key_context: &mut KeyContext, cx: &AppContext) { - self.view.read(cx).extend_key_context(key_context) + self.view.read(cx).extend_key_context(key_context, cx) } fn to_any(&self) -> &dyn std::any::Any { @@ -146,11 +178,6 @@ pub(crate) struct Vim { pub temp_mode: bool, pub exit_temporary_mode: bool, - /// pre_count is the number before an operator is specified (3 in 3d2d) - pre_count: Option, - /// post_count is the number after an operator is specified (2 in 3d2d) - post_count: Option, - operator_stack: Vec, pub(crate) replacements: Vec<(Range, String)>, @@ -197,8 +224,6 @@ impl Vim { last_mode: Mode::Normal, temp_mode: false, exit_temporary_mode: false, - pre_count: None, - post_count: None, operator_stack: Vec::new(), replacements: Vec::new(), @@ -471,7 +496,7 @@ impl Vim { self.current_anchor.take(); } if mode != Mode::Insert && mode != Mode::Replace { - self.take_count(cx); + Vim::take_count(cx); } // Sync editor settings like clip mode @@ -551,22 +576,24 @@ impl Vim { }); } - fn take_count(&mut self, cx: &mut ViewContext) -> Option { + pub fn take_count(cx: &mut AppContext) -> Option { let global_state = cx.global_mut::(); if global_state.dot_replaying { return global_state.recorded_count; } - let count = if self.post_count.is_none() && self.pre_count.is_none() { + let count = if global_state.post_count.is_none() && global_state.pre_count.is_none() { return None; } else { - Some(self.post_count.take().unwrap_or(1) * self.pre_count.take().unwrap_or(1)) + Some( + global_state.post_count.take().unwrap_or(1) + * global_state.pre_count.take().unwrap_or(1), + ) }; if global_state.dot_recording { global_state.recorded_count = count; } - self.sync_vim_settings(cx); count } @@ -613,7 +640,7 @@ impl Vim { } } - pub fn extend_key_context(&self, context: &mut KeyContext) { + pub fn extend_key_context(&self, context: &mut KeyContext, cx: &AppContext) { let mut mode = match self.mode { Mode::Normal => "normal", Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual", @@ -625,8 +652,8 @@ impl Vim { let mut operator_id = "none"; let active_operator = self.active_operator(); - if active_operator.is_none() && self.pre_count.is_some() - || active_operator.is_some() && self.post_count.is_some() + if active_operator.is_none() && cx.global::().pre_count.is_some() + || active_operator.is_some() && cx.global::().post_count.is_some() { context.add("VimCount"); } @@ -837,18 +864,18 @@ impl Vim { fn push_count_digit(&mut self, number: usize, cx: &mut ViewContext) { if self.active_operator().is_some() { - let post_count = self.post_count.unwrap_or(0); + let post_count = Vim::globals(cx).post_count.unwrap_or(0); - self.post_count = Some( + Vim::globals(cx).post_count = Some( post_count .checked_mul(10) .and_then(|post_count| post_count.checked_add(number)) .unwrap_or(post_count), ) } else { - let pre_count = self.pre_count.unwrap_or(0); + let pre_count = Vim::globals(cx).pre_count.unwrap_or(0); - self.pre_count = Some( + Vim::globals(cx).pre_count = Some( pre_count .checked_mul(10) .and_then(|pre_count| pre_count.checked_add(number)) @@ -880,7 +907,7 @@ impl Vim { } fn clear_operator(&mut self, cx: &mut ViewContext) { - self.take_count(cx); + Vim::take_count(cx); self.selected_register.take(); self.operator_stack.clear(); self.sync_vim_settings(cx); diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 47aa618b5c..813be6dda1 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -538,9 +538,8 @@ impl Vim { } pub fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let count = self - .take_count(cx) - .unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); + let count = + Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(cx, |_, editor, cx| { editor.set_clip_at_line_ends(false, cx); for _ in 0..count { @@ -556,9 +555,8 @@ impl Vim { } pub fn select_previous(&mut self, _: &SelectPrevious, cx: &mut ViewContext) { - let count = self - .take_count(cx) - .unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); + let count = + Vim::take_count(cx).unwrap_or_else(|| if self.mode.is_visual() { 1 } else { 2 }); self.update_editor(cx, |_, editor, cx| { for _ in 0..count { if editor @@ -573,7 +571,7 @@ impl Vim { } pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext) { - let count = self.take_count(cx).unwrap_or(1); + let count = Vim::take_count(cx).unwrap_or(1); let Some(pane) = self.pane(cx) else { return; }; diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 390fa6d174..46975eb8f3 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -8,8 +8,8 @@ use call::{ActiveCall, ParticipantLocation}; use client::proto::PeerId; use collections::HashMap; use gpui::{ - point, size, AnyView, AnyWeakView, Axis, Bounds, IntoElement, Model, MouseButton, Pixels, - Point, StyleRefinement, View, ViewContext, + point, size, Along, AnyView, AnyWeakView, Axis, Bounds, IntoElement, Model, MouseButton, + Pixels, Point, StyleRefinement, View, ViewContext, }; use parking_lot::Mutex; use project::Project; @@ -90,6 +90,21 @@ impl PaneGroup { } } + pub fn resize( + &mut self, + pane: &View, + direction: Axis, + amount: Pixels, + bounds: &Bounds, + ) { + match &mut self.root { + Member::Pane(_) => {} + Member::Axis(axis) => { + let _ = axis.resize(pane, direction, amount, bounds); + } + }; + } + pub fn swap(&mut self, from: &View, to: &View) { match &mut self.root { Member::Pane(_) => {} @@ -445,6 +460,116 @@ impl PaneAxis { } } + fn resize( + &mut self, + pane: &View, + axis: Axis, + amount: Pixels, + bounds: &Bounds, + ) -> Option { + let container_size = self + .bounding_boxes + .lock() + .iter() + .filter_map(|e| *e) + .reduce(|acc, e| acc.union(&e)) + .unwrap_or(*bounds) + .size; + + let found_pane = self + .members + .iter() + .any(|member| matches!(member, Member::Pane(p) if p == pane)); + + if found_pane && self.axis != axis { + return Some(false); // pane found but this is not the correct axis direction + } + let mut found_axis_index: Option = None; + if !found_pane { + for (i, pa) in self.members.iter_mut().enumerate() { + if let Member::Axis(pa) = pa { + if let Some(done) = pa.resize(pane, axis, amount, bounds) { + if done { + return Some(true); // pane found and operations already done + } else if self.axis != axis { + return Some(false); // pane found but this is not the correct axis direction + } else { + found_axis_index = Some(i); // pane found and this is correct direction + } + } + } + } + found_axis_index?; // no pane found + } + + let min_size = match axis { + Axis::Horizontal => px(HORIZONTAL_MIN_SIZE), + Axis::Vertical => px(VERTICAL_MIN_SIZE), + }; + let mut flexes = self.flexes.lock(); + + let ix = if found_pane { + self.members.iter().position(|m| { + if let Member::Pane(p) = m { + p == pane + } else { + false + } + }) + } else { + found_axis_index + }; + + if ix.is_none() { + return Some(true); + } + + let ix = ix.unwrap_or(0); + + let size = move |ix, flexes: &[f32]| { + container_size.along(axis) * (flexes[ix] / flexes.len() as f32) + }; + + // Don't allow resizing to less than the minimum size, if elements are already too small + if min_size - px(1.) > size(ix, flexes.as_slice()) { + return Some(true); + } + + let flex_changes = |pixel_dx, target_ix, next: isize, flexes: &[f32]| { + let flex_change = flexes.len() as f32 * pixel_dx / container_size.along(axis); + let current_target_flex = flexes[target_ix] + flex_change; + let next_target_flex = flexes[(target_ix as isize + next) as usize] - flex_change; + (current_target_flex, next_target_flex) + }; + + let apply_changes = + |current_ix: usize, proposed_current_pixel_change: Pixels, flexes: &mut [f32]| { + let next_target_size = Pixels::max( + size(current_ix + 1, flexes) - proposed_current_pixel_change, + min_size, + ); + let current_target_size = Pixels::max( + size(current_ix, flexes) + size(current_ix + 1, flexes) - next_target_size, + min_size, + ); + + let current_pixel_change = current_target_size - size(current_ix, flexes); + + let (current_target_flex, next_target_flex) = + flex_changes(current_pixel_change, current_ix, 1, flexes); + + flexes[current_ix] = current_target_flex; + flexes[current_ix + 1] = next_target_flex; + }; + + if ix + 1 == flexes.len() { + apply_changes(ix - 1, -1.0 * amount, flexes.as_mut_slice()); + } else { + apply_changes(ix, amount, flexes.as_mut_slice()); + } + Some(true) + } + fn swap(&mut self, from: &View, to: &View) { for member in self.members.iter_mut() { match member { @@ -625,6 +750,14 @@ impl SplitDirection { } } +#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +pub enum ResizeIntent { + Lengthen, + Shorten, + Widen, + Narrow, +} + mod element { use std::mem; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 42db3183bd..b2be324b5a 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2988,6 +2988,12 @@ impl Workspace { } } + pub fn resize_pane(&mut self, axis: gpui::Axis, amount: Pixels, cx: &mut ViewContext) { + self.center + .resize(&self.active_pane.clone(), axis, amount, &self.bounds); + cx.notify(); + } + fn handle_pane_focused(&mut self, pane: View, cx: &mut ViewContext) { // This is explicitly hoisted out of the following check for pane identity as // terminal panel panes are not registered as a center panes. From 4e720be41c46d96f127ff1de070dcb5f2a071651 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 26 Nov 2024 16:45:38 -0800 Subject: [PATCH 034/215] Add ctrl-w _ and ctrl-w = (#21227) Closes #ISSUE Release Notes: - vim: Add support for `ctrl-w _` and `ctrl-w =` --- assets/keymaps/vim.json | 2 ++ crates/vim/src/vim.rs | 29 ++++++++++++++++++++++++++--- crates/workspace/src/pane_group.rs | 19 ++++++++++++++++++- crates/workspace/src/workspace.rs | 9 +++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 858a1b8d31..a69e97401d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -561,6 +561,8 @@ "ctrl-w <": ["vim::ResizePane", "Narrow"], "ctrl-w -": ["vim::ResizePane", "Shorten"], "ctrl-w +": ["vim::ResizePane", "Lengthen"], + "ctrl-w _": "vim::MaximizePane", + "ctrl-w =": "vim::ResetPaneSizes", "ctrl-w g t": "pane::ActivateNextItem", "ctrl-w ctrl-g t": "pane::ActivateNextItem", "ctrl-w g shift-t": "pane::ActivatePrevItem", diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index 0f206a88cc..a1820eafbb 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -41,7 +41,7 @@ use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals}; use std::{mem, ops::Range, sync::Arc}; use surrounds::SurroundsType; use theme::ThemeSettings; -use ui::{IntoElement, VisualContext}; +use ui::{px, IntoElement, VisualContext}; use vim_mode_setting::VimModeSetting; use workspace::{self, Pane, ResizeIntent, Workspace}; @@ -79,7 +79,9 @@ actions!( InnerObject, FindForward, FindBackward, - OpenDefaultKeymap + OpenDefaultKeymap, + MaximizePane, + ResetPaneSizes, ] ); @@ -117,8 +119,29 @@ pub fn init(cx: &mut AppContext) { }); }); + workspace.register_action(|workspace, _: &ResetPaneSizes, cx| { + workspace.reset_pane_sizes(cx); + }); + + workspace.register_action(|workspace, _: &MaximizePane, cx| { + let pane = workspace.active_pane(); + let Some(size) = workspace.bounding_box_for_pane(&pane) else { + return; + }; + + let theme = ThemeSettings::get_global(cx); + let height = theme.buffer_font_size(cx) * theme.buffer_line_height.value(); + + let desired_size = if let Some(count) = Vim::take_count(cx) { + height * count + } else { + px(10000.) + }; + workspace.resize_pane(Axis::Vertical, desired_size - size.size.height, cx) + }); + workspace.register_action(|workspace, action: &ResizePane, cx| { - let count = Vim::take_count(cx.window_context()).unwrap_or(1) as f32; + let count = Vim::take_count(cx).unwrap_or(1) as f32; let theme = ThemeSettings::get_global(cx); let Ok(font_id) = cx.text_system().font_id(&theme.buffer_font) else { return; diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 46975eb8f3..6f7d1a66b9 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -105,6 +105,15 @@ impl PaneGroup { }; } + pub fn reset_pane_sizes(&mut self) { + match &mut self.root { + Member::Pane(_) => {} + Member::Axis(axis) => { + let _ = axis.reset_pane_sizes(); + } + }; + } + pub fn swap(&mut self, from: &View, to: &View) { match &mut self.root { Member::Pane(_) => {} @@ -460,6 +469,15 @@ impl PaneAxis { } } + fn reset_pane_sizes(&self) { + *self.flexes.lock() = vec![1.; self.members.len()]; + for member in self.members.iter() { + if let Member::Axis(axis) = member { + axis.reset_pane_sizes(); + } + } + } + fn resize( &mut self, pane: &View, @@ -759,7 +777,6 @@ pub enum ResizeIntent { } mod element { - use std::mem; use std::{cell::RefCell, iter, rc::Rc, sync::Arc}; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b2be324b5a..28fd730e60 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2946,6 +2946,10 @@ impl Workspace { } } + pub fn bounding_box_for_pane(&self, pane: &View) -> Option> { + self.center.bounding_box_for_pane(pane) + } + pub fn find_pane_in_direction( &mut self, direction: SplitDirection, @@ -2994,6 +2998,11 @@ impl Workspace { cx.notify(); } + pub fn reset_pane_sizes(&mut self, cx: &mut ViewContext) { + self.center.reset_pane_sizes(); + cx.notify(); + } + fn handle_pane_focused(&mut self, pane: View, cx: &mut ViewContext) { // This is explicitly hoisted out of the following check for pane identity as // terminal panel panes are not registered as a center panes. From e865b6c52459ca322bff0e6caabca07c724cb6f4 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 27 Nov 2024 00:56:51 +0000 Subject: [PATCH 035/215] Fix cmd-shift-e (reveal in project panel) to match vscode (#21228) Release Notes: - Fixed cmd-shift-e / ctrl-shift-e (`pane::RevealInProjectPanel` / `project_panel::ToggleFocus`) to better my VSCode behavior --- assets/keymaps/default-linux.json | 3 ++- assets/keymaps/default-macos.json | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2eedc1c839..2b792f353f 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -405,7 +405,7 @@ "ctrl-shift-p": "command_palette::Toggle", "f1": "command_palette::Toggle", "ctrl-shift-m": "diagnostics::Deploy", - "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-e": "pane::RevealInProjectPanel", "ctrl-shift-b": "outline_panel::ToggleFocus", "ctrl-?": "assistant::ToggleFocus", "ctrl-alt-s": "workspace::SaveAll", @@ -594,6 +594,7 @@ "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-ctrl-r": "project_panel::RevealInFileManager", "ctrl-shift-enter": "project_panel::OpenWithSystem", + "ctrl-shift-e": "project_panel::ToggleFocus", "ctrl-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", "shift-up": "menu::SelectPrev", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ddbbdd3faf..514604ef98 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -446,7 +446,7 @@ "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], "cmd-shift-p": "command_palette::Toggle", "cmd-shift-m": "diagnostics::Deploy", - "cmd-shift-e": "project_panel::ToggleFocus", + "cmd-shift-e": "pane::RevealInProjectPanel", "cmd-shift-b": "outline_panel::ToggleFocus", "cmd-?": "assistant::ToggleFocus", "cmd-alt-s": "workspace::SaveAll", @@ -616,6 +616,7 @@ "cmd-delete": ["project_panel::Delete", { "skip_prompt": false }], "alt-cmd-r": "project_panel::RevealInFileManager", "ctrl-shift-enter": "project_panel::OpenWithSystem", + "cmd-shift-e": "project_panel::ToggleFocus", "cmd-alt-backspace": ["project_panel::Delete", { "skip_prompt": false }], "cmd-shift-f": "project_panel::NewSearchInDirectory", "shift-down": "menu::SelectNext", From ce6782f4c8d3fe540110da0f1a49058c95192915 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 27 Nov 2024 12:02:39 +0200 Subject: [PATCH 036/215] Use eslint from the fork (#21233) Part of https://github.com/zed-industries/zed/issues/21220 Microsoft had decided to switch over to a different releasing strategy, autogenerating all releases and not publishing tarballs anymore. But it was not enough, and they had also removed old tarballs, including a relatively old `2.4.4` version release's tarballs, which broke Zed downloads. See https://github.com/microsoft/vscode-eslint/issues/1954 This PR uses https://github.com/zed-industries/vscode-eslint/releases/tag/2.4.4 from Zed's fork, manually released for the same tag. This approach is merely a stub before more sustainable solution is found, and I think we need to pivot into downloading *.vsix from https://open-vsx.org/extension/dbaeumer/vscode-eslint but this is quite a change so not done right now. Release Notes: - Fixed eslint 404 downloads --- crates/languages/src/typescript.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/languages/src/typescript.rs b/crates/languages/src/typescript.rs index c580575a1e..076d8d3374 100644 --- a/crates/languages/src/typescript.rs +++ b/crates/languages/src/typescript.rs @@ -412,7 +412,7 @@ impl LspAdapter for EsLintLspAdapter { _delegate: &dyn LspAdapterDelegate, ) -> Result> { let url = build_asset_url( - "microsoft/vscode-eslint", + "zed-industries/vscode-eslint", Self::CURRENT_VERSION_TAG_NAME, Self::GITHUB_ASSET_KIND, )?; From 6736806924d1ebadcb0c47e350c370af2cf8abc9 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 27 Nov 2024 14:25:43 +0000 Subject: [PATCH 037/215] docs: Move install rustup callup to top of developing-extensions.md (#21239) --- docs/src/extensions/developing-extensions.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/src/extensions/developing-extensions.md b/docs/src/extensions/developing-extensions.md index bdfab5fcde..c404d260a0 100644 --- a/docs/src/extensions/developing-extensions.md +++ b/docs/src/extensions/developing-extensions.md @@ -9,6 +9,16 @@ Extensions can add the following capabilities to Zed: - [Slash Commands](./slash-commands.md) - [Context Servers](./context-servers.md) +## Developing an Extension Locally + +Before starting to develop an extension for Zed, be sure to [install Rust via rustup](https://www.rust-lang.org/tools/install). + +When developing an extension, you can use it in Zed without needing to publish it by installing it as a _dev extension_. + +From the extensions page, click the `Install Dev Extension` button and select the directory containing your extension. + +If you already have a published extension with the same name installed, your dev extension will override it. + ## Directory Structure of a Zed Extension A Zed extension is a Git repository that contains an `extension.toml`. This file must contain some @@ -75,16 +85,6 @@ impl zed::Extension for MyExtension { zed::register_extension!(MyExtension); ``` -## Developing an Extension Locally - -Before starting to develop an extension for Zed, be sure to [install Rust via rustup](https://www.rust-lang.org/tools/install). - -When developing an extension, you can use it in Zed without needing to publish it by installing it as a _dev extension_. - -From the extensions page, click the `Install Dev Extension` button and select the directory containing your extension. - -If you already have a published extension with the same name installed, your dev extension will override it. - ## Publishing your extension To publish an extension, open a PR to [the `zed-industries/extensions` repo](https://github.com/zed-industries/extensions). From c021ee60d67cfbecb800f33f5d644a201a6bb567 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 27 Nov 2024 09:48:40 -0500 Subject: [PATCH 038/215] v0.165.x dev --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 41532b9773..d9da330daa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15614,7 +15614,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.164.0" +version = "0.165.0" dependencies = [ "activity_indicator", "anyhow", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 5003ca1b81..24fc0dec8b 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.164.0" +version = "0.165.0" publish = false license = "GPL-3.0-or-later" authors = ["Zed Team "] From 4564da28757d744364ff12dd3c7b43155d75f84a Mon Sep 17 00:00:00 2001 From: Stanislav Alekseev <43210583+WeetHet@users.noreply.github.com> Date: Wed, 27 Nov 2024 20:22:17 +0200 Subject: [PATCH 039/215] Improve Nix package and shell (#21075) With an addition of useFetchCargoVendor, crane becomes less necessary for our use. This reuses the package from nixpkgs as well as creating a better devshell that both work on macOS. I use Xcode's SDKROOT and DEVELOPER_DIR to point the swift in the livekit client crate to a correct sdk when using a devshell. Devshell should work without that once apple releases sources for the 15.1 SDK but for now this is an easy fix This also replaces fenix with rust-overlay because of issues with the out-of-sandbox access I've noticed fenix installed toolchains have Release Notes: - N/A --- .envrc | 2 + flake.lock | 70 +++--------- flake.nix | 81 ++++++------- nix/build.nix | 307 ++++++++++++++++++++++++++++++++++---------------- nix/shell.nix | 104 +++++++++-------- 5 files changed, 326 insertions(+), 238 deletions(-) create mode 100644 .envrc diff --git a/.envrc b/.envrc new file mode 100644 index 0000000000..082c01feeb --- /dev/null +++ b/.envrc @@ -0,0 +1,2 @@ +watch_file nix/shell.nix +use flake diff --git a/flake.lock b/flake.lock index 5666e73569..4011b38c4b 100644 --- a/flake.lock +++ b/flake.lock @@ -1,41 +1,5 @@ { "nodes": { - "crane": { - "locked": { - "lastModified": 1727060013, - "narHash": "sha256-/fC5YlJy4IoAW9GhkJiwyzk0K/gQd9Qi4rRcoweyG9E=", - "owner": "ipetkov", - "repo": "crane", - "rev": "6b40cc876c929bfe1e3a24bf538ce3b5622646ba", - "type": "github" - }, - "original": { - "owner": "ipetkov", - "repo": "crane", - "type": "github" - } - }, - "fenix": { - "inputs": { - "nixpkgs": [ - "nixpkgs" - ], - "rust-analyzer-src": "rust-analyzer-src" - }, - "locked": { - "lastModified": 1727073227, - "narHash": "sha256-1kmkEQmFfGVuPBasqSZrNThqyMDV1SzTalQdRZxtDRs=", - "owner": "nix-community", - "repo": "fenix", - "rev": "88cc292eb3c689073c784d6aecc0edbd47e12881", - "type": "github" - }, - "original": { - "owner": "nix-community", - "repo": "fenix", - "type": "github" - } - }, "flake-compat": { "locked": { "lastModified": 1696426674, @@ -53,11 +17,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1726937504, - "narHash": "sha256-bvGoiQBvponpZh8ClUcmJ6QnsNKw0EMrCQJARK3bI1c=", + "lastModified": 1732014248, + "narHash": "sha256-y/MEyuJ5oBWrWAic/14LaIr/u5E0wRVzyYsouYY3W6w=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "9357f4f23713673f310988025d9dc261c20e70c6", + "rev": "23e89b7da85c3640bbc2173fe04f4bd114342367", "type": "github" }, "original": { @@ -69,26 +33,28 @@ }, "root": { "inputs": { - "crane": "crane", - "fenix": "fenix", "flake-compat": "flake-compat", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" } }, - "rust-analyzer-src": { - "flake": false, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, "locked": { - "lastModified": 1726443025, - "narHash": "sha256-nCmG4NJpwI0IoIlYlwtDwVA49yuspA2E6OhfCOmiArQ=", - "owner": "rust-lang", - "repo": "rust-analyzer", - "rev": "94b526fc86eaa0e90fb4d54a5ba6313aa1e9b269", + "lastModified": 1732242723, + "narHash": "sha256-NWI8csIK0ujFlFuEXKnoc+7hWoCiEtINK9r48LUUMeU=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "a229311fcb45b88a95fdfa5cecd8349c809a272a", "type": "github" }, "original": { - "owner": "rust-lang", - "ref": "nightly", - "repo": "rust-analyzer", + "owner": "oxalica", + "repo": "rust-overlay", "type": "github" } } diff --git a/flake.nix b/flake.nix index 2ee86c4466..3258522eb4 100644 --- a/flake.nix +++ b/flake.nix @@ -3,60 +3,61 @@ inputs = { nixpkgs.url = "github:NixOS/nixpkgs?ref=nixos-unstable"; - fenix = { - url = "github:nix-community/fenix"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; - crane.url = "github:ipetkov/crane"; flake-compat.url = "github:edolstra/flake-compat"; }; - outputs = { - nixpkgs, - crane, - fenix, - ... - }: let - systems = ["x86_64-linux" "aarch64-linux"]; + outputs = + { nixpkgs, rust-overlay, ... }: + let + systems = [ + "x86_64-linux" + "x86_64-darwin" + "aarch64-linux" + "aarch64-darwin" + ]; - overlays = { - fenix = fenix.overlays.default; - rust-toolchain = final: prev: { - rustToolchain = final.fenix.stable.toolchain; - }; - zed-editor = final: prev: { - zed-editor = final.callPackage ./nix/build.nix { - craneLib = (crane.mkLib final).overrideToolchain final.rustToolchain; - rustPlatform = final.makeRustPlatform { - inherit (final.rustToolchain) cargo rustc; + overlays = { + rust-overlay = rust-overlay.overlays.default; + rust-toolchain = final: prev: { + rustToolchain = final.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + }; + zed-editor = final: prev: { + zed-editor = final.callPackage ./nix/build.nix { + rustPlatform = final.makeRustPlatform { + cargo = final.rustToolchain; + rustc = final.rustToolchain; + }; }; }; }; - }; - mkPkgs = system: - import nixpkgs { - inherit system; - overlays = builtins.attrValues overlays; - }; + mkPkgs = + system: + import nixpkgs { + inherit system; + overlays = builtins.attrValues overlays; + }; - forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (mkPkgs system)); - in { - packages = forAllSystems (pkgs: { - zed-editor = pkgs.zed-editor; - default = pkgs.zed-editor; - }); + forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f (mkPkgs system)); + in + { + packages = forAllSystems (pkgs: { + zed-editor = pkgs.zed-editor; + default = pkgs.zed-editor; + }); - devShells = forAllSystems (pkgs: { - default = import ./nix/shell.nix {inherit pkgs;}; - }); + devShells = forAllSystems (pkgs: { + default = import ./nix/shell.nix { inherit pkgs; }; + }); - formatter = forAllSystems (pkgs: pkgs.alejandra); + formatter = forAllSystems (pkgs: pkgs.nixfmt-rfc-style); - overlays = - overlays - // { + overlays = overlays // { default = nixpkgs.lib.composeManyExtensions (builtins.attrValues overlays); }; - }; + }; } diff --git a/nix/build.nix b/nix/build.nix index 4782c9a56f..903f9790c7 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -1,10 +1,9 @@ { lib, - craneLib, rustPlatform, + fetchpatch, clang, - llvmPackages_18, - mold-wrapped, + cmake, copyDesktopItems, curl, perl, @@ -22,122 +21,236 @@ wayland, libglvnd, xorg, + stdenv, makeFontsConf, vulkan-loader, envsubst, - stdenvAdapters, + cargo-about, + versionCheckHook, + cargo-bundle, + git, + apple-sdk_15, + darwinMinVersionHook, + makeWrapper, + nodejs_22, nix-gitignore, + withGLES ? false, - cmake, -}: let - includeFilter = path: type: let - baseName = baseNameOf (toString path); - parentDir = dirOf path; - inRootDir = type == "directory" && parentDir == ../.; - in - !(inRootDir && (baseName == "docs" || baseName == ".github" || baseName == "script" || baseName == ".git" || baseName == "target")); +}: + +assert withGLES -> stdenv.hostPlatform.isLinux; + +let + includeFilter = + path: type: + let + baseName = baseNameOf (toString path); + parentDir = dirOf path; + inRootDir = type == "directory" && parentDir == ../.; + in + !( + inRootDir + && ( + baseName == "docs" + || baseName == ".github" + || baseName == "script" + || baseName == ".git" + || baseName == "target" + ) + ); +in +rustPlatform.buildRustPackage rec { + pname = "zed-editor"; + version = "nightly"; src = lib.cleanSourceWith { - src = nix-gitignore.gitignoreSource [] ../.; + src = nix-gitignore.gitignoreSource [ ] ../.; filter = includeFilter; name = "source"; }; - stdenv = stdenvAdapters.useMoldLinker llvmPackages_18.stdenv; + patches = + [ + # Zed uses cargo-install to install cargo-about during the script execution. + # We provide cargo-about ourselves and can skip this step. + # Until https://github.com/zed-industries/zed/issues/19971 is fixed, + # we also skip any crate for which the license cannot be determined. + (fetchpatch { + url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0001-generate-licenses.patch"; + hash = "sha256-cLgqLDXW1JtQ2OQFLd5UolAjfy7bMoTw40lEx2jA2pk="; + }) + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + # Livekit requires Swift 6 + # We need this until livekit-rust sdk is used + (fetchpatch { + url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0002-disable-livekit-darwin.patch"; + hash = "sha256-whZ7RaXv8hrVzWAveU3qiBnZSrvGNEHTuyNhxgMIo5w="; + }) + ]; - commonArgs = - craneLib.crateNameFromCargoToml {cargoToml = ../crates/zed/Cargo.toml;} - // { - inherit src stdenv; + useFetchCargoVendor = true; + cargoHash = "sha256-xL/EBe3+rlaPwU2zZyQtsZNHGBjzAD8ZCWrQXCQVxm8="; - nativeBuildInputs = [ - clang - copyDesktopItems - curl - mold-wrapped - perl - pkg-config - protobuf - rustPlatform.bindgenHook - cmake + nativeBuildInputs = + [ + clang + cmake + copyDesktopItems + curl + perl + pkg-config + protobuf + rustPlatform.bindgenHook + cargo-about + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ makeWrapper ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ cargo-bundle ]; + + dontUseCmakeConfigure = true; + + buildInputs = + [ + curl + fontconfig + freetype + libgit2 + openssl + sqlite + zlib + zstd + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + alsa-lib + libxkbcommon + wayland + xorg.libxcb + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + apple-sdk_15 + (darwinMinVersionHook "10.15") + ]; + + cargoBuildFlags = [ + "--package=zed" + "--package=cli" + ]; + + buildFeatures = lib.optionals stdenv.hostPlatform.isDarwin [ "gpui/runtime_shaders" ]; + + env = { + ZSTD_SYS_USE_PKG_CONFIG = true; + FONTCONFIG_FILE = makeFontsConf { + fontDirectories = [ + "${src}/assets/fonts/plex-mono" + "${src}/assets/fonts/plex-sans" ]; - - buildInputs = [ - curl - fontconfig - freetype - libgit2 - openssl - sqlite - zlib - zstd - - alsa-lib - libxkbcommon - wayland - xorg.libxcb - ]; - - ZSTD_SYS_USE_PKG_CONFIG = true; - FONTCONFIG_FILE = makeFontsConf { - fontDirectories = [ - "../assets/fonts/zed-mono" - "../assets/fonts/zed-sans" - ]; - }; - ZED_UPDATE_EXPLANATION = "zed has been installed using nix. Auto-updates have thus been disabled."; }; + ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; + RELEASE_VERSION = version; + }; - cargoArtifacts = craneLib.buildDepsOnly commonArgs; + RUSTFLAGS = if withGLES then "--cfg gles" else ""; + gpu-lib = if withGLES then libglvnd else vulkan-loader; - gpu-lib = - if withGLES - then libglvnd - else vulkan-loader; + preBuild = '' + bash script/generate-licenses + ''; - zed = craneLib.buildPackage (commonArgs - // { - inherit cargoArtifacts; - cargoExtraArgs = "--package=zed --package=cli"; - buildFeatures = ["gpui/runtime_shaders"]; - doCheck = false; + postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' + patchelf --add-rpath ${gpu-lib}/lib $out/libexec/* + patchelf --add-rpath ${wayland}/lib $out/libexec/* + wrapProgram $out/libexec/zed-editor --suffix PATH : ${lib.makeBinPath [ nodejs_22 ]} + ''; - RUSTFLAGS = - if withGLES - then "--cfg gles" - else ""; + preCheck = '' + export HOME=$(mktemp -d); + ''; - postFixup = '' - patchelf --add-rpath ${gpu-lib}/lib $out/libexec/* - patchelf --add-rpath ${wayland}/lib $out/libexec/* - ''; + checkFlags = + [ + # Flaky: unreliably fails on certain hosts (including Hydra) + "--skip=zed::tests::test_window_edit_state_restoring_enabled" + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + # Fails on certain hosts (including Hydra) for unclear reason + "--skip=test_open_paths_action" + ]; + + installPhase = + if stdenv.hostPlatform.isDarwin then + '' + runHook preInstall + + # cargo-bundle expects the binary in target/release + mv target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/zed target/release/zed + + pushd crates/zed + + # Note that this is GNU sed, while Zed's bundle-mac uses BSD sed + sed -i "s/package.metadata.bundle-stable/package.metadata.bundle/" Cargo.toml + export CARGO_BUNDLE_SKIP_BUILD=true + app_path=$(cargo bundle --release | xargs) + + # We're not using the fork of cargo-bundle, so we must manually append plist extensions + # Remove closing tags from Info.plist (last two lines) + head -n -2 $app_path/Contents/Info.plist > Info.plist + # Append extensions + cat resources/info/*.plist >> Info.plist + # Add closing tags + printf "\n\n" >> Info.plist + mv Info.plist $app_path/Contents/Info.plist + + popd + + mkdir -p $out/Applications $out/bin + # Zed expects git next to its own binary + ln -s ${git}/bin/git $app_path/Contents/MacOS/git + mv target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/cli $app_path/Contents/MacOS/cli + mv $app_path $out/Applications/ + + # Physical location of the CLI must be inside the app bundle as this is used + # to determine which app to start + ln -s $out/Applications/Zed.app/Contents/MacOS/cli $out/bin/zed + + runHook postInstall + '' + else + '' + runHook preInstall - postInstall = '' mkdir -p $out/bin $out/libexec - mv $out/bin/zed $out/libexec/zed-editor - mv $out/bin/cli $out/bin/zed + cp target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/zed $out/libexec/zed-editor + cp target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/cli $out/bin/zed - install -D crates/zed/resources/app-icon@2x.png $out/share/icons/hicolor/1024x1024@2x/apps/zed.png - install -D crates/zed/resources/app-icon.png $out/share/icons/hicolor/512x512/apps/zed.png + install -D ${src}/crates/zed/resources/app-icon@2x.png $out/share/icons/hicolor/1024x1024@2x/apps/zed.png + install -D ${src}/crates/zed/resources/app-icon.png $out/share/icons/hicolor/512x512/apps/zed.png - export DO_STARTUP_NOTIFY="true" - export APP_CLI="zed" - export APP_ICON="zed" - export APP_NAME="Zed" - export APP_ARGS="%U" - mkdir -p "$out/share/applications" - ${lib.getExe envsubst} < "crates/zed/resources/zed.desktop.in" > "$out/share/applications/dev.zed.Zed.desktop" + # extracted from https://github.com/zed-industries/zed/blob/v0.141.2/script/bundle-linux (envsubst) + # and https://github.com/zed-industries/zed/blob/v0.141.2/script/install.sh (final desktop file name) + ( + export DO_STARTUP_NOTIFY="true" + export APP_CLI="zed" + export APP_ICON="zed" + export APP_NAME="Zed" + export APP_ARGS="%U" + mkdir -p "$out/share/applications" + ${lib.getExe envsubst} < "crates/zed/resources/zed.desktop.in" > "$out/share/applications/dev.zed.Zed.desktop" + ) + + runHook postInstall ''; - }); -in - zed - // { - meta = with lib; { - description = "High-performance, multiplayer code editor from the creators of Atom and Tree-sitter"; - homepage = "https://zed.dev"; - changelog = "https://zed.dev/releases/preview"; - license = licenses.gpl3Only; - mainProgram = "zed"; - platforms = platforms.linux; - }; - } + + nativeInstallCheckInputs = [ + versionCheckHook + ]; + + meta = { + description = "High-performance, multiplayer code editor from the creators of Atom and Tree-sitter"; + homepage = "https://zed.dev"; + changelog = "https://zed.dev/releases/preview"; + license = lib.licenses.gpl3Only; + mainProgram = "zed"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; +} diff --git a/nix/shell.nix b/nix/shell.nix index e0b4018778..75ceb0d8e3 100644 --- a/nix/shell.nix +++ b/nix/shell.nix @@ -1,51 +1,57 @@ -{pkgs ? import {}}: let - stdenv = pkgs.stdenvAdapters.useMoldLinker pkgs.llvmPackages_18.stdenv; +{ + pkgs ? import { }, +}: +let + inherit (pkgs) lib; in - if pkgs.stdenv.isDarwin - then - # See https://github.com/NixOS/nixpkgs/issues/320084 - throw "zed: nix dev-shell isn't supported on darwin yet." - else let - buildInputs = with pkgs; [ - curl - fontconfig - freetype - libgit2 - openssl - sqlite - zlib - zstd - alsa-lib - libxkbcommon - wayland - xorg.libxcb - vulkan-loader - rustToolchain +pkgs.mkShell rec { + packages = [ + pkgs.clang + pkgs.curl + pkgs.cmake + pkgs.perl + pkgs.pkg-config + pkgs.protobuf + pkgs.rustPlatform.bindgenHook + pkgs.rust-analyzer + ]; + + buildInputs = + [ + pkgs.curl + pkgs.fontconfig + pkgs.freetype + pkgs.libgit2 + pkgs.openssl + pkgs.sqlite + pkgs.zlib + pkgs.zstd + pkgs.rustToolchain + ] + ++ lib.optionals pkgs.stdenv.hostPlatform.isLinux [ + pkgs.alsa-lib + pkgs.libxkbcommon + ] + ++ lib.optional pkgs.stdenv.hostPlatform.isDarwin pkgs.apple-sdk_15; + + # We set SDKROOT and DEVELOPER_DIR to the Xcode ones instead of the nixpkgs ones, + # because we need Swift 6.0 and nixpkgs doesn't have it. + # Xcode is required for development anyways + shellHook = + '' + export LD_LIBRARY_PATH="${lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" + export PROTOC="${pkgs.protobuf}/bin/protoc" + '' + + lib.optionalString pkgs.stdenv.hostPlatform.isDarwin '' + export SDKROOT="/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk"; + export DEVELOPER_DIR="/Applications/Xcode.app/Contents/Developer"; + ''; + + FONTCONFIG_FILE = pkgs.makeFontsConf { + fontDirectories = [ + "./assets/fonts/zed-mono" + "./assets/fonts/zed-sans" ]; - in - pkgs.mkShell.override {inherit stdenv;} { - nativeBuildInputs = with pkgs; [ - clang - curl - cmake - perl - pkg-config - protobuf - rustPlatform.bindgenHook - ]; - - inherit buildInputs; - - shellHook = '' - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath buildInputs}:$LD_LIBRARY_PATH" - export PROTOC="${pkgs.protobuf}/bin/protoc" - ''; - - FONTCONFIG_FILE = pkgs.makeFontsConf { - fontDirectories = [ - "./assets/fonts/zed-mono" - "./assets/fonts/zed-sans" - ]; - }; - ZSTD_SYS_USE_PKG_CONFIG = true; - } + }; + ZSTD_SYS_USE_PKG_CONFIG = true; +} From d0bafce86bf94c3ddafae865896ae31cf89711e9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 27 Nov 2024 20:22:39 +0200 Subject: [PATCH 040/215] Allow splitting the terminal panel (#21238) Closes https://github.com/zed-industries/zed/issues/4351 ![it_splits](https://github.com/user-attachments/assets/40de03c9-2173-4441-ba96-8e91537956e0) Applies the same splitting mechanism, as Zed's central pane has, to the terminal panel. Similar navigation, splitting and (de)serialization capabilities are supported. Notable caveats: * zooming keeps the terminal splits' ratio, rather expanding the terminal pane * on macOs, central panel is split with `cmd-k up/down/etc.` but `cmd-k` is a "standard" terminal clearing keybinding on macOS, so terminal panel splitting is done via `ctrl-k up/down/etc.` * task terminals are "split" into regular terminals, and also not persisted (same as currently in the terminal) Seems ok for the initial version, we can revisit and polish things later. Release Notes: - Added the ability to split the terminal panel --- Cargo.lock | 1 + assets/keymaps/default-macos.json | 6 +- crates/assistant/src/assistant_panel.rs | 1 - crates/editor/src/items.rs | 14 +- crates/gpui/src/app.rs | 2 +- crates/gpui/src/elements/div.rs | 39 +- crates/gpui/src/text_system/line_layout.rs | 26 +- crates/gpui/src/window.rs | 20 +- crates/image_viewer/src/image_viewer.rs | 6 +- crates/terminal_view/Cargo.toml | 1 + crates/terminal_view/src/persistence.rs | 345 +++++++++- crates/terminal_view/src/terminal_panel.rs | 696 +++++++++++++-------- crates/terminal_view/src/terminal_view.rs | 10 +- crates/workspace/src/item.rs | 7 +- crates/workspace/src/pane.rs | 32 +- crates/workspace/src/pane_group.rs | 50 +- crates/workspace/src/workspace.rs | 45 +- 17 files changed, 953 insertions(+), 348 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9da330daa..9e1354c40d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12418,6 +12418,7 @@ name = "terminal_view" version = "0.1.0" dependencies = [ "anyhow", + "async-recursion 1.1.1", "breadcrumbs", "client", "collections", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 514604ef98..f3990cecee 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -732,7 +732,11 @@ "cmd-end": "terminal::ScrollToBottom", "shift-home": "terminal::ScrollToTop", "shift-end": "terminal::ScrollToBottom", - "ctrl-shift-space": "terminal::ToggleViMode" + "ctrl-shift-space": "terminal::ToggleViMode", + "ctrl-k up": "pane::SplitUp", + "ctrl-k down": "pane::SplitDown", + "ctrl-k left": "pane::SplitLeft", + "ctrl-k right": "pane::SplitRight" } } ] diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 7467d5dfd4..79e026cb51 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -416,7 +416,6 @@ impl AssistantPanel { ControlFlow::Break(()) }); - pane.set_can_split(false, cx); pane.set_can_navigate(true, cx); pane.display_nav_history_buttons(None); pane.set_should_display_tab_bar(|_| true); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 51ad9b9dec..813b212761 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -47,7 +47,7 @@ use workspace::item::{BreadcrumbText, FollowEvent}; use workspace::{ item::{FollowableItem, Item, ItemEvent, ProjectItem}, searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle}, - ItemId, ItemNavHistory, Pane, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, + ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId, }; pub const MAX_TAB_TITLE_LEN: usize = 24; @@ -954,7 +954,7 @@ impl SerializableItem for Editor { workspace: WeakView, workspace_id: workspace::WorkspaceId, item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let serialized_editor = match DB .get_serialized_editor(item_id, workspace_id) @@ -989,7 +989,7 @@ impl SerializableItem for Editor { contents: Some(contents), language, .. - } => cx.spawn(|pane, mut cx| { + } => cx.spawn(|mut cx| { let project = project.clone(); async move { let language = if let Some(language_name) = language { @@ -1019,7 +1019,7 @@ impl SerializableItem for Editor { buffer.set_text(contents, cx); })?; - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); @@ -1046,7 +1046,7 @@ impl SerializableItem for Editor { match project_item { Some(project_item) => { - cx.spawn(|pane, mut cx| async move { + cx.spawn(|mut cx| async move { let (_, project_item) = project_item.await?; let buffer = project_item.downcast::().map_err(|_| { anyhow!("Project item at stored path was not a buffer") @@ -1073,7 +1073,7 @@ impl SerializableItem for Editor { })?; } - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| { let mut editor = Editor::for_buffer(buffer, Some(project), cx); @@ -1087,7 +1087,7 @@ impl SerializableItem for Editor { let open_by_abs_path = workspace.update(cx, |workspace, cx| { workspace.open_abs_path(abs_path.clone(), false, cx) }); - cx.spawn(|_, mut cx| async move { + cx.spawn(|mut cx| async move { let editor = open_by_abs_path?.await?.downcast::().with_context(|| format!("Failed to downcast to Editor after opening abs path {abs_path:?}"))?; editor.update(&mut cx, |editor, cx| { editor.read_scroll_position_from_db(item_id, workspace_id, cx); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 0776e5c72e..87ee3942dd 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -1578,7 +1578,7 @@ pub struct AnyDrag { pub view: AnyView, /// The value of the dragged item, to be dropped - pub value: Box, + pub value: Arc, /// This is used to render the dragged item in the same place /// on the original element that the drag was initiated diff --git a/crates/gpui/src/elements/div.rs b/crates/gpui/src/elements/div.rs index 6928ca74ee..909af004a5 100644 --- a/crates/gpui/src/elements/div.rs +++ b/crates/gpui/src/elements/div.rs @@ -35,6 +35,7 @@ use std::{ mem, ops::DerefMut, rc::Rc, + sync::Arc, time::Duration, }; use taffy::style::Overflow; @@ -61,6 +62,7 @@ pub struct DragMoveEvent { /// The bounds of this element. pub bounds: Bounds, drag: PhantomData, + dragged_item: Arc, } impl DragMoveEvent { @@ -71,6 +73,11 @@ impl DragMoveEvent { .and_then(|drag| drag.value.downcast_ref::()) .expect("DragMoveEvent is only valid when the stored active drag is of the same type.") } + + /// An item that is about to be dropped. + pub fn dragged_item(&self) -> &dyn Any { + self.dragged_item.as_ref() + } } impl Interactivity { @@ -243,20 +250,20 @@ impl Interactivity { { self.mouse_move_listeners .push(Box::new(move |event, phase, hitbox, cx| { - if phase == DispatchPhase::Capture - && cx - .active_drag - .as_ref() - .is_some_and(|drag| drag.value.as_ref().type_id() == TypeId::of::()) - { - (listener)( - &DragMoveEvent { - event: event.clone(), - bounds: hitbox.bounds, - drag: PhantomData, - }, - cx, - ); + if phase == DispatchPhase::Capture { + if let Some(drag) = &cx.active_drag { + if drag.value.as_ref().type_id() == TypeId::of::() { + (listener)( + &DragMoveEvent { + event: event.clone(), + bounds: hitbox.bounds, + drag: PhantomData, + dragged_item: Arc::clone(&drag.value), + }, + cx, + ); + } + } } })); } @@ -454,7 +461,7 @@ impl Interactivity { "calling on_drag more than once on the same element is not supported" ); self.drag_listener = Some(( - Box::new(value), + Arc::new(value), Box::new(move |value, offset, cx| { constructor(value.downcast_ref().unwrap(), offset, cx).into() }), @@ -1292,7 +1299,7 @@ pub struct Interactivity { pub(crate) drop_listeners: Vec<(TypeId, DropListener)>, pub(crate) can_drop_predicate: Option, pub(crate) click_listeners: Vec, - pub(crate) drag_listener: Option<(Box, DragListener)>, + pub(crate) drag_listener: Option<(Arc, DragListener)>, pub(crate) hover_listener: Option>, pub(crate) tooltip_builder: Option, pub(crate) occlude_mouse: bool, diff --git a/crates/gpui/src/text_system/line_layout.rs b/crates/gpui/src/text_system/line_layout.rs index 66eb914a30..13a7896a3f 100644 --- a/crates/gpui/src/text_system/line_layout.rs +++ b/crates/gpui/src/text_system/line_layout.rs @@ -385,20 +385,28 @@ impl LineLayoutCache { let mut previous_frame = &mut *self.previous_frame.lock(); let mut current_frame = &mut *self.current_frame.write(); - for key in &previous_frame.used_lines[range.start.lines_index..range.end.lines_index] { - if let Some((key, line)) = previous_frame.lines.remove_entry(key) { - current_frame.lines.insert(key, line); + if let Some(cached_keys) = previous_frame + .used_lines + .get(range.start.lines_index..range.end.lines_index) + { + for key in cached_keys { + if let Some((key, line)) = previous_frame.lines.remove_entry(key) { + current_frame.lines.insert(key, line); + } + current_frame.used_lines.push(key.clone()); } - current_frame.used_lines.push(key.clone()); } - for key in &previous_frame.used_wrapped_lines - [range.start.wrapped_lines_index..range.end.wrapped_lines_index] + if let Some(cached_keys) = previous_frame + .used_wrapped_lines + .get(range.start.wrapped_lines_index..range.end.wrapped_lines_index) { - if let Some((key, line)) = previous_frame.wrapped_lines.remove_entry(key) { - current_frame.wrapped_lines.insert(key, line); + for key in cached_keys { + if let Some((key, line)) = previous_frame.wrapped_lines.remove_entry(key) { + current_frame.wrapped_lines.insert(key, line); + } + current_frame.used_wrapped_lines.push(key.clone()); } - current_frame.used_wrapped_lines.push(key.clone()); } } diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index c1c14edba2..902c699cb7 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1752,12 +1752,18 @@ impl<'a> WindowContext<'a> { .iter_mut() .map(|listener| listener.take()), ); - window.next_frame.accessed_element_states.extend( - window.rendered_frame.accessed_element_states[range.start.accessed_element_states_index - ..range.end.accessed_element_states_index] - .iter() - .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), - ); + if let Some(element_states) = window + .rendered_frame + .accessed_element_states + .get(range.start.accessed_element_states_index..range.end.accessed_element_states_index) + { + window.next_frame.accessed_element_states.extend( + element_states + .iter() + .map(|(id, type_id)| (GlobalElementId(id.0.clone()), *type_id)), + ); + } + window .text_system .reuse_layouts(range.start.line_layout_index..range.end.line_layout_index); @@ -3126,7 +3132,7 @@ impl<'a> WindowContext<'a> { self.window.mouse_position = position; if self.active_drag.is_none() { self.active_drag = Some(AnyDrag { - value: Box::new(paths.clone()), + value: Arc::new(paths.clone()), view: self.new_view(|_| paths).into(), cursor_offset: position, }); diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index 1d03e77e76..ed87562e64 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -16,7 +16,7 @@ use settings::Settings; use util::paths::PathExt; use workspace::{ item::{BreadcrumbText, Item, ProjectItem, SerializableItem, TabContentParams}, - ItemId, ItemSettings, Pane, ToolbarItemLocation, Workspace, WorkspaceId, + ItemId, ItemSettings, ToolbarItemLocation, Workspace, WorkspaceId, }; const IMAGE_VIEWER_KIND: &str = "ImageView"; @@ -172,9 +172,9 @@ impl SerializableItem for ImageView { _workspace: WeakView, workspace_id: WorkspaceId, item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { - cx.spawn(|_pane, mut cx| async move { + cx.spawn(|mut cx| async move { let image_path = IMAGE_VIEWER .get_image_path(item_id, workspace_id)? .ok_or_else(|| anyhow::anyhow!("No image path found"))?; diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index e57d9d1fc6..7e4a4fe76f 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -14,6 +14,7 @@ doctest = false [dependencies] anyhow.workspace = true +async-recursion.workspace = true breadcrumbs.workspace = true collections.workspace = true db.workspace = true diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b8c31e05b0..dd430963d2 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -1,8 +1,351 @@ use anyhow::Result; +use async_recursion::async_recursion; +use collections::HashSet; +use futures::{stream::FuturesUnordered, StreamExt as _}; +use gpui::{AsyncWindowContext, Axis, Model, Task, View, WeakView}; +use project::{terminals::TerminalKind, Project}; +use serde::{Deserialize, Serialize}; use std::path::PathBuf; +use ui::{Pixels, ViewContext, VisualContext as _, WindowContext}; +use util::ResultExt as _; use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; -use workspace::{ItemId, WorkspaceDb, WorkspaceId}; +use workspace::{ + ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, + WorkspaceDb, WorkspaceId, +}; + +use crate::{ + default_working_directory, + terminal_panel::{new_terminal_pane, TerminalPanel}, + TerminalView, +}; + +pub(crate) fn serialize_pane_group( + pane_group: &PaneGroup, + active_pane: &View, + cx: &WindowContext, +) -> SerializedPaneGroup { + build_serialized_pane_group(&pane_group.root, active_pane, cx) +} + +fn build_serialized_pane_group( + pane_group: &Member, + active_pane: &View, + cx: &WindowContext, +) -> SerializedPaneGroup { + match pane_group { + Member::Axis(PaneAxis { + axis, + members, + flexes, + bounding_boxes: _, + }) => SerializedPaneGroup::Group { + axis: SerializedAxis(*axis), + children: members + .iter() + .map(|member| build_serialized_pane_group(member, active_pane, cx)) + .collect::>(), + flexes: Some(flexes.lock().clone()), + }, + Member::Pane(pane_handle) => { + SerializedPaneGroup::Pane(serialize_pane(pane_handle, pane_handle == active_pane, cx)) + } + } +} + +fn serialize_pane(pane: &View, active: bool, cx: &WindowContext) -> SerializedPane { + let mut items_to_serialize = HashSet::default(); + let pane = pane.read(cx); + let children = pane + .items() + .filter_map(|item| { + let terminal_view = item.act_as::(cx)?; + if terminal_view.read(cx).terminal().read(cx).task().is_some() { + None + } else { + let id = item.item_id().as_u64(); + items_to_serialize.insert(id); + Some(id) + } + }) + .collect::>(); + let active_item = pane + .active_item() + .map(|item| item.item_id().as_u64()) + .filter(|active_id| items_to_serialize.contains(active_id)); + + SerializedPane { + active, + children, + active_item, + } +} + +pub(crate) fn deserialize_terminal_panel( + workspace: WeakView, + project: Model, + database_id: WorkspaceId, + serialized_panel: SerializedTerminalPanel, + cx: &mut WindowContext, +) -> Task>> { + cx.spawn(move |mut cx| async move { + let terminal_panel = workspace.update(&mut cx, |workspace, cx| { + cx.new_view(|cx| { + let mut panel = TerminalPanel::new(workspace, cx); + panel.height = serialized_panel.height.map(|h| h.round()); + panel.width = serialized_panel.width.map(|w| w.round()); + panel + }) + })?; + match &serialized_panel.items { + SerializedItems::NoSplits(item_ids) => { + let items = deserialize_terminal_views( + database_id, + project, + workspace, + item_ids.as_slice(), + &mut cx, + ) + .await; + let active_item = serialized_panel.active_item_id; + terminal_panel.update(&mut cx, |terminal_panel, cx| { + terminal_panel.active_pane.update(cx, |pane, cx| { + populate_pane_items(pane, items, active_item, cx); + }); + })?; + } + SerializedItems::WithSplits(serialized_pane_group) => { + let center_pane = deserialize_pane_group( + workspace, + project, + terminal_panel.clone(), + database_id, + serialized_pane_group, + &mut cx, + ) + .await; + if let Some((center_group, active_pane)) = center_pane { + terminal_panel.update(&mut cx, |terminal_panel, _| { + terminal_panel.center = PaneGroup::with_root(center_group); + terminal_panel.active_pane = + active_pane.unwrap_or_else(|| terminal_panel.center.first_pane()); + })?; + } + } + } + + Ok(terminal_panel) + }) +} + +fn populate_pane_items( + pane: &mut Pane, + items: Vec>, + active_item: Option, + cx: &mut ViewContext<'_, Pane>, +) { + let mut item_index = pane.items_len(); + for item in items { + let activate_item = Some(item.item_id().as_u64()) == active_item; + pane.add_item(Box::new(item), false, false, None, cx); + item_index += 1; + if activate_item { + pane.activate_item(item_index, false, false, cx); + } + } +} + +#[async_recursion(?Send)] +async fn deserialize_pane_group( + workspace: WeakView, + project: Model, + panel: View, + workspace_id: WorkspaceId, + serialized: &SerializedPaneGroup, + cx: &mut AsyncWindowContext, +) -> Option<(Member, Option>)> { + match serialized { + SerializedPaneGroup::Group { + axis, + flexes, + children, + } => { + let mut current_active_pane = None; + let mut members = Vec::new(); + for child in children { + if let Some((new_member, active_pane)) = deserialize_pane_group( + workspace.clone(), + project.clone(), + panel.clone(), + workspace_id, + child, + cx, + ) + .await + { + members.push(new_member); + current_active_pane = current_active_pane.or(active_pane); + } + } + + if members.is_empty() { + return None; + } + + if members.len() == 1 { + return Some((members.remove(0), current_active_pane)); + } + + Some(( + Member::Axis(PaneAxis::load(axis.0, members, flexes.clone())), + current_active_pane, + )) + } + SerializedPaneGroup::Pane(serialized_pane) => { + let active = serialized_pane.active; + let new_items = deserialize_terminal_views( + workspace_id, + project.clone(), + workspace.clone(), + serialized_pane.children.as_slice(), + cx, + ) + .await; + + let pane = panel + .update(cx, |_, cx| { + new_terminal_pane(workspace.clone(), project.clone(), cx) + }) + .log_err()?; + let active_item = serialized_pane.active_item; + pane.update(cx, |pane, cx| { + populate_pane_items(pane, new_items, active_item, cx); + // Avoid blank panes in splits + if pane.items_len() == 0 { + let working_directory = workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + .ok() + .flatten(); + let kind = TerminalKind::Shell(working_directory); + let window = cx.window_handle(); + let terminal = project + .update(cx, |project, cx| project.create_terminal(kind, window, cx)) + .log_err()?; + let terminal_view = Box::new(cx.new_view(|cx| { + TerminalView::new( + terminal.clone(), + workspace.clone(), + Some(workspace_id), + cx, + ) + })); + pane.add_item(terminal_view, true, false, None, cx); + } + Some(()) + }) + .ok() + .flatten()?; + Some((Member::Pane(pane.clone()), active.then_some(pane))) + } + } +} + +async fn deserialize_terminal_views( + workspace_id: WorkspaceId, + project: Model, + workspace: WeakView, + item_ids: &[u64], + cx: &mut AsyncWindowContext, +) -> Vec> { + let mut items = Vec::with_capacity(item_ids.len()); + let mut deserialized_items = item_ids + .iter() + .map(|item_id| { + cx.update(|cx| { + TerminalView::deserialize( + project.clone(), + workspace.clone(), + workspace_id, + *item_id, + cx, + ) + }) + .unwrap_or_else(|e| Task::ready(Err(e.context("no window present")))) + }) + .collect::>(); + while let Some(item) = deserialized_items.next().await { + if let Some(item) = item.log_err() { + items.push(item); + } + } + items +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedTerminalPanel { + pub items: SerializedItems, + // A deprecated field, kept for backwards compatibility for the code before terminal splits were introduced. + pub active_item_id: Option, + pub width: Option, + pub height: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +pub(crate) enum SerializedItems { + // The data stored before terminal splits were introduced. + NoSplits(Vec), + WithSplits(SerializedPaneGroup), +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) enum SerializedPaneGroup { + Pane(SerializedPane), + Group { + axis: SerializedAxis, + flexes: Option>, + children: Vec, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct SerializedPane { + pub active: bool, + pub children: Vec, + pub active_item: Option, +} + +#[derive(Debug)] +pub(crate) struct SerializedAxis(pub Axis); + +impl Serialize for SerializedAxis { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self.0 { + Axis::Horizontal => serializer.serialize_str("horizontal"), + Axis::Vertical => serializer.serialize_str("vertical"), + } + } +} + +impl<'de> Deserialize<'de> for SerializedAxis { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "horizontal" => Ok(SerializedAxis(Axis::Horizontal)), + "vertical" => Ok(SerializedAxis(Axis::Vertical)), + invalid => Err(serde::de::Error::custom(format!( + "Invalid axis value: '{invalid}'" + ))), + } + } +} define_connection! { pub static ref TERMINAL_DB: TerminalDb = diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index ee10e924f4..38b2eda676 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -1,19 +1,24 @@ -use std::{ops::ControlFlow, path::PathBuf, sync::Arc}; +use std::{cmp, ops::ControlFlow, path::PathBuf, sync::Arc, time::Duration}; -use crate::{default_working_directory, TerminalView}; +use crate::{ + default_working_directory, + persistence::{ + deserialize_terminal_panel, serialize_pane_group, SerializedItems, SerializedTerminalPanel, + }, + TerminalView, +}; use breadcrumbs::Breadcrumbs; -use collections::{HashMap, HashSet}; +use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use futures::future::join_all; use gpui::{ - actions, Action, AnchorCorner, AnyView, AppContext, AsyncWindowContext, Entity, EventEmitter, + actions, Action, AnchorCorner, AnyView, AppContext, AsyncWindowContext, EventEmitter, ExternalPaths, FocusHandle, FocusableView, IntoElement, Model, ParentElement, Pixels, Render, - Styled, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, + Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use itertools::Itertools; -use project::{terminals::TerminalKind, Fs, ProjectEntryId}; +use project::{terminals::TerminalKind, Fs, Project, ProjectEntryId}; use search::{buffer_search::DivRegistrar, BufferSearchBar}; -use serde::{Deserialize, Serialize}; use settings::Settings; use task::{RevealStrategy, Shell, SpawnInTerminal, TaskId}; use terminal::{ @@ -21,16 +26,18 @@ use terminal::{ Terminal, }; use ui::{ - h_flex, ButtonCommon, Clickable, ContextMenu, IconButton, IconSize, PopoverMenu, Selectable, - Tooltip, + div, h_flex, ButtonCommon, Clickable, ContextMenu, IconButton, IconSize, InteractiveElement, + PopoverMenu, Selectable, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, item::SerializableItem, - pane, + move_item, pane, ui::IconName, - DraggedTab, ItemId, NewTerminal, Pane, ToggleZoom, Workspace, + ActivateNextPane, ActivatePane, ActivatePaneInDirection, ActivatePreviousPane, DraggedTab, + ItemId, NewTerminal, Pane, PaneGroup, SplitDirection, SwapPaneInDirection, ToggleZoom, + Workspace, }; use anyhow::Result; @@ -60,14 +67,14 @@ pub fn init(cx: &mut AppContext) { } pub struct TerminalPanel { - pane: View, + pub(crate) active_pane: View, + pub(crate) center: PaneGroup, fs: Arc, workspace: WeakView, - width: Option, - height: Option, + pub(crate) width: Option, + pub(crate) height: Option, pending_serialization: Task>, pending_terminals_to_add: usize, - _subscriptions: Vec, deferred_tasks: HashMap>, enabled: bool, assistant_enabled: bool, @@ -75,85 +82,14 @@ pub struct TerminalPanel { } impl TerminalPanel { - fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { - let pane = cx.new_view(|cx| { - let mut pane = Pane::new( - workspace.weak_handle(), - workspace.project().clone(), - Default::default(), - None, - NewTerminal.boxed_clone(), - cx, - ); - pane.set_can_split(false, cx); - pane.set_can_navigate(false, cx); - pane.display_nav_history_buttons(None); - pane.set_should_display_tab_bar(|_| true); - - let is_local = workspace.project().read(cx).is_local(); - let workspace = workspace.weak_handle(); - pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { - if let Some(tab) = dropped_item.downcast_ref::() { - let item = if &tab.pane == cx.view() { - pane.item_for_index(tab.ix) - } else { - tab.pane.read(cx).item_for_index(tab.ix) - }; - if let Some(item) = item { - if item.downcast::().is_some() { - return ControlFlow::Continue(()); - } else if let Some(project_path) = item.project_path(cx) { - if let Some(entry_path) = workspace - .update(cx, |workspace, cx| { - workspace - .project() - .read(cx) - .absolute_path(&project_path, cx) - }) - .log_err() - .flatten() - { - add_paths_to_terminal(pane, &[entry_path], cx); - } - } - } - } else if let Some(&entry_id) = dropped_item.downcast_ref::() { - if let Some(entry_path) = workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - project - .path_for_entry(entry_id, cx) - .and_then(|project_path| project.absolute_path(&project_path, cx)) - }) - .log_err() - .flatten() - { - add_paths_to_terminal(pane, &[entry_path], cx); - } - } else if is_local { - if let Some(paths) = dropped_item.downcast_ref::() { - add_paths_to_terminal(pane, paths.paths(), cx); - } - } - - ControlFlow::Break(()) - }); - let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); - let breadcrumbs = cx.new_view(|_| Breadcrumbs::new()); - pane.toolbar().update(cx, |toolbar, cx| { - toolbar.add_item(buffer_search_bar, cx); - toolbar.add_item(breadcrumbs, cx); - }); - pane - }); - let subscriptions = vec![ - cx.observe(&pane, |_, _, cx| cx.notify()), - cx.subscribe(&pane, Self::handle_pane_event), - ]; - let project = workspace.project().read(cx); - let enabled = project.supports_terminal(cx); - let this = Self { - pane, + pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { + let project = workspace.project(); + let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), cx); + let center = PaneGroup::new(pane.clone()); + let enabled = project.read(cx).supports_terminal(cx); + let terminal_panel = Self { + center, + active_pane: pane, fs: workspace.app_state().fs.clone(), workspace: workspace.weak_handle(), pending_serialization: Task::ready(None), @@ -161,20 +97,19 @@ impl TerminalPanel { height: None, pending_terminals_to_add: 0, deferred_tasks: HashMap::default(), - _subscriptions: subscriptions, enabled, assistant_enabled: false, assistant_tab_bar_button: None, }; - this.apply_tab_bar_buttons(cx); - this + terminal_panel.apply_tab_bar_buttons(&terminal_panel.active_pane, cx); + terminal_panel } pub fn asssistant_enabled(&mut self, enabled: bool, cx: &mut ViewContext) { self.assistant_enabled = enabled; if enabled { let focus_handle = self - .pane + .active_pane .read(cx) .active_item() .map(|item| item.focus_handle(cx)) @@ -186,12 +121,14 @@ impl TerminalPanel { } else { self.assistant_tab_bar_button = None; } - self.apply_tab_bar_buttons(cx); + for pane in self.center.panes() { + self.apply_tab_bar_buttons(pane, cx); + } } - fn apply_tab_bar_buttons(&self, cx: &mut ViewContext) { + fn apply_tab_bar_buttons(&self, terminal_pane: &View, cx: &mut ViewContext) { let assistant_tab_bar_button = self.assistant_tab_bar_button.clone(); - self.pane.update(cx, |pane, cx| { + terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, cx| { if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { return (None, None); @@ -268,80 +205,45 @@ impl TerminalPanel { .log_err() .flatten(); - let (panel, pane, items) = workspace.update(&mut cx, |workspace, cx| { - let panel = cx.new_view(|cx| TerminalPanel::new(workspace, cx)); - let items = if let Some((serialized_panel, database_id)) = - serialized_panel.as_ref().zip(workspace.database_id()) - { - panel.update(cx, |panel, cx| { - cx.notify(); - panel.height = serialized_panel.height.map(|h| h.round()); - panel.width = serialized_panel.width.map(|w| w.round()); - panel.pane.update(cx, |_, cx| { - serialized_panel - .items - .iter() - .map(|item_id| { - TerminalView::deserialize( - workspace.project().clone(), - workspace.weak_handle(), - database_id, - *item_id, - cx, - ) - }) - .collect::>() - }) - }) - } else { - Vec::new() - }; - let pane = panel.read(cx).pane.clone(); - (panel, pane, items) - })?; + let terminal_panel = workspace + .update(&mut cx, |workspace, cx| { + match serialized_panel.zip(workspace.database_id()) { + Some((serialized_panel, database_id)) => deserialize_terminal_panel( + workspace.weak_handle(), + workspace.project().clone(), + database_id, + serialized_panel, + cx, + ), + None => Task::ready(Ok(cx.new_view(|cx| TerminalPanel::new(workspace, cx)))), + } + })? + .await?; if let Some(workspace) = workspace.upgrade() { - panel - .update(&mut cx, |panel, cx| { - panel._subscriptions.push(cx.subscribe( - &workspace, - |terminal_panel, _, e, cx| { - if let workspace::Event::SpawnTask(spawn_in_terminal) = e { - terminal_panel.spawn_task(spawn_in_terminal, cx); - }; - }, - )) + terminal_panel + .update(&mut cx, |_, cx| { + cx.subscribe(&workspace, |terminal_panel, _, e, cx| { + if let workspace::Event::SpawnTask(spawn_in_terminal) = e { + terminal_panel.spawn_task(spawn_in_terminal, cx); + }; + }) + .detach(); }) .ok(); } - let pane = pane.downgrade(); - let items = futures::future::join_all(items).await; - let mut alive_item_ids = Vec::new(); - pane.update(&mut cx, |pane, cx| { - let active_item_id = serialized_panel - .as_ref() - .and_then(|panel| panel.active_item_id); - let mut active_ix = None; - for item in items { - if let Some(item) = item.log_err() { - let item_id = item.entity_id().as_u64(); - pane.add_item(Box::new(item), false, false, None, cx); - alive_item_ids.push(item_id as ItemId); - if Some(item_id) == active_item_id { - active_ix = Some(pane.items_len() - 1); - } - } - } - - if let Some(active_ix) = active_ix { - pane.activate_item(active_ix, false, false, cx) - } - })?; - // Since panels/docks are loaded outside from the workspace, we cleanup here, instead of through the workspace. if let Some(workspace) = workspace.upgrade() { let cleanup_task = workspace.update(&mut cx, |workspace, cx| { + let alive_item_ids = terminal_panel + .read(cx) + .center + .panes() + .into_iter() + .flat_map(|pane| pane.read(cx).items()) + .map(|item| item.item_id().as_u64() as ItemId) + .collect(); workspace .database_id() .map(|workspace_id| TerminalView::cleanup(workspace_id, alive_item_ids, cx)) @@ -351,33 +253,92 @@ impl TerminalPanel { } } - Ok(panel) + Ok(terminal_panel) } fn handle_pane_event( &mut self, - _pane: View, + pane: View, event: &pane::Event, cx: &mut ViewContext, ) { match event { pane::Event::ActivateItem { .. } => self.serialize(cx), pane::Event::RemovedItem { .. } => self.serialize(cx), - pane::Event::Remove { .. } => cx.emit(PanelEvent::Close), + pane::Event::Remove { focus_on_pane } => { + let pane_count_before_removal = self.center.panes().len(); + let _removal_result = self.center.remove(&pane); + if pane_count_before_removal == 1 { + cx.emit(PanelEvent::Close); + } else { + if let Some(focus_on_pane) = + focus_on_pane.as_ref().or_else(|| self.center.panes().pop()) + { + focus_on_pane.focus_handle(cx).focus(cx); + } + } + } pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), - pane::Event::AddItem { item } => { if let Some(workspace) = self.workspace.upgrade() { - let pane = self.pane.clone(); - workspace.update(cx, |workspace, cx| item.added_to_pane(workspace, pane, cx)) + workspace.update(cx, |workspace, cx| { + item.added_to_pane(workspace, pane.clone(), cx) + }) } } + pane::Event::Split(direction) => { + let Some(new_pane) = self.new_pane_with_cloned_active_terminal(cx) else { + return; + }; + self.center.split(&pane, &new_pane, *direction).log_err(); + } + pane::Event::Focus => { + self.active_pane = pane.clone(); + } _ => {} } } + fn new_pane_with_cloned_active_terminal( + &mut self, + cx: &mut ViewContext, + ) -> Option> { + let workspace = self.workspace.clone().upgrade()?; + let project = workspace.read(cx).project().clone(); + let working_directory = self + .active_pane + .read(cx) + .active_item() + .and_then(|item| item.downcast::()) + .and_then(|terminal_view| { + terminal_view + .read(cx) + .terminal() + .read(cx) + .working_directory() + }) + .or_else(|| default_working_directory(workspace.read(cx), cx)); + let kind = TerminalKind::Shell(working_directory); + let window = cx.window_handle(); + let terminal = project + .update(cx, |project, cx| project.create_terminal(kind, window, cx)) + .log_err()?; + let database_id = workspace.read(cx).database_id(); + let terminal_view = Box::new(cx.new_view(|cx| { + TerminalView::new(terminal.clone(), self.workspace.clone(), database_id, cx) + })); + let pane = new_terminal_pane(self.workspace.clone(), project, cx); + self.apply_tab_bar_buttons(&pane, cx); + pane.update(cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, cx); + }); + cx.focus_view(&pane); + + Some(pane) + } + pub fn open_terminal( workspace: &mut Workspace, action: &workspace::OpenTerminal, @@ -494,7 +455,7 @@ impl TerminalPanel { .detach_and_log_err(cx); return; } - let (existing_item_index, existing_terminal) = terminals_for_task + let (existing_item_index, task_pane, existing_terminal) = terminals_for_task .last() .expect("covered no terminals case above") .clone(); @@ -503,7 +464,13 @@ impl TerminalPanel { !use_new_terminal, "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" ); - self.replace_terminal(spawn_task, existing_item_index, existing_terminal, cx); + self.replace_terminal( + spawn_task, + task_pane, + existing_item_index, + existing_terminal, + cx, + ); } else { self.deferred_tasks.insert( spawn_in_terminal.id.clone(), @@ -518,6 +485,7 @@ impl TerminalPanel { } else { terminal_panel.replace_terminal( spawn_task, + task_pane, existing_item_index, existing_terminal, cx, @@ -562,25 +530,36 @@ impl TerminalPanel { &self, label: &str, cx: &mut AppContext, - ) -> Vec<(usize, View)> { - self.pane - .read(cx) - .items() - .enumerate() - .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) - .filter_map(|(index, terminal_view)| { - let task_state = terminal_view.read(cx).terminal().read(cx).task()?; - if &task_state.full_label == label { - Some((index, terminal_view)) - } else { - None - } + ) -> Vec<(usize, View, View)> { + self.center + .panes() + .into_iter() + .flat_map(|pane| { + pane.read(cx) + .items() + .enumerate() + .filter_map(|(index, item)| Some((index, item.act_as::(cx)?))) + .filter_map(|(index, terminal_view)| { + let task_state = terminal_view.read(cx).terminal().read(cx).task()?; + if &task_state.full_label == label { + Some((index, terminal_view)) + } else { + None + } + }) + .map(|(index, terminal_view)| (index, pane.clone(), terminal_view)) }) .collect() } - fn activate_terminal_view(&self, item_index: usize, focus: bool, cx: &mut WindowContext) { - self.pane.update(cx, |pane, cx| { + fn activate_terminal_view( + &self, + pane: &View, + item_index: usize, + focus: bool, + cx: &mut WindowContext, + ) { + pane.update(cx, |pane, cx| { pane.activate_item(item_index, true, focus, cx) }) } @@ -601,7 +580,7 @@ impl TerminalPanel { self.pending_terminals_to_add += 1; cx.spawn(|terminal_panel, mut cx| async move { - let pane = terminal_panel.update(&mut cx, |this, _| this.pane.clone())?; + let pane = terminal_panel.update(&mut cx, |this, _| this.active_pane.clone())?; let result = workspace.update(&mut cx, |workspace, cx| { let window = cx.window_handle(); let terminal = workspace @@ -640,52 +619,49 @@ impl TerminalPanel { } fn serialize(&mut self, cx: &mut ViewContext) { - let mut items_to_serialize = HashSet::default(); - let items = self - .pane - .read(cx) - .items() - .filter_map(|item| { - let terminal_view = item.act_as::(cx)?; - if terminal_view.read(cx).terminal().read(cx).task().is_some() { - None - } else { - let id = item.item_id().as_u64(); - items_to_serialize.insert(id); - Some(id) - } - }) - .collect::>(); - let active_item_id = self - .pane - .read(cx) - .active_item() - .map(|item| item.item_id().as_u64()) - .filter(|active_id| items_to_serialize.contains(active_id)); let height = self.height; let width = self.width; - self.pending_serialization = cx.background_executor().spawn( - async move { - KEY_VALUE_STORE - .write_kvp( - TERMINAL_PANEL_KEY.into(), - serde_json::to_string(&SerializedTerminalPanel { - items, - active_item_id, - height, - width, - })?, - ) - .await?; - anyhow::Ok(()) - } - .log_err(), - ); + self.pending_serialization = cx.spawn(|terminal_panel, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + let terminal_panel = terminal_panel.upgrade()?; + let items = terminal_panel + .update(&mut cx, |terminal_panel, cx| { + SerializedItems::WithSplits(serialize_pane_group( + &terminal_panel.center, + &terminal_panel.active_pane, + cx, + )) + }) + .ok()?; + cx.background_executor() + .spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + TERMINAL_PANEL_KEY.into(), + serde_json::to_string(&SerializedTerminalPanel { + items, + active_item_id: None, + height, + width, + })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ) + .await; + Some(()) + }); } fn replace_terminal( &self, spawn_task: SpawnInTerminal, + task_pane: View, terminal_item_index: usize, terminal_to_replace: View, cx: &mut ViewContext<'_, Self>, @@ -708,7 +684,7 @@ impl TerminalPanel { match reveal { RevealStrategy::Always => { - self.activate_terminal_view(terminal_item_index, true, cx); + self.activate_terminal_view(&task_pane, terminal_item_index, true, cx); let task_workspace = self.workspace.clone(); cx.spawn(|_, mut cx| async move { task_workspace @@ -718,7 +694,7 @@ impl TerminalPanel { .detach(); } RevealStrategy::NoFocus => { - self.activate_terminal_view(terminal_item_index, false, cx); + self.activate_terminal_view(&task_pane, terminal_item_index, false, cx); let task_workspace = self.workspace.clone(); cx.spawn(|_, mut cx| async move { task_workspace @@ -734,7 +710,7 @@ impl TerminalPanel { } fn has_no_terminals(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 + self.active_pane.read(cx).items_len() == 0 && self.pending_terminals_to_add == 0 } pub fn assistant_enabled(&self) -> bool { @@ -742,11 +718,149 @@ impl TerminalPanel { } } +pub fn new_terminal_pane( + workspace: WeakView, + project: Model, + cx: &mut ViewContext, +) -> View { + let is_local = project.read(cx).is_local(); + let terminal_panel = cx.view().clone(); + let pane = cx.new_view(|cx| { + let mut pane = Pane::new( + workspace.clone(), + project.clone(), + Default::default(), + None, + NewTerminal.boxed_clone(), + cx, + ); + pane.set_can_navigate(false, cx); + pane.display_nav_history_buttons(None); + pane.set_should_display_tab_bar(|_| true); + + let terminal_panel_for_split_check = terminal_panel.clone(); + pane.set_can_split(Some(Arc::new(move |pane, dragged_item, cx| { + if let Some(tab) = dragged_item.downcast_ref::() { + let current_pane = cx.view().clone(); + let can_drag_away = + terminal_panel_for_split_check.update(cx, |terminal_panel, _| { + let current_panes = terminal_panel.center.panes(); + !current_panes.contains(&&tab.pane) + || current_panes.len() > 1 + || (tab.pane != current_pane || pane.items_len() > 1) + }); + if can_drag_away { + let item = if tab.pane == current_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + return item.downcast::().is_some(); + } + } + } + false + }))); + + let buffer_search_bar = cx.new_view(search::BufferSearchBar::new); + let breadcrumbs = cx.new_view(|_| Breadcrumbs::new()); + pane.toolbar().update(cx, |toolbar, cx| { + toolbar.add_item(buffer_search_bar, cx); + toolbar.add_item(breadcrumbs, cx); + }); + + pane.set_custom_drop_handle(cx, move |pane, dropped_item, cx| { + if let Some(tab) = dropped_item.downcast_ref::() { + let this_pane = cx.view().clone(); + let belongs_to_this_pane = tab.pane == this_pane; + let item = if belongs_to_this_pane { + pane.item_for_index(tab.ix) + } else { + tab.pane.read(cx).item_for_index(tab.ix) + }; + if let Some(item) = item { + if item.downcast::().is_some() { + let source = tab.pane.clone(); + let item_id_to_move = item.item_id(); + + let new_pane = pane.drag_split_direction().and_then(|split_direction| { + terminal_panel.update(cx, |terminal_panel, cx| { + let new_pane = + new_terminal_pane(workspace.clone(), project.clone(), cx); + terminal_panel.apply_tab_bar_buttons(&new_pane, cx); + terminal_panel + .center + .split(&this_pane, &new_pane, split_direction) + .log_err()?; + Some(new_pane) + }) + }); + + let destination; + let destination_index; + if let Some(new_pane) = new_pane { + destination_index = new_pane.read(cx).active_item_index(); + destination = new_pane; + } else if belongs_to_this_pane { + return ControlFlow::Break(()); + } else { + destination = cx.view().clone(); + destination_index = pane.active_item_index(); + } + // Destination pane may be the one currently updated, so defer the move. + cx.spawn(|_, mut cx| async move { + cx.update(|cx| { + move_item( + &source, + &destination, + item_id_to_move, + destination_index, + cx, + ); + }) + .ok(); + }) + .detach(); + } else if let Some(project_path) = item.project_path(cx) { + if let Some(entry_path) = project.read(cx).absolute_path(&project_path, cx) + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } + } + } else if let Some(&entry_id) = dropped_item.downcast_ref::() { + if let Some(entry_path) = project + .read(cx) + .path_for_entry(entry_id, cx) + .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx)) + { + add_paths_to_terminal(pane, &[entry_path], cx); + } + } else if is_local { + if let Some(paths) = dropped_item.downcast_ref::() { + add_paths_to_terminal(pane, paths.paths(), cx); + } + } + + ControlFlow::Break(()) + }); + + pane + }); + + cx.subscribe(&pane, TerminalPanel::handle_pane_event) + .detach(); + cx.observe(&pane, |_, _, cx| cx.notify()).detach(); + + pane +} + async fn wait_for_terminals_tasks( - terminals_for_task: Vec<(usize, View)>, + terminals_for_task: Vec<(usize, View, View)>, cx: &mut AsyncWindowContext, ) { - let pending_tasks = terminals_for_task.iter().filter_map(|(_, terminal)| { + let pending_tasks = terminals_for_task.iter().filter_map(|(_, _, terminal)| { terminal .update(cx, |terminal_view, cx| { terminal_view @@ -781,7 +895,7 @@ impl Render for TerminalPanel { let mut registrar = DivRegistrar::new( |panel, cx| { panel - .pane + .active_pane .read(cx) .toolbar() .read(cx) @@ -790,13 +904,99 @@ impl Render for TerminalPanel { cx, ); BufferSearchBar::register(&mut registrar); - registrar.into_div().size_full().child(self.pane.clone()) + let registrar = registrar.into_div(); + self.workspace + .update(cx, |workspace, cx| { + registrar.size_full().child(self.center.render( + workspace.project(), + &HashMap::default(), + None, + &self.active_pane, + workspace.zoomed_item(), + workspace.app_state(), + cx, + )) + }) + .ok() + .map(|div| { + div.on_action({ + cx.listener(|terminal_panel, action: &ActivatePaneInDirection, cx| { + if let Some(pane) = terminal_panel.center.find_pane_in_direction( + &terminal_panel.active_pane, + action.0, + cx, + ) { + cx.focus_view(&pane); + } + }) + }) + .on_action( + cx.listener(|terminal_panel, _action: &ActivateNextPane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let next_ix = (ix + 1) % panes.len(); + let next_pane = panes[next_ix].clone(); + cx.focus_view(&next_pane); + } + }), + ) + .on_action( + cx.listener(|terminal_panel, _action: &ActivatePreviousPane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(ix) = panes + .iter() + .position(|pane| **pane == terminal_panel.active_pane) + { + let prev_ix = cmp::min(ix.wrapping_sub(1), panes.len() - 1); + let prev_pane = panes[prev_ix].clone(); + cx.focus_view(&prev_pane); + } + }), + ) + .on_action(cx.listener(|terminal_panel, action: &ActivatePane, cx| { + let panes = terminal_panel.center.panes(); + if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { + cx.focus_view(&pane); + } else { + if let Some(new_pane) = + terminal_panel.new_pane_with_cloned_active_terminal(cx) + { + terminal_panel + .center + .split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + ) + .log_err(); + } + } + })) + .on_action(cx.listener( + |terminal_panel, action: &SwapPaneInDirection, cx| { + if let Some(to) = terminal_panel + .center + .find_pane_in_direction(&terminal_panel.active_pane, action.0, cx) + .cloned() + { + terminal_panel + .center + .swap(&terminal_panel.active_pane.clone(), &to); + cx.notify(); + } + }, + )) + }) + .unwrap_or_else(|| div()) } } impl FocusableView for TerminalPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.pane.focus_handle(cx) + self.active_pane.focus_handle(cx) } } @@ -848,11 +1048,12 @@ impl Panel for TerminalPanel { } fn is_zoomed(&self, cx: &WindowContext) -> bool { - self.pane.read(cx).is_zoomed() + self.active_pane.read(cx).is_zoomed() } fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { - self.pane.update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); + self.active_pane + .update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { @@ -872,7 +1073,12 @@ impl Panel for TerminalPanel { } fn icon_label(&self, cx: &WindowContext) -> Option { - let count = self.pane.read(cx).items_len(); + let count = self + .center + .panes() + .into_iter() + .map(|pane| pane.read(cx).items_len()) + .sum::(); if count == 0 { None } else { @@ -901,7 +1107,7 @@ impl Panel for TerminalPanel { } fn pane(&self) -> Option> { - Some(self.pane.clone()) + Some(self.active_pane.clone()) } } @@ -923,14 +1129,6 @@ impl Render for InlineAssistTabBarButton { } } -#[derive(Serialize, Deserialize)] -struct SerializedTerminalPanel { - items: Vec, - active_item_id: Option, - width: Option, - height: Option, -} - fn retrieve_system_shell() -> Option { #[cfg(not(target_os = "windows"))] { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index ad0c7f520d..35ad35a0e1 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -33,8 +33,8 @@ use workspace::{ notifications::NotifyResultExt, register_serializable_item, searchable::{SearchEvent, SearchOptions, SearchableItem, SearchableItemHandle}, - CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, Pane, ToolbarItemLocation, - Workspace, WorkspaceId, + CloseActiveItem, NewCenterTerminal, NewTerminal, OpenVisible, ToolbarItemLocation, Workspace, + WorkspaceId, }; use anyhow::Context; @@ -1222,10 +1222,10 @@ impl SerializableItem for TerminalView { workspace: WeakView, workspace_id: workspace::WorkspaceId, item_id: workspace::ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let window = cx.window_handle(); - cx.spawn(|pane, mut cx| async move { + cx.spawn(|mut cx| async move { let cwd = cx .update(|cx| { let from_db = TERMINAL_DB @@ -1249,7 +1249,7 @@ impl SerializableItem for TerminalView { let terminal = project.update(&mut cx, |project, cx| { project.create_terminal(TerminalKind::Shell(cwd), window, cx) })??; - pane.update(&mut cx, |_, cx| { + cx.update(|cx| { cx.new_view(|cx| TerminalView::new(terminal, workspace, Some(workspace_id), cx)) }) }) diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index a7bf90dd17..20437145cb 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -315,7 +315,7 @@ pub trait SerializableItem: Item { _workspace: WeakView, _workspace_id: WorkspaceId, _item_id: ItemId, - _cx: &mut ViewContext, + _cx: &mut WindowContext, ) -> Task>>; fn serialize( @@ -1032,7 +1032,7 @@ impl WeakFollowableItemHandle for WeakView { #[cfg(any(test, feature = "test-support"))] pub mod test { use super::{Item, ItemEvent, SerializableItem, TabContentParams}; - use crate::{ItemId, ItemNavHistory, Pane, Workspace, WorkspaceId}; + use crate::{ItemId, ItemNavHistory, Workspace, WorkspaceId}; use gpui::{ AnyElement, AppContext, Context as _, EntityId, EventEmitter, FocusableView, InteractiveElement, IntoElement, Model, Render, SharedString, Task, View, ViewContext, @@ -1040,6 +1040,7 @@ pub mod test { }; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; use std::{any::Any, cell::Cell, path::Path}; + use ui::WindowContext; pub struct TestProjectItem { pub entry_id: Option, @@ -1339,7 +1340,7 @@ pub mod test { _workspace: WeakView, workspace_id: WorkspaceId, _item_id: ItemId, - cx: &mut ViewContext, + cx: &mut WindowContext, ) -> Task>> { let view = cx.new_view(|cx| Self::new_deserialized(workspace_id, cx)); Task::ready(Ok(view)) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 4eec2f18d1..69485846e9 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -291,7 +291,7 @@ pub struct Pane { can_drop_predicate: Option bool>>, custom_drop_handle: Option) -> ControlFlow<(), ()>>>, - can_split: bool, + can_split_predicate: Option) -> bool>>, should_display_tab_bar: Rc) -> bool>, render_tab_bar_buttons: Rc) -> (Option, Option)>, @@ -411,7 +411,7 @@ impl Pane { project, can_drop_predicate, custom_drop_handle: None, - can_split: true, + can_split_predicate: None, should_display_tab_bar: Rc::new(|cx| TabBarSettings::get_global(cx).show), render_tab_bar_buttons: Rc::new(move |pane, cx| { if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { @@ -623,9 +623,13 @@ impl Pane { self.should_display_tab_bar = Rc::new(should_display_tab_bar); } - pub fn set_can_split(&mut self, can_split: bool, cx: &mut ViewContext) { - self.can_split = can_split; - cx.notify(); + pub fn set_can_split( + &mut self, + can_split_predicate: Option< + Arc) -> bool + 'static>, + >, + ) { + self.can_split_predicate = can_split_predicate; } pub fn set_can_navigate(&mut self, can_navigate: bool, cx: &mut ViewContext) { @@ -2384,8 +2388,18 @@ impl Pane { self.zoomed } - fn handle_drag_move(&mut self, event: &DragMoveEvent, cx: &mut ViewContext) { - if !self.can_split { + fn handle_drag_move( + &mut self, + event: &DragMoveEvent, + cx: &mut ViewContext, + ) { + let can_split_predicate = self.can_split_predicate.take(); + let can_split = match &can_split_predicate { + Some(can_split_predicate) => can_split_predicate(self, event.dragged_item(), cx), + None => false, + }; + self.can_split_predicate = can_split_predicate; + if !can_split { return; } @@ -2679,6 +2693,10 @@ impl Pane { }) .collect() } + + pub fn drag_split_direction(&self) -> Option { + self.drag_split_direction + } } impl FocusableView for Pane { diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 6f7d1a66b9..4461e58925 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -27,11 +27,11 @@ const VERTICAL_MIN_SIZE: f32 = 100.; /// Single-pane group is a regular pane. #[derive(Clone)] pub struct PaneGroup { - pub(crate) root: Member, + pub root: Member, } impl PaneGroup { - pub(crate) fn with_root(root: Member) -> Self { + pub fn with_root(root: Member) -> Self { Self { root } } @@ -122,7 +122,7 @@ impl PaneGroup { } #[allow(clippy::too_many_arguments)] - pub(crate) fn render( + pub fn render( &self, project: &Model, follower_states: &HashMap, @@ -144,19 +144,51 @@ impl PaneGroup { ) } - pub(crate) fn panes(&self) -> Vec<&View> { + pub fn panes(&self) -> Vec<&View> { let mut panes = Vec::new(); self.root.collect_panes(&mut panes); panes } - pub(crate) fn first_pane(&self) -> View { + pub fn first_pane(&self) -> View { self.root.first_pane() } + + pub fn find_pane_in_direction( + &mut self, + active_pane: &View, + direction: SplitDirection, + cx: &WindowContext, + ) -> Option<&View> { + let bounding_box = self.bounding_box_for_pane(active_pane)?; + let cursor = active_pane.read(cx).pixel_position_of_cursor(cx); + let center = match cursor { + Some(cursor) if bounding_box.contains(&cursor) => cursor, + _ => bounding_box.center(), + }; + + let distance_to_next = crate::HANDLE_HITBOX_SIZE; + + let target = match direction { + SplitDirection::Left => { + Point::new(bounding_box.left() - distance_to_next.into(), center.y) + } + SplitDirection::Right => { + Point::new(bounding_box.right() + distance_to_next.into(), center.y) + } + SplitDirection::Up => { + Point::new(center.x, bounding_box.top() - distance_to_next.into()) + } + SplitDirection::Down => { + Point::new(center.x, bounding_box.bottom() + distance_to_next.into()) + } + }; + self.pane_at_pixel_position(target) + } } -#[derive(Clone)] -pub(crate) enum Member { +#[derive(Debug, Clone)] +pub enum Member { Axis(PaneAxis), Pane(View), } @@ -359,8 +391,8 @@ impl Member { } } -#[derive(Clone)] -pub(crate) struct PaneAxis { +#[derive(Debug, Clone)] +pub struct PaneAxis { pub axis: Axis, pub members: Vec, pub flexes: Arc>>, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 28fd730e60..4687b1decd 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -777,7 +777,7 @@ pub struct ViewId { pub id: u64, } -struct FollowerState { +pub struct FollowerState { center_pane: View, dock_pane: Option>, active_view_id: Option, @@ -887,14 +887,16 @@ impl Workspace { let pane_history_timestamp = Arc::new(AtomicUsize::new(0)); let center_pane = cx.new_view(|cx| { - Pane::new( + let mut center_pane = Pane::new( weak_handle.clone(), project.clone(), pane_history_timestamp.clone(), None, NewFile.boxed_clone(), cx, - ) + ); + center_pane.set_can_split(Some(Arc::new(|_, _, _| true))); + center_pane }); cx.subscribe(¢er_pane, Self::handle_pane_event).detach(); @@ -2464,14 +2466,16 @@ impl Workspace { fn add_pane(&mut self, cx: &mut ViewContext) -> View { let pane = cx.new_view(|cx| { - Pane::new( + let mut pane = Pane::new( self.weak_handle(), self.project.clone(), self.pane_history_timestamp.clone(), None, NewFile.boxed_clone(), cx, - ) + ); + pane.set_can_split(Some(Arc::new(|_, _, _| true))); + pane }); cx.subscribe(&pane, Self::handle_pane_event).detach(); self.panes.push(pane.clone()); @@ -2955,30 +2959,9 @@ impl Workspace { direction: SplitDirection, cx: &WindowContext, ) -> Option> { - let bounding_box = self.center.bounding_box_for_pane(&self.active_pane)?; - let cursor = self.active_pane.read(cx).pixel_position_of_cursor(cx); - let center = match cursor { - Some(cursor) if bounding_box.contains(&cursor) => cursor, - _ => bounding_box.center(), - }; - - let distance_to_next = pane_group::HANDLE_HITBOX_SIZE; - - let target = match direction { - SplitDirection::Left => { - Point::new(bounding_box.left() - distance_to_next.into(), center.y) - } - SplitDirection::Right => { - Point::new(bounding_box.right() + distance_to_next.into(), center.y) - } - SplitDirection::Up => { - Point::new(center.x, bounding_box.top() - distance_to_next.into()) - } - SplitDirection::Down => { - Point::new(center.x, bounding_box.bottom() + distance_to_next.into()) - } - }; - self.center.pane_at_pixel_position(target).cloned() + self.center + .find_pane_in_direction(&self.active_pane, direction, cx) + .cloned() } pub fn swap_pane_in_direction( @@ -4591,6 +4574,10 @@ impl Workspace { let window = cx.window_handle().downcast::()?; cx.read_window(&window, |workspace, _| workspace).ok() } + + pub fn zoomed_item(&self) -> Option<&AnyWeakView> { + self.zoomed.as_ref() + } } fn leader_border_for_pane( From cff9ae0bbcc7f05c075d8aa226954c0ac290ece9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Thu, 28 Nov 2024 02:22:58 +0800 Subject: [PATCH 041/215] Better absolute path handling (#19727) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #19866 This PR supersedes #19228, as #19228 encountered too many merge conflicts. After some exploration, I found that for paths with the `\\?\` prefix, we can safely remove it and consistently use the clean paths in all cases. Previously, in #19228, I thought we would still need the `\\?\` prefix for IO operations to handle long paths better. However, this turns out to be unnecessary because Rust automatically manages this for us when calling IO-related APIs. For details, refer to Rust's internal function [`get_long_path`](https://github.com/rust-lang/rust/blob/017ae1b21f7be6dcdcfc95631e54bde806653a8a/library/std/src/sys/path/windows.rs#L225-L233). Therefore, we can always store and use paths without the `\\?\` prefix. This PR introduces a `SanitizedPath` structure, which represents a path stripped of the `\\?\` prefix. To prevent untrimmed paths from being mistakenly passed into `Worktree`, the type of `Worktree`’s `abs_path` member variable has been changed to `SanitizedPath`. Additionally, this PR reverts the changes of #15856 and #18726. After testing, it appears that the issues those PRs addressed can be resolved by this PR. ### Existing Issue To keep the scope of modifications manageable, `Worktree::abs_path` has retained its current signature as `fn abs_path(&self) -> Arc`, rather than returning a `SanitizedPath`. Updating the method to return `SanitizedPath`—which may better resolve path inconsistencies—would likely introduce extensive changes similar to those in #19228. Currently, the limitation is as follows: ```rust let abs_path: &Arc = snapshot.abs_path(); let some_non_trimmed_path = Path::new("\\\\?\\C:\\Users\\user\\Desktop\\project"); // The caller performs some actions here: some_non_trimmed_path.strip_prefix(abs_path); // This fails some_non_trimmed_path.starts_with(abs_path); // This fails too ``` The final two lines will fail because `snapshot.abs_path()` returns a clean path without the `\\?\` prefix. I have identified two relevant instances that may face this issue: - [lsp_store.rs#L3578](https://github.com/zed-industries/zed/blob/0173479d18e2526c1f9c8b25ac94ec66b992a2b2/crates/project/src/lsp_store.rs#L3578) - [worktree.rs#L4338](https://github.com/zed-industries/zed/blob/0173479d18e2526c1f9c8b25ac94ec66b992a2b2/crates/worktree/src/worktree.rs#L4338) Switching `Worktree::abs_path` to return `SanitizedPath` would resolve these issues but would also lead to many code changes. Any suggestions or feedback on this approach are very welcome. cc @SomeoneToIgnore Release Notes: - N/A --- Cargo.lock | 7 ++ Cargo.toml | 12 +- crates/fs/src/fs.rs | 20 ++-- crates/gpui/src/platform/windows/platform.rs | 14 +-- crates/project/src/lsp_store.rs | 3 +- crates/project/src/worktree_store.rs | 42 ++++--- crates/terminal_view/src/terminal_view.rs | 15 --- crates/util/Cargo.toml | 1 + crates/util/src/paths.rs | 60 +++++++++- crates/workspace/src/workspace.rs | 6 +- crates/worktree/src/worktree.rs | 117 ++++++++++++------- crates/zed/src/main.rs | 5 +- 12 files changed, 189 insertions(+), 113 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e1354c40d..f5c45f8d4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3752,6 +3752,12 @@ dependencies = [ "phf", ] +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dwrote" version = "0.11.2" @@ -13689,6 +13695,7 @@ dependencies = [ "async-fs 1.6.0", "collections", "dirs 4.0.0", + "dunce", "futures 0.3.31", "futures-lite 1.13.0", "git2", diff --git a/Cargo.toml b/Cargo.toml index 7c141a1b6c..71701dd8f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -228,7 +228,9 @@ git = { path = "crates/git" } git_hosting_providers = { path = "crates/git_hosting_providers" } go_to_line = { path = "crates/go_to_line" } google_ai = { path = "crates/google_ai" } -gpui = { path = "crates/gpui", default-features = false, features = ["http_client"]} +gpui = { path = "crates/gpui", default-features = false, features = [ + "http_client", +] } gpui_macros = { path = "crates/gpui_macros" } html_to_markdown = { path = "crates/html_to_markdown" } http_client = { path = "crates/http_client" } @@ -403,10 +405,10 @@ parking_lot = "0.12.1" pathdiff = "0.2" pet = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } pet-fs = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } -pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-conda = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-core = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-poetry = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } +pet-reporter = { git = "https://github.com/microsoft/python-environment-tools.git", rev = "ffcbf3f28c46633abd5448a52b1f396c322e0d6c" } postage = { version = "0.5", features = ["futures-traits"] } pretty_assertions = { version = "1.3.0", features = ["unstable"] } profiling = "1" diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index fc0fae3fe8..37525db7d9 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -452,18 +452,16 @@ impl Fs for RealFs { #[cfg(target_os = "windows")] async fn trash_file(&self, path: &Path, _options: RemoveOptions) -> Result<()> { + use util::paths::SanitizedPath; use windows::{ core::HSTRING, Storage::{StorageDeleteOption, StorageFile}, }; // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let path = path.canonicalize()?.to_string_lossy().to_string(); - let path_str = path.trim_start_matches("\\\\?\\"); - if path_str.is_empty() { - anyhow::bail!("File path is empty!"); - } - let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_str))?.get()?; + let path = SanitizedPath::from(path.canonicalize()?); + let path_string = path.to_string(); + let file = StorageFile::GetFileFromPathAsync(&HSTRING::from(path_string))?.get()?; file.DeleteAsync(StorageDeleteOption::Default)?.get()?; Ok(()) } @@ -480,19 +478,17 @@ impl Fs for RealFs { #[cfg(target_os = "windows")] async fn trash_dir(&self, path: &Path, _options: RemoveOptions) -> Result<()> { + use util::paths::SanitizedPath; use windows::{ core::HSTRING, Storage::{StorageDeleteOption, StorageFolder}, }; - let path = path.canonicalize()?.to_string_lossy().to_string(); - let path_str = path.trim_start_matches("\\\\?\\"); - if path_str.is_empty() { - anyhow::bail!("Folder path is empty!"); - } // todo(windows) // When new version of `windows-rs` release, make this operation `async` - let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_str))?.get()?; + let path = SanitizedPath::from(path.canonicalize()?); + let path_string = path.to_string(); + let folder = StorageFolder::GetFolderFromPathAsync(&HSTRING::from(path_string))?.get()?; folder.DeleteAsync(StorageDeleteOption::Default)?.get()?; Ok(()) } diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 91e9816106..389b90765d 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -6,7 +6,7 @@ use std::{ sync::Arc, }; -use ::util::ResultExt; +use ::util::{paths::SanitizedPath, ResultExt}; use anyhow::{anyhow, Context, Result}; use async_task::Runnable; use futures::channel::oneshot::{self, Receiver}; @@ -645,13 +645,11 @@ fn file_save_dialog(directory: PathBuf) -> Result> { let dialog: IFileSaveDialog = unsafe { CoCreateInstance(&FileSaveDialog, None, CLSCTX_ALL)? }; if !directory.to_string_lossy().is_empty() { if let Some(full_path) = directory.canonicalize().log_err() { - let full_path = full_path.to_string_lossy(); - let full_path_str = full_path.trim_start_matches("\\\\?\\"); - if !full_path_str.is_empty() { - let path_item: IShellItem = - unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_str), None)? }; - unsafe { dialog.SetFolder(&path_item).log_err() }; - } + let full_path = SanitizedPath::from(full_path); + let full_path_string = full_path.to_string(); + let path_item: IShellItem = + unsafe { SHCreateItemFromParsingName(&HSTRING::from(full_path_string), None)? }; + unsafe { dialog.SetFolder(&path_item).log_err() }; } } unsafe { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 29a0afcfe5..6f4d23fa76 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -5577,7 +5577,7 @@ impl LspStore { let worktree = worktree_handle.read(cx); let worktree_id = worktree.id(); - let worktree_path = worktree.abs_path(); + let root_path = worktree.abs_path(); let key = (worktree_id, adapter.name.clone()); if self.language_server_ids.contains_key(&key) { @@ -5599,7 +5599,6 @@ impl LspStore { as Arc; let server_id = self.languages.next_language_server_id(); - let root_path = worktree_path.clone(); log::info!( "attempting to start language server {:?}, path: {root_path:?}, id: {server_id}", adapter.name.0 diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index db5ae67ba7..1e48cc052e 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -23,7 +23,7 @@ use smol::{ stream::StreamExt, }; use text::ReplicaId; -use util::ResultExt; +use util::{paths::SanitizedPath, ResultExt}; use worktree::{Entry, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings}; use crate::{search::SearchQuery, ProjectPath}; @@ -52,7 +52,7 @@ pub struct WorktreeStore { worktrees_reordered: bool, #[allow(clippy::type_complexity)] loading_worktrees: - HashMap, Shared, Arc>>>>, + HashMap, Arc>>>>, state: WorktreeStoreState, } @@ -147,11 +147,12 @@ impl WorktreeStore { pub fn find_worktree( &self, - abs_path: &Path, + abs_path: impl Into, cx: &AppContext, ) -> Option<(Model, PathBuf)> { + let abs_path: SanitizedPath = abs_path.into(); for tree in self.worktrees() { - if let Ok(relative_path) = abs_path.strip_prefix(tree.read(cx).abs_path()) { + if let Ok(relative_path) = abs_path.as_path().strip_prefix(tree.read(cx).abs_path()) { return Some((tree.clone(), relative_path.into())); } } @@ -192,12 +193,12 @@ impl WorktreeStore { pub fn create_worktree( &mut self, - abs_path: impl AsRef, + abs_path: impl Into, visible: bool, cx: &mut ModelContext, ) -> Task>> { - let path: Arc = abs_path.as_ref().into(); - if !self.loading_worktrees.contains_key(&path) { + let abs_path: SanitizedPath = abs_path.into(); + if !self.loading_worktrees.contains_key(&abs_path) { let task = match &self.state { WorktreeStoreState::Remote { upstream_client, .. @@ -205,20 +206,26 @@ impl WorktreeStore { if upstream_client.is_via_collab() { Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab")))) } else { - self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx) + self.create_ssh_worktree( + upstream_client.clone(), + abs_path.clone(), + visible, + cx, + ) } } WorktreeStoreState::Local { fs } => { - self.create_local_worktree(fs.clone(), abs_path, visible, cx) + self.create_local_worktree(fs.clone(), abs_path.clone(), visible, cx) } }; - self.loading_worktrees.insert(path.clone(), task.shared()); + self.loading_worktrees + .insert(abs_path.clone(), task.shared()); } - let task = self.loading_worktrees.get(&path).unwrap().clone(); + let task = self.loading_worktrees.get(&abs_path).unwrap().clone(); cx.spawn(|this, mut cx| async move { let result = task.await; - this.update(&mut cx, |this, _| this.loading_worktrees.remove(&path)) + this.update(&mut cx, |this, _| this.loading_worktrees.remove(&abs_path)) .ok(); match result { Ok(worktree) => Ok(worktree), @@ -230,12 +237,11 @@ impl WorktreeStore { fn create_ssh_worktree( &mut self, client: AnyProtoClient, - abs_path: impl AsRef, + abs_path: impl Into, visible: bool, cx: &mut ModelContext, ) -> Task, Arc>> { - let path_key: Arc = abs_path.as_ref().into(); - let mut abs_path = path_key.clone().to_string_lossy().to_string(); + let mut abs_path = Into::::into(abs_path).to_string(); // If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/` // in which case want to strip the leading the `/`. // On the host-side, the `~` will get expanded. @@ -293,12 +299,12 @@ impl WorktreeStore { fn create_local_worktree( &mut self, fs: Arc, - abs_path: impl AsRef, + abs_path: impl Into, visible: bool, cx: &mut ModelContext, ) -> Task, Arc>> { let next_entry_id = self.next_entry_id.clone(); - let path: Arc = abs_path.as_ref().into(); + let path: SanitizedPath = abs_path.into(); cx.spawn(move |this, mut cx| async move { let worktree = Worktree::local(path.clone(), visible, fs, next_entry_id, &mut cx).await; @@ -308,7 +314,7 @@ impl WorktreeStore { if visible { cx.update(|cx| { - cx.add_recent_document(&path); + cx.add_recent_document(path.as_path()); }) .log_err(); } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 35ad35a0e1..44e97122b8 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -798,7 +798,6 @@ fn possible_open_paths_metadata( cx.background_executor().spawn(async move { let mut paths_with_metadata = Vec::with_capacity(potential_paths.len()); - #[cfg(not(target_os = "windows"))] let mut fetch_metadata_tasks = potential_paths .into_iter() .map(|potential_path| async { @@ -814,20 +813,6 @@ fn possible_open_paths_metadata( }) .collect::>(); - #[cfg(target_os = "windows")] - let mut fetch_metadata_tasks = potential_paths - .iter() - .map(|potential_path| async { - let metadata = fs.metadata(potential_path).await.ok().flatten(); - let path = PathBuf::from( - potential_path - .to_string_lossy() - .trim_start_matches("\\\\?\\"), - ); - (PathWithPosition { path, row, column }, metadata) - }) - .collect::>(); - while let Some((path, metadata)) = fetch_metadata_tasks.next().await { if let Some(metadata) = metadata { paths_with_metadata.push((path, metadata)); diff --git a/crates/util/Cargo.toml b/crates/util/Cargo.toml index 94d580e643..2f84114409 100644 --- a/crates/util/Cargo.toml +++ b/crates/util/Cargo.toml @@ -37,6 +37,7 @@ unicase.workspace = true [target.'cfg(windows)'.dependencies] tendril = "0.4.3" +dunce = "1.0" [dev-dependencies] git2.workspace = true diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index f4e494f66e..e3b0af1fdb 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -1,5 +1,5 @@ use std::cmp; -use std::sync::OnceLock; +use std::sync::{Arc, OnceLock}; use std::{ ffi::OsStr, path::{Path, PathBuf}, @@ -95,6 +95,46 @@ impl> PathExt for T { } } +/// Due to the issue of UNC paths on Windows, which can cause bugs in various parts of Zed, introducing this `SanitizedPath` +/// leverages Rust's type system to ensure that all paths entering Zed are always "sanitized" by removing the `\\\\?\\` prefix. +/// On non-Windows operating systems, this struct is effectively a no-op. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct SanitizedPath(Arc); + +impl SanitizedPath { + pub fn starts_with(&self, prefix: &SanitizedPath) -> bool { + self.0.starts_with(&prefix.0) + } + + pub fn as_path(&self) -> &Arc { + &self.0 + } + + pub fn to_string(&self) -> String { + self.0.to_string_lossy().to_string() + } +} + +impl From for Arc { + fn from(sanitized_path: SanitizedPath) -> Self { + sanitized_path.0 + } +} + +impl> From for SanitizedPath { + #[cfg(not(target_os = "windows"))] + fn from(path: T) -> Self { + let path = path.as_ref(); + SanitizedPath(path.into()) + } + + #[cfg(target_os = "windows")] + fn from(path: T) -> Self { + let path = path.as_ref(); + SanitizedPath(dunce::simplified(path).into()) + } +} + /// A delimiter to use in `path_query:row_number:column_number` strings parsing. pub const FILE_ROW_COLUMN_DELIMITER: char = ':'; @@ -805,4 +845,22 @@ mod tests { "Path matcher should match {path:?}" ); } + + #[test] + #[cfg(target_os = "windows")] + fn test_sanitized_path() { + let path = Path::new("C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::from(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + + let path = Path::new("\\\\?\\C:\\Users\\someone\\test_file.rs"); + let sanitized_path = SanitizedPath::from(path); + assert_eq!( + sanitized_path.to_string(), + "C:\\Users\\someone\\test_file.rs" + ); + } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 4687b1decd..ed5aaa6e49 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -97,7 +97,7 @@ use ui::{ IntoElement, ParentElement as _, Pixels, SharedString, Styled as _, ViewContext, VisualContext as _, WindowContext, }; -use util::{ResultExt, TryFutureExt}; +use util::{paths::SanitizedPath, ResultExt, TryFutureExt}; use uuid::Uuid; pub use workspace_settings::{ AutosaveSetting, RestoreOnStartupBehavior, TabBarSettings, WorkspaceSettings, @@ -2024,7 +2024,7 @@ impl Workspace { }; let this = this.clone(); - let abs_path = abs_path.clone(); + let abs_path: Arc = SanitizedPath::from(abs_path.clone()).into(); let fs = fs.clone(); let pane = pane.clone(); let task = cx.spawn(move |mut cx| async move { @@ -2033,7 +2033,7 @@ impl Workspace { this.update(&mut cx, |workspace, cx| { let worktree = worktree.read(cx); let worktree_abs_path = worktree.abs_path(); - let entry_id = if abs_path == worktree_abs_path.as_ref() { + let entry_id = if abs_path.as_ref() == worktree_abs_path.as_ref() { worktree.root_entry() } else { abs_path diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b7ee4466c7..e856bbf7de 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -66,7 +66,7 @@ use std::{ use sum_tree::{Bias, Edit, SeekTarget, SumTree, TreeMap, TreeSet}; use text::{LineEnding, Rope}; use util::{ - paths::{home_dir, PathMatcher}, + paths::{home_dir, PathMatcher, SanitizedPath}, ResultExt, }; pub use worktree_settings::WorktreeSettings; @@ -149,7 +149,7 @@ pub struct RemoteWorktree { #[derive(Clone)] pub struct Snapshot { id: WorktreeId, - abs_path: Arc, + abs_path: SanitizedPath, root_name: String, root_char_bag: CharBag, entries_by_path: SumTree, @@ -356,7 +356,7 @@ enum ScanState { scanning: bool, }, RootUpdated { - new_path: Option>, + new_path: Option, }, } @@ -654,8 +654,8 @@ impl Worktree { pub fn abs_path(&self) -> Arc { match self { - Worktree::Local(worktree) => worktree.abs_path.clone(), - Worktree::Remote(worktree) => worktree.abs_path.clone(), + Worktree::Local(worktree) => worktree.abs_path.clone().into(), + Worktree::Remote(worktree) => worktree.abs_path.clone().into(), } } @@ -1026,6 +1026,7 @@ impl LocalWorktree { } pub fn contains_abs_path(&self, path: &Path) -> bool { + let path = SanitizedPath::from(path); path.starts_with(&self.abs_path) } @@ -1066,13 +1067,13 @@ impl LocalWorktree { let (scan_states_tx, mut scan_states_rx) = mpsc::unbounded(); let background_scanner = cx.background_executor().spawn({ let abs_path = &snapshot.abs_path; - let abs_path = if cfg!(target_os = "windows") { - abs_path - .canonicalize() - .unwrap_or_else(|_| abs_path.to_path_buf()) - } else { - abs_path.to_path_buf() - }; + #[cfg(target_os = "windows")] + let abs_path = abs_path + .as_path() + .canonicalize() + .unwrap_or_else(|_| abs_path.as_path().to_path_buf()); + #[cfg(not(target_os = "windows"))] + let abs_path = abs_path.as_path().to_path_buf(); let background = cx.background_executor().clone(); async move { let (events, watcher) = fs.watch(&abs_path, FS_WATCH_LATENCY).await; @@ -1135,6 +1136,7 @@ impl LocalWorktree { this.snapshot.git_repositories = Default::default(); this.snapshot.ignores_by_parent_abs_path = Default::default(); let root_name = new_path + .as_path() .file_name() .map_or(String::new(), |f| f.to_string_lossy().to_string()); this.snapshot.update_abs_path(new_path, root_name); @@ -2075,7 +2077,7 @@ impl Snapshot { pub fn new(id: u64, root_name: String, abs_path: Arc) -> Self { Snapshot { id: WorktreeId::from_usize(id as usize), - abs_path, + abs_path: abs_path.into(), root_char_bag: root_name.chars().map(|c| c.to_ascii_lowercase()).collect(), root_name, always_included_entries: Default::default(), @@ -2091,8 +2093,20 @@ impl Snapshot { self.id } + // TODO: + // Consider the following: + // + // ```rust + // let abs_path: Arc = snapshot.abs_path(); // e.g. "C:\Users\user\Desktop\project" + // let some_non_trimmed_path = Path::new("\\\\?\\C:\\Users\\user\\Desktop\\project\\main.rs"); + // // The caller perform some actions here: + // some_non_trimmed_path.strip_prefix(abs_path); // This fails + // some_non_trimmed_path.starts_with(abs_path); // This fails too + // ``` + // + // This is definitely a bug, but it's not clear if we should handle it here or not. pub fn abs_path(&self) -> &Arc { - &self.abs_path + self.abs_path.as_path() } fn build_initial_update(&self, project_id: u64, worktree_id: u64) -> proto::UpdateWorktree { @@ -2132,9 +2146,9 @@ impl Snapshot { return Err(anyhow!("invalid path")); } if path.file_name().is_some() { - Ok(self.abs_path.join(path)) + Ok(self.abs_path.as_path().join(path)) } else { - Ok(self.abs_path.to_path_buf()) + Ok(self.abs_path.as_path().to_path_buf()) } } @@ -2193,7 +2207,7 @@ impl Snapshot { .and_then(|entry| entry.git_status) } - fn update_abs_path(&mut self, abs_path: Arc, root_name: String) { + fn update_abs_path(&mut self, abs_path: SanitizedPath, root_name: String) { self.abs_path = abs_path; if root_name != self.root_name { self.root_char_bag = root_name.chars().map(|c| c.to_ascii_lowercase()).collect(); @@ -2212,7 +2226,7 @@ impl Snapshot { update.removed_entries.len() ); self.update_abs_path( - Arc::from(PathBuf::from(update.abs_path).as_path()), + SanitizedPath::from(PathBuf::from(update.abs_path)), update.root_name, ); @@ -2632,7 +2646,7 @@ impl LocalSnapshot { fn insert_entry(&mut self, mut entry: Entry, fs: &dyn Fs) -> Entry { if entry.is_file() && entry.path.file_name() == Some(&GITIGNORE) { - let abs_path = self.abs_path.join(&entry.path); + let abs_path = self.abs_path.as_path().join(&entry.path); match smol::block_on(build_gitignore(&abs_path, fs)) { Ok(ignore) => { self.ignores_by_parent_abs_path @@ -2786,8 +2800,9 @@ impl LocalSnapshot { if git_state { for ignore_parent_abs_path in self.ignores_by_parent_abs_path.keys() { - let ignore_parent_path = - ignore_parent_abs_path.strip_prefix(&self.abs_path).unwrap(); + let ignore_parent_path = ignore_parent_abs_path + .strip_prefix(self.abs_path.as_path()) + .unwrap(); assert!(self.entry_for_path(ignore_parent_path).is_some()); assert!(self .entry_for_path(ignore_parent_path.join(*GITIGNORE)) @@ -2941,7 +2956,7 @@ impl BackgroundScannerState { } if let Some(ignore) = ignore { - let abs_parent_path = self.snapshot.abs_path.join(parent_path).into(); + let abs_parent_path = self.snapshot.abs_path.as_path().join(parent_path).into(); self.snapshot .ignores_by_parent_abs_path .insert(abs_parent_path, (ignore, false)); @@ -3004,7 +3019,11 @@ impl BackgroundScannerState { } if entry.path.file_name() == Some(&GITIGNORE) { - let abs_parent_path = self.snapshot.abs_path.join(entry.path.parent().unwrap()); + let abs_parent_path = self + .snapshot + .abs_path + .as_path() + .join(entry.path.parent().unwrap()); if let Some((_, needs_update)) = self .snapshot .ignores_by_parent_abs_path @@ -3085,7 +3104,7 @@ impl BackgroundScannerState { return None; } - let dot_git_abs_path = self.snapshot.abs_path.join(&dot_git_path); + let dot_git_abs_path = self.snapshot.abs_path.as_path().join(&dot_git_path); let t0 = Instant::now(); let repository = fs.open_repo(&dot_git_abs_path)?; @@ -3299,9 +3318,9 @@ impl language::LocalFile for File { fn abs_path(&self, cx: &AppContext) -> PathBuf { let worktree_path = &self.worktree.read(cx).as_local().unwrap().abs_path; if self.path.as_ref() == Path::new("") { - worktree_path.to_path_buf() + worktree_path.as_path().to_path_buf() } else { - worktree_path.join(&self.path) + worktree_path.as_path().join(&self.path) } } @@ -3712,7 +3731,7 @@ impl BackgroundScanner { // the git repository in an ancestor directory. Find any gitignore files // in ancestor directories. let root_abs_path = self.state.lock().snapshot.abs_path.clone(); - for (index, ancestor) in root_abs_path.ancestors().enumerate() { + for (index, ancestor) in root_abs_path.as_path().ancestors().enumerate() { if index != 0 { if let Ok(ignore) = build_gitignore(&ancestor.join(*GITIGNORE), self.fs.as_ref()).await @@ -3744,7 +3763,13 @@ impl BackgroundScanner { self.state.lock().insert_git_repository_for_path( Path::new("").into(), ancestor_dot_git.into(), - Some(root_abs_path.strip_prefix(ancestor).unwrap().into()), + Some( + root_abs_path + .as_path() + .strip_prefix(ancestor) + .unwrap() + .into(), + ), self.fs.as_ref(), self.watcher.as_ref(), ); @@ -3763,12 +3788,12 @@ impl BackgroundScanner { if let Some(mut root_entry) = state.snapshot.root_entry().cloned() { let ignore_stack = state .snapshot - .ignore_stack_for_abs_path(&root_abs_path, true); - if ignore_stack.is_abs_path_ignored(&root_abs_path, true) { + .ignore_stack_for_abs_path(root_abs_path.as_path(), true); + if ignore_stack.is_abs_path_ignored(root_abs_path.as_path(), true) { root_entry.is_ignored = true; state.insert_entry(root_entry.clone(), self.fs.as_ref(), self.watcher.as_ref()); } - state.enqueue_scan_dir(root_abs_path, &root_entry, &scan_job_tx); + state.enqueue_scan_dir(root_abs_path.into(), &root_entry, &scan_job_tx); } }; @@ -3818,7 +3843,7 @@ impl BackgroundScanner { { let mut state = self.state.lock(); state.path_prefixes_to_scan.insert(path_prefix.clone()); - state.snapshot.abs_path.join(&path_prefix) + state.snapshot.abs_path.as_path().join(&path_prefix) }; if let Some(abs_path) = self.fs.canonicalize(&abs_path).await.log_err() { @@ -3845,7 +3870,7 @@ impl BackgroundScanner { self.forcibly_load_paths(&request.relative_paths).await; let root_path = self.state.lock().snapshot.abs_path.clone(); - let root_canonical_path = match self.fs.canonicalize(&root_path).await { + let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { Ok(path) => path, Err(err) => { log::error!("failed to canonicalize root path: {}", err); @@ -3874,7 +3899,7 @@ impl BackgroundScanner { } self.reload_entries_for_paths( - root_path, + root_path.into(), root_canonical_path, &request.relative_paths, abs_paths, @@ -3887,7 +3912,7 @@ impl BackgroundScanner { async fn process_events(&self, mut abs_paths: Vec) { let root_path = self.state.lock().snapshot.abs_path.clone(); - let root_canonical_path = match self.fs.canonicalize(&root_path).await { + let root_canonical_path = match self.fs.canonicalize(root_path.as_path()).await { Ok(path) => path, Err(err) => { let new_path = self @@ -3897,21 +3922,20 @@ impl BackgroundScanner { .root_file_handle .clone() .and_then(|handle| handle.current_path(&self.fs).log_err()) - .filter(|new_path| **new_path != *root_path); + .map(SanitizedPath::from) + .filter(|new_path| *new_path != root_path); if let Some(new_path) = new_path.as_ref() { log::info!( "root renamed from {} to {}", - root_path.display(), - new_path.display() + root_path.as_path().display(), + new_path.as_path().display() ) } else { log::warn!("root path could not be canonicalized: {}", err); } self.status_updates_tx - .unbounded_send(ScanState::RootUpdated { - new_path: new_path.map(|p| p.into()), - }) + .unbounded_send(ScanState::RootUpdated { new_path }) .ok(); return; } @@ -4006,7 +4030,7 @@ impl BackgroundScanner { let (scan_job_tx, scan_job_rx) = channel::unbounded(); log::debug!("received fs events {:?}", relative_paths); self.reload_entries_for_paths( - root_path, + root_path.into(), root_canonical_path, &relative_paths, abs_paths, @@ -4044,7 +4068,7 @@ impl BackgroundScanner { for ancestor in path.ancestors() { if let Some(entry) = state.snapshot.entry_for_path(ancestor) { if entry.kind == EntryKind::UnloadedDir { - let abs_path = root_path.join(ancestor); + let abs_path = root_path.as_path().join(ancestor); state.enqueue_scan_dir(abs_path.into(), entry, &scan_job_tx); state.paths_to_scan.insert(path.clone()); break; @@ -4548,7 +4572,7 @@ impl BackgroundScanner { snapshot .ignores_by_parent_abs_path .retain(|parent_abs_path, (_, needs_update)| { - if let Ok(parent_path) = parent_abs_path.strip_prefix(&abs_path) { + if let Ok(parent_path) = parent_abs_path.strip_prefix(abs_path.as_path()) { if *needs_update { *needs_update = false; if snapshot.snapshot.entry_for_path(parent_path).is_some() { @@ -4627,7 +4651,10 @@ impl BackgroundScanner { let mut entries_by_id_edits = Vec::new(); let mut entries_by_path_edits = Vec::new(); - let path = job.abs_path.strip_prefix(&snapshot.abs_path).unwrap(); + let path = job + .abs_path + .strip_prefix(snapshot.abs_path.as_path()) + .unwrap(); let repo = snapshot.repo_for_path(path); for mut entry in snapshot.child_entries(path).cloned() { let was_ignored = entry.is_ignored; diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index cfc11ade3f..c598054356 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -1124,10 +1124,7 @@ impl ToString for IdType { fn parse_url_arg(arg: &str, cx: &AppContext) -> Result { match std::fs::canonicalize(Path::new(&arg)) { - Ok(path) => Ok(format!( - "file://{}", - path.to_string_lossy().trim_start_matches(r#"\\?\"#) - )), + Ok(path) => Ok(format!("file://{}", path.display())), Err(error) => { if arg.starts_with("file://") || arg.starts_with("zed-cli://") From 0c8e5550e7dc2c343e9a387eb1af9dd92d1b720b Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 27 Nov 2024 10:47:23 -0800 Subject: [PATCH 042/215] Make Markdown images layout vertically instead of horizontally (#21247) Release Notes: - Fixed a bug in the Markdown preview where images in the same paragraph would be rendered next to each other --- crates/markdown_preview/src/markdown_renderer.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 6140372e0b..39bcd546df 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -417,6 +417,7 @@ fn render_markdown_paragraph(parsed: &MarkdownParagraph, cx: &mut RenderContext) cx.with_common_p(div()) .children(render_markdown_text(parsed, cx)) .flex() + .flex_col() .into_any_element() } From 34ed48e14bcf48a2dea2bc9b237bc669601e0a88 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 27 Nov 2024 23:17:44 +0200 Subject: [PATCH 043/215] Add a split button to terminal panes (#21251) Follow-up of https://github.com/zed-industries/zed/pull/21238 image Release Notes: - N/A --- crates/terminal_view/src/terminal_panel.rs | 23 ++++++++++++++++++++-- crates/workspace/src/pane.rs | 2 +- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 38b2eda676..4d8d197aea 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -36,8 +36,8 @@ use workspace::{ move_item, pane, ui::IconName, ActivateNextPane, ActivatePane, ActivatePaneInDirection, ActivatePreviousPane, DraggedTab, - ItemId, NewTerminal, Pane, PaneGroup, SplitDirection, SwapPaneInDirection, ToggleZoom, - Workspace, + ItemId, NewTerminal, Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitRight, + SplitUp, SwapPaneInDirection, ToggleZoom, Workspace, }; use anyhow::Result; @@ -166,6 +166,25 @@ impl TerminalPanel { Some(menu) }), ) + .child( + PopoverMenu::new("terminal-pane-tab-bar-split") + .trigger( + IconButton::new("terminal-pane-split", IconName::Split) + .icon_size(IconSize::Small) + .tooltip(|cx| Tooltip::text("Split Pane", cx)), + ) + .anchor(AnchorCorner::TopRight) + .with_handle(pane.split_item_context_menu_handle.clone()) + .menu(move |cx| { + ContextMenu::build(cx, |menu, _| { + menu.action("Split Right", SplitRight.boxed_clone()) + .action("Split Left", SplitLeft.boxed_clone()) + .action("Split Up", SplitUp.boxed_clone()) + .action("Split Down", SplitDown.boxed_clone()) + }) + .into() + }), + ) .child({ let zoomed = pane.is_zoomed(); IconButton::new("toggle_zoom", IconName::Maximize) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 69485846e9..292f59eba8 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -303,7 +303,7 @@ pub struct Pane { double_click_dispatch_action: Box, save_modals_spawned: HashSet, pub new_item_context_menu_handle: PopoverMenuHandle, - split_item_context_menu_handle: PopoverMenuHandle, + pub split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, } From e803815b1645b551b096fc77f16a3d7485c6fdd7 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 28 Nov 2024 00:06:23 +0200 Subject: [PATCH 044/215] Use proper context to show terminal split menu bindings (#21253) Follow-up of https://github.com/zed-industries/zed/pull/21251 Show proper keybindings on the terminal split button: image Release Notes: - N/A --- crates/terminal_view/src/terminal_panel.rs | 25 ++++++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 4d8d197aea..1bc8a9e19b 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -26,8 +26,8 @@ use terminal::{ Terminal, }; use ui::{ - div, h_flex, ButtonCommon, Clickable, ContextMenu, IconButton, IconSize, InteractiveElement, - PopoverMenu, Selectable, Tooltip, + div, h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, + InteractiveElement, PopoverMenu, Selectable, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ @@ -130,6 +130,10 @@ impl TerminalPanel { let assistant_tab_bar_button = self.assistant_tab_bar_button.clone(); terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, cx| { + let split_context = pane + .items() + .find_map(|item| item.downcast::()) + .map(|terminal_view| terminal_view.read(cx).focus_handle.clone()); if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { return (None, None); } @@ -175,14 +179,21 @@ impl TerminalPanel { ) .anchor(AnchorCorner::TopRight) .with_handle(pane.split_item_context_menu_handle.clone()) - .menu(move |cx| { - ContextMenu::build(cx, |menu, _| { - menu.action("Split Right", SplitRight.boxed_clone()) + .menu({ + let split_context = split_context.clone(); + move |cx| { + ContextMenu::build(cx, |menu, _| { + menu.when_some( + split_context.clone(), + |menu, split_context| menu.context(split_context), + ) + .action("Split Right", SplitRight.boxed_clone()) .action("Split Left", SplitLeft.boxed_clone()) .action("Split Up", SplitUp.boxed_clone()) .action("Split Down", SplitDown.boxed_clone()) - }) - .into() + }) + .into() + } }), ) .child({ From 66ba9d5b4b27bc26571e6cb98b08cb46b7a0ae41 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 28 Nov 2024 00:30:33 +0200 Subject: [PATCH 045/215] Use item context for pane tab context menu (#21254) This allows to show proper override values for terminal tabs in Linux and Windows. Release Notes: - Fixed incorrect "close tab" keybinding shown in context menu of the terminal panel tabs on Linux and Windows --- crates/workspace/src/pane.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 292f59eba8..dc7b92a13b 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2075,8 +2075,10 @@ impl Pane { let is_pinned = self.is_tab_pinned(ix); let pane = cx.view().downgrade(); + let menu_context = item.focus_handle(cx); right_click_menu(ix).trigger(tab).menu(move |cx| { let pane = pane.clone(); + let menu_context = menu_context.clone(); ContextMenu::build(cx, move |mut menu, cx| { if let Some(pane) = pane.upgrade() { menu = menu @@ -2255,7 +2257,7 @@ impl Pane { } } - menu + menu.context(menu_context) }) }) } From 04ff9f060cf9eeb3b848c858f80d6c882bb5cc20 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Thu, 28 Nov 2024 00:54:01 +0100 Subject: [PATCH 046/215] Improve runnable detection for JavaScript files (#21246) Closes #21242 ![Screenshot 2024-11-27 at 18 52 51](https://github.com/user-attachments/assets/d096197c-33d2-41b9-963d-3e1a9bbdc035) ![Screenshot 2024-11-27 at 18 53 08](https://github.com/user-attachments/assets/b3202b00-3f68-4d9d-acc2-1b86c081fc34) Release Notes: - Improved runnable detection for JavaScript/Typescript files. --- crates/languages/src/javascript/outline.scm | 20 +++++++++++++------ crates/languages/src/javascript/runnables.scm | 15 ++++++++++---- crates/languages/src/tsx/outline.scm | 20 +++++++++++++------ crates/languages/src/tsx/runnables.scm | 19 ++++++++++++------ crates/languages/src/typescript/outline.scm | 20 +++++++++++++------ crates/languages/src/typescript/runnables.scm | 19 ++++++++++++------ 6 files changed, 79 insertions(+), 34 deletions(-) diff --git a/crates/languages/src/javascript/outline.scm b/crates/languages/src/javascript/outline.scm index c5ec3d36dd..da6a1e0d31 100644 --- a/crates/languages/src/javascript/outline.scm +++ b/crates/languages/src/javascript/outline.scm @@ -62,12 +62,20 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable -(call_expression - function: (_) @context - (#any-of? @context "it" "test" "describe") - arguments: ( - arguments . (string - (string_fragment) @name +( + (call_expression + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ]* + ) + ]* @context + (#any-of? @_name "it" "test" "describe") + arguments: ( + arguments . (string (string_fragment) @name) ) ) ) @item diff --git a/crates/languages/src/javascript/runnables.scm b/crates/languages/src/javascript/runnables.scm index 37f48e1df8..615bd2f51a 100644 --- a/crates/languages/src/javascript/runnables.scm +++ b/crates/languages/src/javascript/runnables.scm @@ -2,13 +2,20 @@ ; Function expression that has `it`, `test` or `describe` as the function name ( (call_expression - function: (_) @_name + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ]* + ) + ] (#any-of? @_name "it" "test" "describe") arguments: ( - arguments . (string - (string_fragment) @run - ) + arguments . (string (string_fragment) @run) ) ) @_js-test + (#set! tag js-test) ) diff --git a/crates/languages/src/tsx/outline.scm b/crates/languages/src/tsx/outline.scm index 0c3589071d..14dbf1cc0a 100644 --- a/crates/languages/src/tsx/outline.scm +++ b/crates/languages/src/tsx/outline.scm @@ -70,12 +70,20 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable -(call_expression - function: (_) @context - (#any-of? @context "it" "test" "describe") - arguments: ( - arguments . (string - (string_fragment) @name +( + (call_expression + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ]* + ) + ]* @context + (#any-of? @_name "it" "test" "describe") + arguments: ( + arguments . (string (string_fragment) @name) ) ) ) @item diff --git a/crates/languages/src/tsx/runnables.scm b/crates/languages/src/tsx/runnables.scm index 68c81d04c7..615bd2f51a 100644 --- a/crates/languages/src/tsx/runnables.scm +++ b/crates/languages/src/tsx/runnables.scm @@ -2,13 +2,20 @@ ; Function expression that has `it`, `test` or `describe` as the function name ( (call_expression - function: (_) @_name + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ]* + ) + ] (#any-of? @_name "it" "test" "describe") arguments: ( - arguments . (string - (string_fragment) @run - ) + arguments . (string (string_fragment) @run) ) - ) @_tsx-test - (#set! tag tsx-test) + ) @_js-test + + (#set! tag js-test) ) diff --git a/crates/languages/src/typescript/outline.scm b/crates/languages/src/typescript/outline.scm index 0c3589071d..14dbf1cc0a 100644 --- a/crates/languages/src/typescript/outline.scm +++ b/crates/languages/src/typescript/outline.scm @@ -70,12 +70,20 @@ name: (_) @name) @item ; Add support for (node:test, bun:test and Jest) runnable -(call_expression - function: (_) @context - (#any-of? @context "it" "test" "describe") - arguments: ( - arguments . (string - (string_fragment) @name +( + (call_expression + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ]* + ) + ]* @context + (#any-of? @_name "it" "test" "describe") + arguments: ( + arguments . (string (string_fragment) @name) ) ) ) @item diff --git a/crates/languages/src/typescript/runnables.scm b/crates/languages/src/typescript/runnables.scm index 21a965fd31..615bd2f51a 100644 --- a/crates/languages/src/typescript/runnables.scm +++ b/crates/languages/src/typescript/runnables.scm @@ -2,13 +2,20 @@ ; Function expression that has `it`, `test` or `describe` as the function name ( (call_expression - function: (_) @_name + function: [ + (identifier) @_name + (member_expression + object: [ + (identifier) @_name + (member_expression object: (identifier) @_name) + ]* + ) + ] (#any-of? @_name "it" "test" "describe") arguments: ( - arguments . (string - (string_fragment) @run - ) + arguments . (string (string_fragment) @run) ) - ) @_ts-test - (#set! tag ts-test) + ) @_js-test + + (#set! tag js-test) ) From 461ab24a0618484644b4e8732060e70bc2b5c0c6 Mon Sep 17 00:00:00 2001 From: Jared Ramirez Date: Wed, 27 Nov 2024 22:04:11 -0800 Subject: [PATCH 047/215] Update nix cargo hash (#21257) Closes https://github.com/zed-industries/zed/issues/21256 Release Notes: - N/A --- nix/build.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nix/build.nix b/nix/build.nix index 903f9790c7..d3d3d1aab1 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -90,7 +90,7 @@ rustPlatform.buildRustPackage rec { ]; useFetchCargoVendor = true; - cargoHash = "sha256-xL/EBe3+rlaPwU2zZyQtsZNHGBjzAD8ZCWrQXCQVxm8="; + cargoHash = "sha256-KURM1W9UP65BU9gbvEBgQj3jwSYfQT7X18gcSmOMguI="; nativeBuildInputs = [ From e9e260776bba12a0427e875d0cd29914ab8220cc Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Thu, 28 Nov 2024 16:08:07 +0800 Subject: [PATCH 048/215] gpui: Fix default colors blue, red, green to match in CSS default colors (#20851) Release Notes: - N/A --- This change to let the default colors to 100% match with CSS default colors. And update the methods to as `const`. Here is an example: image https://codepen.io/huacnlee/pen/ZEgNXJZ But the before version for example blue: `h: 0.6 * 360 = 216`, but we expected `240`, `240 / 360 = 0.666666666`, so the before version are lose the precision. (Here is a test tool: https://hslpicker.com/#0000FF) ## After Update ```bash cargo run -p gpui --example hello_world ``` image --- crates/gpui/examples/hello_world.rs | 19 ++++++++++++--- crates/gpui/src/color.rs | 36 ++++++++++++++--------------- 2 files changed, 34 insertions(+), 21 deletions(-) diff --git a/crates/gpui/examples/hello_world.rs b/crates/gpui/examples/hello_world.rs index 961212fa62..57312c06bb 100644 --- a/crates/gpui/examples/hello_world.rs +++ b/crates/gpui/examples/hello_world.rs @@ -8,8 +8,10 @@ impl Render for HelloWorld { fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { div() .flex() - .bg(rgb(0x2e7d32)) - .size(Length::Definite(Pixels(300.0).into())) + .flex_col() + .gap_3() + .bg(rgb(0x505050)) + .size(Length::Definite(Pixels(500.0).into())) .justify_center() .items_center() .shadow_lg() @@ -18,12 +20,23 @@ impl Render for HelloWorld { .text_xl() .text_color(rgb(0xffffff)) .child(format!("Hello, {}!", &self.text)) + .child( + div() + .flex() + .gap_2() + .child(div().size_8().bg(gpui::red())) + .child(div().size_8().bg(gpui::green())) + .child(div().size_8().bg(gpui::blue())) + .child(div().size_8().bg(gpui::yellow())) + .child(div().size_8().bg(gpui::black())) + .child(div().size_8().bg(gpui::white())), + ) } } fn main() { App::new().run(|cx: &mut AppContext| { - let bounds = Bounds::centered(None, size(px(300.0), px(300.0)), cx); + let bounds = Bounds::centered(None, size(px(500.), px(500.0)), cx); cx.open_window( WindowOptions { window_bounds: Some(WindowBounds::Windowed(bounds)), diff --git a/crates/gpui/src/color.rs b/crates/gpui/src/color.rs index 9c831d0875..04a35e6886 100644 --- a/crates/gpui/src/color.rs +++ b/crates/gpui/src/color.rs @@ -314,7 +314,7 @@ pub fn hsla(h: f32, s: f32, l: f32, a: f32) -> Hsla { } /// Pure black in [`Hsla`] -pub fn black() -> Hsla { +pub const fn black() -> Hsla { Hsla { h: 0., s: 0., @@ -324,7 +324,7 @@ pub fn black() -> Hsla { } /// Transparent black in [`Hsla`] -pub fn transparent_black() -> Hsla { +pub const fn transparent_black() -> Hsla { Hsla { h: 0., s: 0., @@ -334,7 +334,7 @@ pub fn transparent_black() -> Hsla { } /// Transparent black in [`Hsla`] -pub fn transparent_white() -> Hsla { +pub const fn transparent_white() -> Hsla { Hsla { h: 0., s: 0., @@ -354,7 +354,7 @@ pub fn opaque_grey(lightness: f32, opacity: f32) -> Hsla { } /// Pure white in [`Hsla`] -pub fn white() -> Hsla { +pub const fn white() -> Hsla { Hsla { h: 0., s: 0., @@ -364,7 +364,7 @@ pub fn white() -> Hsla { } /// The color red in [`Hsla`] -pub fn red() -> Hsla { +pub const fn red() -> Hsla { Hsla { h: 0., s: 1., @@ -374,9 +374,9 @@ pub fn red() -> Hsla { } /// The color blue in [`Hsla`] -pub fn blue() -> Hsla { +pub const fn blue() -> Hsla { Hsla { - h: 0.6, + h: 0.6666666667, s: 1., l: 0.5, a: 1., @@ -384,19 +384,19 @@ pub fn blue() -> Hsla { } /// The color green in [`Hsla`] -pub fn green() -> Hsla { +pub const fn green() -> Hsla { Hsla { - h: 0.33, + h: 0.3333333333, s: 1., - l: 0.5, + l: 0.25, a: 1., } } /// The color yellow in [`Hsla`] -pub fn yellow() -> Hsla { +pub const fn yellow() -> Hsla { Hsla { - h: 0.16, + h: 0.1666666667, s: 1., l: 0.5, a: 1., @@ -410,32 +410,32 @@ impl Hsla { } /// The color red - pub fn red() -> Self { + pub const fn red() -> Self { red() } /// The color green - pub fn green() -> Self { + pub const fn green() -> Self { green() } /// The color blue - pub fn blue() -> Self { + pub const fn blue() -> Self { blue() } /// The color black - pub fn black() -> Self { + pub const fn black() -> Self { black() } /// The color white - pub fn white() -> Self { + pub const fn white() -> Self { white() } /// The color transparent black - pub fn transparent_black() -> Self { + pub const fn transparent_black() -> Self { transparent_black() } From a4584c9d13876d6cb2b3fb6d4fd7f881ca359808 Mon Sep 17 00:00:00 2001 From: Stanislav Alekseev <43210583+WeetHet@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:31:12 +0200 Subject: [PATCH 049/215] Add an uninstall script (#21213) Closes #14306 This looks at what #16660 did and install.sh script as a base for the uninstall.sh script. The script is bundled with the cli by default unless the cli/no-bundled-uninstall feature is selected which is done, so package managers could build zed without bundling a useless feature and increasing binary size. I don't have capabilities to test this right now, so any help with that is appreciated. Release Notes: - Added an uninstall script for Zed installations done via zed.dev. To uninstall zed, run `zed --uninstall` via the CLI binary. --- crates/cli/Cargo.toml | 4 ++ crates/cli/build.rs | 5 ++ crates/cli/src/main.rs | 30 ++++++++ script/uninstall.sh | 158 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 crates/cli/build.rs create mode 100644 script/uninstall.sh diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 5dd53b5a09..18f49a5691 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -16,6 +16,10 @@ doctest = false name = "cli" path = "src/main.rs" +[features] +no-bundled-uninstall = [] +default = [] + [dependencies] anyhow.workspace = true clap.workspace = true diff --git a/crates/cli/build.rs b/crates/cli/build.rs new file mode 100644 index 0000000000..399755fa28 --- /dev/null +++ b/crates/cli/build.rs @@ -0,0 +1,5 @@ +fn main() { + if std::env::var("ZED_UPDATE_EXPLANATION").is_ok() { + println!(r#"cargo:rustc-cfg=feature="no-bundled-uninstall""#); + } +} diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 002b0c0173..c8e1c8d3ed 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -59,6 +59,13 @@ struct Args { /// Run zed in dev-server mode #[arg(long)] dev_server_token: Option, + /// Uninstall Zed from user system + #[cfg(all( + any(target_os = "linux", target_os = "macos"), + not(feature = "no-bundled-uninstall") + ))] + #[arg(long)] + uninstall: bool, } fn parse_path_with_position(argument_str: &str) -> anyhow::Result { @@ -119,6 +126,29 @@ fn main() -> Result<()> { return Ok(()); } + #[cfg(all( + any(target_os = "linux", target_os = "macos"), + not(feature = "no-bundled-uninstall") + ))] + if args.uninstall { + static UNINSTALL_SCRIPT: &[u8] = include_bytes!("../../../script/uninstall.sh"); + + let tmp_dir = tempfile::tempdir()?; + let script_path = tmp_dir.path().join("uninstall.sh"); + fs::write(&script_path, UNINSTALL_SCRIPT)?; + + use std::os::unix::fs::PermissionsExt as _; + fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755))?; + + let status = std::process::Command::new("sh") + .arg(&script_path) + .env("ZED_CHANNEL", &*release_channel::RELEASE_CHANNEL_NAME) + .status() + .context("Failed to execute uninstall script")?; + + std::process::exit(status.code().unwrap_or(1)); + } + let (server, server_name) = IpcOneShotServer::::new().context("Handshake before Zed spawn")?; let url = format!("zed-cli://{server_name}"); diff --git a/script/uninstall.sh b/script/uninstall.sh new file mode 100644 index 0000000000..3e460b8186 --- /dev/null +++ b/script/uninstall.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env sh +set -eu + +# Uninstalls Zed that was installed using the install.sh script + +check_remaining_installations() { + platform="$(uname -s)" + if [ "$platform" = "Darwin" ]; then + # Check for any Zed variants in /Applications + remaining=$(ls -d /Applications/Zed*.app 2>/dev/null | wc -l) + [ "$remaining" -eq 0 ] + else + # Check for any Zed variants in ~/.local + remaining=$(ls -d "$HOME/.local/zed"*.app 2>/dev/null | wc -l) + [ "$remaining" -eq 0 ] + fi +} + +prompt_remove_preferences() { + printf "Do you want to keep your Zed preferences? [Y/n] " + read -r response + case "$response" in + [nN]|[nN][oO]) + rm -rf "$HOME/.config/zed" + echo "Preferences removed." + ;; + *) + echo "Preferences kept." + ;; + esac +} + +main() { + platform="$(uname -s)" + channel="${ZED_CHANNEL:-stable}" + + if [ "$platform" = "Darwin" ]; then + platform="macos" + elif [ "$platform" = "Linux" ]; then + platform="linux" + else + echo "Unsupported platform $platform" + exit 1 + fi + + "$platform" + + echo "Zed has been uninstalled" +} + +linux() { + suffix="" + if [ "$channel" != "stable" ]; then + suffix="-$channel" + fi + + appid="" + db_suffix="stable" + case "$channel" in + stable) + appid="dev.zed.Zed" + db_suffix="stable" + ;; + nightly) + appid="dev.zed.Zed-Nightly" + db_suffix="nightly" + ;; + preview) + appid="dev.zed.Zed-Preview" + db_suffix="preview" + ;; + dev) + appid="dev.zed.Zed-Dev" + db_suffix="dev" + ;; + *) + echo "Unknown release channel: ${channel}. Using stable app ID." + appid="dev.zed.Zed" + db_suffix="stable" + ;; + esac + + # Remove the app directory + rm -rf "$HOME/.local/zed$suffix.app" + + # Remove the binary symlink + rm -f "$HOME/.local/bin/zed" + + # Remove the .desktop file + rm -f "$HOME/.local/share/applications/${appid}.desktop" + + # Remove the database directory for this channel + rm -rf "$HOME/.local/share/zed/db/0-$db_suffix" + + # Remove socket file + rm -f "$HOME/.local/share/zed/zed-$db_suffix.sock" + + # Remove the entire Zed directory if no installations remain + if check_remaining_installations; then + rm -rf "$HOME/.local/share/zed" + prompt_remove_preferences + fi + + rm -rf $HOME/.zed_server +} + +macos() { + app="Zed.app" + db_suffix="stable" + app_id="dev.zed.Zed" + case "$channel" in + nightly) + app="Zed Nightly.app" + db_suffix="nightly" + app_id="dev.zed.Zed-Nightly" + ;; + preview) + app="Zed Preview.app" + db_suffix="preview" + app_id="dev.zed.Zed-Preview" + ;; + dev) + app="Zed Dev.app" + db_suffix="dev" + app_id="dev.zed.Zed-Dev" + ;; + esac + + # Remove the app bundle + if [ -d "/Applications/$app" ]; then + rm -rf "/Applications/$app" + fi + + # Remove the binary symlink + rm -f "$HOME/.local/bin/zed" + + # Remove the database directory for this channel + rm -rf "$HOME/Library/Application Support/Zed/db/0-$db_suffix" + + # Remove app-specific files and directories + rm -rf "$HOME/Library/Application Support/com.apple.sharedfilelist/com.apple.LSSharedFileList.ApplicationRecentDocuments/$app_id.sfl"* + rm -rf "$HOME/Library/Caches/$app_id" + rm -rf "$HOME/Library/HTTPStorages/$app_id" + rm -rf "$HOME/Library/Preferences/$app_id.plist" + rm -rf "$HOME/Library/Saved Application State/$app_id.savedState" + + # Remove the entire Zed directory if no installations remain + if check_remaining_installations; then + rm -rf "$HOME/Library/Application Support/Zed" + rm -rf "$HOME/Library/Logs/Zed" + + prompt_remove_preferences + fi + + rm -rf $HOME/.zed_server +} + +main "$@" From c2c968f2de46018891b5958e0cfec82098e06257 Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:43:25 +0800 Subject: [PATCH 050/215] Enable clangd's dot-to-arrow feature (#21142) Closes #20815 ![dot2arrow1127](https://github.com/user-attachments/assets/d825f9bf-52ae-47ee-b3a3-5f952b6e8979) Release Notes: - Enabled clangd's dot-to-arrow feature --- crates/language/src/language.rs | 10 +++++++++- crates/languages/src/c.rs | 25 +++++++++++++++++++++++-- crates/lsp/src/lsp.rs | 30 +++++++++++++++++++----------- crates/project/src/lsp_store.rs | 14 +++++++++++--- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 58be8a4dc3..2725122990 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -30,7 +30,10 @@ use gpui::{AppContext, AsyncAppContext, Model, SharedString, Task}; pub use highlight_map::HighlightMap; use http_client::HttpClient; pub use language_registry::{LanguageName, LoadedLanguage}; -use lsp::{CodeActionKind, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerName}; +use lsp::{ + CodeActionKind, InitializeParams, LanguageServerBinary, LanguageServerBinaryOptions, + LanguageServerName, +}; use parking_lot::Mutex; use regex::Regex; use schemars::{ @@ -484,6 +487,11 @@ pub trait LspAdapter: 'static + Send + Sync { fn language_ids(&self) -> HashMap { Default::default() } + + /// Support custom initialize params. + fn prepare_initialize_params(&self, original: InitializeParams) -> Result { + Ok(original) + } } async fn try_fetch_server_binary( diff --git a/crates/languages/src/c.rs b/crates/languages/src/c.rs index 8d0369f0e0..c50a16b3e4 100644 --- a/crates/languages/src/c.rs +++ b/crates/languages/src/c.rs @@ -4,10 +4,11 @@ use futures::StreamExt; use gpui::AsyncAppContext; use http_client::github::{latest_github_release, GitHubLspBinaryVersion}; pub use language::*; -use lsp::{LanguageServerBinary, LanguageServerName}; +use lsp::{InitializeParams, LanguageServerBinary, LanguageServerName}; +use serde_json::json; use smol::fs::{self, File}; use std::{any::Any, env::consts, path::PathBuf, sync::Arc}; -use util::{fs::remove_matching, maybe, ResultExt}; +use util::{fs::remove_matching, maybe, merge_json_value_into, ResultExt}; pub struct CLspAdapter; @@ -257,6 +258,26 @@ impl super::LspAdapter for CLspAdapter { filter_range, }) } + + fn prepare_initialize_params( + &self, + mut original: InitializeParams, + ) -> Result { + // enable clangd's dot-to-arrow feature. + let experimental = json!({ + "textDocument": { + "completion" : { + "editsNearCursor": true + } + } + }); + if let Some(ref mut original_experimental) = original.capabilities.experimental { + merge_json_value_into(experimental, original_experimental); + } else { + original.capabilities.experimental = Some(experimental); + } + Ok(original) + } } async fn get_cached_server_binary(container_dir: PathBuf) -> Option { diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 98755583e3..8789f5f252 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -599,22 +599,14 @@ impl LanguageServer { Ok(()) } - /// Initializes a language server by sending the `Initialize` request. - /// Note that `options` is used directly to construct [`InitializeParams`], which is why it is owned. - /// - /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize) - pub fn initialize( - mut self, - options: Option, - cx: &AppContext, - ) -> Task>> { + pub fn default_initialize_params(&self, cx: &AppContext) -> InitializeParams { let root_uri = Url::from_file_path(&self.working_dir).unwrap(); #[allow(deprecated)] - let params = InitializeParams { + InitializeParams { process_id: None, root_path: None, root_uri: Some(root_uri.clone()), - initialization_options: options, + initialization_options: None, capabilities: ClientCapabilities { workspace: Some(WorkspaceClientCapabilities { configuration: Some(true), @@ -779,6 +771,22 @@ impl LanguageServer { }), locale: None, ..Default::default() + } + } + + /// Initializes a language server by sending the `Initialize` request. + /// Note that `options` is used directly to construct [`InitializeParams`], which is why it is owned. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#initialize) + pub fn initialize( + mut self, + initialize_params: Option, + cx: &AppContext, + ) -> Task>> { + let params = if let Some(params) = initialize_params { + params + } else { + self.default_initialize_params(cx) }; cx.spawn(|_| async move { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 6f4d23fa76..7d75347cf0 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -5673,8 +5673,6 @@ impl LspStore { .initialization_options(&(delegate)) .await?; - Self::setup_lsp_messages(this.clone(), &language_server, delegate, adapter); - match (&mut initialization_options, override_options) { (Some(initialization_options), Some(override_options)) => { merge_json_value_into(override_options, initialization_options); @@ -5683,8 +5681,18 @@ impl LspStore { _ => {} } + let initialization_params = cx.update(|cx| { + let mut params = language_server.default_initialize_params(cx); + params.initialization_options = initialization_options; + adapter.adapter.prepare_initialize_params(params) + })??; + + Self::setup_lsp_messages(this.clone(), &language_server, delegate, adapter); + let language_server = cx - .update(|cx| language_server.initialize(initialization_options, cx))? + .update(|cx| { + language_server.initialize(Some(initialization_params), cx) + })? .await .inspect_err(|_| { if let Some(this) = this.upgrade() { From 28640ac0766eda9a04b767ffd0d1ac43c8d4ad7f Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:55:46 +0200 Subject: [PATCH 051/215] Update astral-sh/setup-uv digest to caf0cab (#20927) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [astral-sh/setup-uv](https://redirect.github.com/astral-sh/setup-uv) | action | digest | `2e657c1` -> `caf0cab` | --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/community_update_all_top_ranking_issues.yml | 2 +- .../workflows/community_update_weekly_top_ranking_issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/community_update_all_top_ranking_issues.yml b/.github/workflows/community_update_all_top_ranking_issues.yml index af69446462..9642315bb3 100644 --- a/.github/workflows/community_update_all_top_ranking_issues.yml +++ b/.github/workflows/community_update_all_top_ranking_issues.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up uv - uses: astral-sh/setup-uv@2e657c127d5b1635d5a8e3fa40e0ac50a5bf6992 # v3 + uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: version: "latest" enable-cache: true diff --git a/.github/workflows/community_update_weekly_top_ranking_issues.yml b/.github/workflows/community_update_weekly_top_ranking_issues.yml index 18f525ab3b..53dcfd1d87 100644 --- a/.github/workflows/community_update_weekly_top_ranking_issues.yml +++ b/.github/workflows/community_update_weekly_top_ranking_issues.yml @@ -12,7 +12,7 @@ jobs: steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 - name: Set up uv - uses: astral-sh/setup-uv@2e657c127d5b1635d5a8e3fa40e0ac50a5bf6992 # v3 + uses: astral-sh/setup-uv@caf0cab7a618c569241d31dcd442f54681755d39 # v3 with: version: "latest" enable-cache: true From 4342a93d2226c3152cadc8304e6fe4540115cb84 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:55:57 +0200 Subject: [PATCH 052/215] Update Rust crate tree-sitter-c to v0.23.2 (#20938) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [tree-sitter-c](https://redirect.github.com/tree-sitter/tree-sitter-c) | workspace.dependencies | patch | `0.23.1` -> `0.23.2` | --- ### Release Notes
tree-sitter/tree-sitter-c (tree-sitter-c) ### [`v0.23.2`](https://redirect.github.com/tree-sitter/tree-sitter-c/releases/tag/v0.23.2) [Compare Source](https://redirect.github.com/tree-sitter/tree-sitter-c/compare/v0.23.1...v0.23.2) **NOTE:** Download `tree-sitter-c.tar.xz` for the *complete* source code.
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f5c45f8d4a..97e92f46f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13170,9 +13170,9 @@ dependencies = [ [[package]] name = "tree-sitter-c" -version = "0.23.1" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8b3fb515e498e258799a31d78e6603767cd6892770d9e2290ec00af5c3ad80b" +checksum = "db56fadd8c3c6bc880dffcf1177c9d1c54a71a5207716db8660189082e63b587" dependencies = [ "cc", "tree-sitter-language", From 6927512e345bb8c258417e58f7b0cf25b1ac8a87 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:56:21 +0200 Subject: [PATCH 053/215] Update Rust crate ashpd to 0.10.0 (#20939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [ashpd](https://redirect.github.com/bilelmoussaoui/ashpd) | workspace.dependencies | minor | `0.9.1` -> `0.10.0` | --- ### Release Notes
bilelmoussaoui/ashpd (ashpd) ### [`v0.10.2`](https://redirect.github.com/bilelmoussaoui/ashpd/releases/tag/0.10.2) [Compare Source](https://redirect.github.com/bilelmoussaoui/ashpd/compare/0.10.1...0.10.2) - Add `backend` feature to docs.rs ### [`v0.10.1`](https://redirect.github.com/bilelmoussaoui/ashpd/releases/tag/0.10.1) [Compare Source](https://redirect.github.com/bilelmoussaoui/ashpd/compare/0.10.0...0.10.1) #### What's Changed - desktop/activation-token: Add helper for retriving the token from a `gtk::Widget` or a `WlSurface` - desktop/secret: Close the socket after done reading - desktop/input-capture: Fix barrier-id type - desktop: Use a Pid alias all over the codebase - desktop/notification: Support v2 of the interface - Introduce backend implementation support, allowing to write a portal implementation in pure Rust. Currently, we don't support Session based portals. The backend feature is considered experimental as we might possibly introduce API breaking changes in the future but it should be good enough for getting started. Examples of how a portal can be implemented can be found in [backend-demo](https://redirect.github.com/bilelmoussaoui/ashpd/tree/master/backend-demo) **Note**: The 0.10.0 release has been yanked from crates.io as it contained a build error when the `glib` feature is enabled. ### [`v0.10.0`](https://redirect.github.com/bilelmoussaoui/ashpd/compare/0.9.2...0.10.0) [Compare Source](https://redirect.github.com/bilelmoussaoui/ashpd/compare/0.9.2...0.10.0)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 127 +++++++++++++++++++++++++++++++++++++++++++++++------ Cargo.toml | 2 +- 2 files changed, 114 insertions(+), 15 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 97e92f46f3..a1727c610f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -342,20 +342,19 @@ dependencies = [ [[package]] name = "ashpd" -version = "0.9.2" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d43c03d9e36dd40cab48435be0b09646da362c278223ca535493877b2c1dee9" +checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" dependencies = [ - "async-fs 2.1.2", - "async-net 2.0.0", "enumflags2", "futures-channel", "futures-util", "rand 0.8.5", "serde", "serde_repr", + "tokio", "url", - "zbus", + "zbus 5.1.1", ] [[package]] @@ -7988,9 +7987,9 @@ dependencies = [ "serde", "sha2", "subtle", - "zbus", + "zbus 4.4.0", "zeroize", - "zvariant", + "zvariant 4.2.0", ] [[package]] @@ -12798,6 +12797,7 @@ dependencies = [ "signal-hook-registry", "socket2 0.5.7", "tokio-macros", + "tracing", "windows-sys 0.52.0", ] @@ -15591,9 +15591,39 @@ dependencies = [ "uds_windows", "windows-sys 0.52.0", "xdg-home", - "zbus_macros", - "zbus_names", - "zvariant", + "zbus_macros 4.4.0", + "zbus_names 3.0.0", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1162094dc63b1629fcc44150bcceeaa80798cd28bcbe7fa987b65a034c258608" +dependencies = [ + "async-broadcast", + "async-recursion 1.1.1", + "async-trait", + "enumflags2", + "event-listener 5.3.1", + "futures-core", + "futures-util", + "hex", + "nix", + "ordered-stream", + "serde", + "serde_repr", + "static_assertions", + "tokio", + "tracing", + "uds_windows", + "windows-sys 0.59.0", + "winnow 0.6.20", + "xdg-home", + "zbus_macros 5.1.1", + "zbus_names 4.1.0", + "zvariant 5.1.0", ] [[package]] @@ -15606,7 +15636,22 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.87", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zbus_macros" +version = "5.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd2dcdce3e2727f7d74b7e33b5a89539b3cc31049562137faf7ae4eb86cd16d" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.87", + "zbus_names 4.1.0", + "zvariant 5.1.0", + "zvariant_utils 3.0.2", ] [[package]] @@ -15617,7 +15662,19 @@ checksum = "4b9b1fef7d021261cc16cba64c351d291b715febe0fa10dc3a443ac5a5022e6c" dependencies = [ "serde", "static_assertions", - "zvariant", + "zvariant 4.2.0", +] + +[[package]] +name = "zbus_names" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "856b7a38811f71846fd47856ceee8bccaec8399ff53fb370247e66081ace647b" +dependencies = [ + "serde", + "static_assertions", + "winnow 0.6.20", + "zvariant 5.1.0", ] [[package]] @@ -16107,13 +16164,28 @@ name = "zvariant" version = "4.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2084290ab9a1c471c38fc524945837734fbf124487e105daec2bb57fd48c81fe" +dependencies = [ + "endi", + "enumflags2", + "serde", + "static_assertions", + "zvariant_derive 4.2.0", +] + +[[package]] +name = "zvariant" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1200ee6ac32f1e5a312e455a949a4794855515d34f9909f4a3e082d14e1a56f" dependencies = [ "endi", "enumflags2", "serde", "static_assertions", "url", - "zvariant_derive", + "winnow 0.6.20", + "zvariant_derive 5.1.0", + "zvariant_utils 3.0.2", ] [[package]] @@ -16126,7 +16198,20 @@ dependencies = [ "proc-macro2", "quote", "syn 2.0.87", - "zvariant_utils", + "zvariant_utils 2.1.0", +] + +[[package]] +name = "zvariant_derive" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687e3b97fae6c9104fbbd36c73d27d149abf04fb874e2efbd84838763daa8916" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.87", + "zvariant_utils 3.0.2", ] [[package]] @@ -16139,3 +16224,17 @@ dependencies = [ "quote", "syn 2.0.87", ] + +[[package]] +name = "zvariant_utils" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20d1d011a38f12360e5fcccceeff5e2c42a8eb7f27f0dcba97a0862ede05c9c6" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "static_assertions", + "syn 2.0.87", + "winnow 0.6.20", +] diff --git a/Cargo.toml b/Cargo.toml index 71701dd8f4..996d41e803 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -333,7 +333,7 @@ alacritty_terminal = { git = "https://github.com/alacritty/alacritty", rev = "91 any_vec = "0.14" anyhow = "1.0.86" arrayvec = { version = "0.7.4", features = ["serde"] } -ashpd = "0.9.1" +ashpd = "0.10.0" async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" From 38900c2321fb417d3b96c529bfa56c635c7e5c2a Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:56:36 +0200 Subject: [PATCH 054/215] Update Rust crate bytemuck to v1.20.0 (#20947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [bytemuck](https://redirect.github.com/Lokathor/bytemuck) | dependencies | minor | `1.19.0` -> `1.20.0` | --- ### Release Notes
Lokathor/bytemuck (bytemuck) ### [`v1.20.0`](https://redirect.github.com/Lokathor/bytemuck/compare/v1.19.0...v1.20.0) [Compare Source](https://redirect.github.com/Lokathor/bytemuck/compare/v1.19.0...v1.20.0)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a1727c610f..f24731677d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1974,9 +1974,9 @@ dependencies = [ [[package]] name = "bytemuck" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" +checksum = "8b37c88a63ffd85d15b406896cc343916d7cf57838a847b3a6f2ca5d39a5695a" dependencies = [ "bytemuck_derive", ] From fe30a03921191c56c725f02c3edb1ded315e6a2c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:58:10 +0200 Subject: [PATCH 055/215] Update Rust crate ipc-channel to 0.19 (#20951) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [ipc-channel](https://redirect.github.com/servo/ipc-channel) | dependencies | minor | `0.18` -> `0.19` | --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- crates/cli/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f24731677d..6082b46fa0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6284,9 +6284,9 @@ dependencies = [ [[package]] name = "ipc-channel" -version = "0.18.3" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f4c80f2df4fc64fb7fc2cff69fc034af26e6e6617ea9f1313131af464b9ca0" +checksum = "6fb8251fb7bcd9ccd3725ed8deae9fe7db8e586495c9eb5b0c52e6233e5e75ea" dependencies = [ "bincode", "crossbeam-channel", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 18f49a5691..fedd6738ed 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -24,7 +24,7 @@ default = [] anyhow.workspace = true clap.workspace = true collections.workspace = true -ipc-channel = "0.18" +ipc-channel = "0.19" once_cell.workspace = true parking_lot.workspace = true paths.workspace = true From 4aa47a90631c69457a279f17812c165ae9ac8a6b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:58:36 +0200 Subject: [PATCH 056/215] Update Rust crate rodio to 0.20.0 (#20955) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [rodio](https://redirect.github.com/RustAudio/rodio) | dependencies | minor | `0.19.0` -> `0.20.0` | --- ### Release Notes
RustAudio/rodio (rodio) ### [`v0.20.1`](https://redirect.github.com/RustAudio/rodio/blob/HEAD/CHANGELOG.md#Version-0201-2024-11-08) [Compare Source](https://redirect.github.com/RustAudio/rodio/compare/v0.20.0...v0.20.1) ##### Fixed - Builds without the `symphonia` feature did not compile ### [`v0.20.0`](https://redirect.github.com/RustAudio/rodio/blob/HEAD/CHANGELOG.md#Version-0200-2024-11-08) [Compare Source](https://redirect.github.com/RustAudio/rodio/compare/v0.19.0...v0.20.0) ##### Added - Support for *ALAC/AIFF* - Add `automatic_gain_control` source for dynamic audio level adjustment. - New test signal generator sources: - `SignalGenerator` source generates a sine, triangle, square wave or sawtooth of a given frequency and sample rate. - `Chirp` source generates a sine wave with a linearly-increasing frequency over a given frequency range and duration. - `white` and `pink` generate white or pink noise, respectively. These sources depend on the `rand` crate and are guarded with the "noise" feature. - Documentation for the "noise" feature has been added to `lib.rs`. - New Fade and Crossfade sources: - `fade_out` fades an input out using a linear gain fade. - `linear_gain_ramp` applies a linear gain change to a sound over a given duration. `fade_out` is implemented as a `linear_gain_ramp` and `fade_in` has been refactored to use the `linear_gain_ramp` implementation. ##### Fixed - `Sink.try_seek` now updates `controls.position` before returning. Calls to `Sink.get_pos` done immediately after a seek will now return the correct value. ##### Changed - `SamplesBuffer` is now `Clone`
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 5 ++--- crates/audio/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6082b46fa0..68af825b9d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10286,13 +10286,12 @@ dependencies = [ [[package]] name = "rodio" -version = "0.19.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" +checksum = "e7ceb6607dd738c99bc8cb28eff249b7cd5c8ec88b9db96c0608c1480d140fb1" dependencies = [ "cpal", "hound", - "thiserror 1.0.69", ] [[package]] diff --git a/crates/audio/Cargo.toml b/crates/audio/Cargo.toml index 9502b58f93..f3bc173764 100644 --- a/crates/audio/Cargo.toml +++ b/crates/audio/Cargo.toml @@ -18,5 +18,5 @@ collections.workspace = true derive_more.workspace = true gpui.workspace = true parking_lot.workspace = true -rodio = { version = "0.19.0", default-features = false, features = ["wav"] } +rodio = { version = "0.20.0", default-features = false, features = ["wav"] } util.workspace = true From 1739de59d4438529ba6a4bf6ba472bc350a13eea Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 28 Nov 2024 10:58:53 +0200 Subject: [PATCH 057/215] Update Rust crate proc-macro2 to v1.0.92 (#20967) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [proc-macro2](https://redirect.github.com/dtolnay/proc-macro2) | dependencies | patch | `1.0.89` -> `1.0.92` | --- ### Release Notes
dtolnay/proc-macro2 (proc-macro2) ### [`v1.0.92`](https://redirect.github.com/dtolnay/proc-macro2/releases/tag/1.0.92) [Compare Source](https://redirect.github.com/dtolnay/proc-macro2/compare/1.0.91...1.0.92) - Improve compiler/fallback mismatch panic message ([#​487](https://redirect.github.com/dtolnay/proc-macro2/issues/487)) ### [`v1.0.91`](https://redirect.github.com/dtolnay/proc-macro2/releases/tag/1.0.91) [Compare Source](https://redirect.github.com/dtolnay/proc-macro2/compare/1.0.90...1.0.91) - Fix panic *"compiler/fallback mismatch 949"* when using TokenStream::from_str from inside a proc macro to parse a string containing doc comment ([#​484](https://redirect.github.com/dtolnay/proc-macro2/issues/484)) ### [`v1.0.90`](https://redirect.github.com/dtolnay/proc-macro2/releases/tag/1.0.90) [Compare Source](https://redirect.github.com/dtolnay/proc-macro2/compare/1.0.89...1.0.90) - Improve error recovery in TokenStream's and Literal's FromStr implementations to work around [https://github.com/rust-lang/rust/issues/58736](https://redirect.github.com/rust-lang/rust/issues/58736) such that rustc does not poison compilation on codepaths that should be recoverable errors ([#​477](https://redirect.github.com/dtolnay/proc-macro2/issues/477), [#​478](https://redirect.github.com/dtolnay/proc-macro2/issues/478), [#​479](https://redirect.github.com/dtolnay/proc-macro2/issues/479), [#​480](https://redirect.github.com/dtolnay/proc-macro2/issues/480), [#​481](https://redirect.github.com/dtolnay/proc-macro2/issues/481), [#​482](https://redirect.github.com/dtolnay/proc-macro2/issues/482))
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 68af825b9d..fc4de8bd7c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9220,9 +9220,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.89" +version = "1.0.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f139b0662de085916d1fb67d2b4169d1addddda1919e696f3252b740b629986e" +checksum = "37d3544b3f2748c54e147655edb5025752e2303145b5aefb3c3ea2c78b973bb0" dependencies = [ "unicode-ident", ] From b12a508ed9ae5b7ba42b207f68c1e9f4c9f90a78 Mon Sep 17 00:00:00 2001 From: Jaagup Averin Date: Thu, 28 Nov 2024 10:59:10 +0200 Subject: [PATCH 058/215] python: Fix highlighting for forward references (#20766) [PEP484](https://peps.python.org/pep-0484/) defines "Forward references" for undefined types. This PR treats such annotations as types rather than strings. Release Notes: - Added Python syntax highlighting for forward references. --- crates/languages/src/python/highlights.scm | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/languages/src/python/highlights.scm b/crates/languages/src/python/highlights.scm index 98ed203969..3b318fe962 100644 --- a/crates/languages/src/python/highlights.scm +++ b/crates/languages/src/python/highlights.scm @@ -18,6 +18,12 @@ (tuple (identifier) @type) ) +; Forward references +(type + (string) @type +) + + ; Function calls (decorator From 3ac119ac4edd373a896e6b98976843fd0f85679a Mon Sep 17 00:00:00 2001 From: Zach Bruggeman Date: Thu, 28 Nov 2024 01:00:45 -0800 Subject: [PATCH 059/215] Fix hovered links underline not showing when using cmd_or_ctrl for multi_cursor_modifier (#20949) I use `cmd_or_ctrl` for `multi_cursor_modifier`, but noticed that if I hovered a code reference while holding alt, it wouldn't show the underline. Instead, it would only show when pressing cmd. Looking at the code, it seems like this was just a small oversight on always checking for `modifiers.secondary`, instead of reading from the `multi_cursor_modifier` setting to determine which button was invoking link handling. --- Release Notes: - Fixed underline when hovering a code link not showing when `multi_cursor_modifier` is `cmd_or_ctrl` --- crates/editor/src/hover_links.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/hover_links.rs b/crates/editor/src/hover_links.rs index 31be9e93a9..0973f59bab 100644 --- a/crates/editor/src/hover_links.rs +++ b/crates/editor/src/hover_links.rs @@ -1,8 +1,9 @@ use crate::{ + editor_settings::MultiCursorModifier, hover_popover::{self, InlayHover}, scroll::ScrollAmount, - Anchor, Editor, EditorSnapshot, FindAllReferences, GoToDefinition, GoToTypeDefinition, - GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase, + Anchor, Editor, EditorSettings, EditorSnapshot, FindAllReferences, GoToDefinition, + GoToTypeDefinition, GotoDefinitionKind, InlayId, Navigated, PointForPosition, SelectPhase, }; use gpui::{px, AppContext, AsyncWindowContext, Model, Modifiers, Task, ViewContext}; use language::{Bias, ToOffset}; @@ -12,6 +13,7 @@ use project::{ HoverBlock, HoverBlockKind, InlayHintLabelPartTooltip, InlayHintTooltip, LocationLink, Project, ResolveState, ResolvedPath, }; +use settings::Settings; use std::ops::Range; use theme::ActiveTheme as _; use util::{maybe, ResultExt, TryFutureExt as _}; @@ -117,7 +119,12 @@ impl Editor { modifiers: Modifiers, cx: &mut ViewContext, ) { - if !modifiers.secondary() || self.has_pending_selection() { + let multi_cursor_setting = EditorSettings::get_global(cx).multi_cursor_modifier; + let hovered_link_modifier = match multi_cursor_setting { + MultiCursorModifier::Alt => modifiers.secondary(), + MultiCursorModifier::CmdOrCtrl => modifiers.alt, + }; + if !hovered_link_modifier || self.has_pending_selection() { self.hide_hovered_link(cx); return; } @@ -137,7 +144,7 @@ impl Editor { snapshot, point_for_position, self, - modifiers.secondary(), + hovered_link_modifier, modifiers.shift, cx, ); From cacec06db66fe29252b0f24b08e53509812f32a6 Mon Sep 17 00:00:00 2001 From: CharlesChen0823 Date: Thu, 28 Nov 2024 17:06:48 +0800 Subject: [PATCH 060/215] search: Treat non-word char as whole-char when searching (#19152) when search somethings like `clone(`, with search options `match case sensitively` and `match whole words` in zed code base, only `clone(cx)` hit match, `clone()` will not hit math. Release Notes: - Improved buffer search for queries ending with non-letter characters --- crates/project/src/search.rs | 26 ++++++++-- crates/search/src/buffer_search.rs | 80 ++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 6a2d5032e4..0708f25410 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -3,14 +3,14 @@ use anyhow::Result; use client::proto; use fancy_regex::{Captures, Regex, RegexBuilder}; use gpui::Model; -use language::{Buffer, BufferSnapshot}; +use language::{Buffer, BufferSnapshot, CharKind}; use smol::future::yield_now; use std::{ borrow::Cow, io::{BufRead, BufReader, Read}, ops::Range, path::Path, - sync::{Arc, OnceLock}, + sync::{Arc, LazyLock, OnceLock}, }; use text::Anchor; use util::paths::PathMatcher; @@ -76,6 +76,12 @@ pub enum SearchQuery { }, } +static WORD_MATCH_TEST: LazyLock = LazyLock::new(|| { + RegexBuilder::new(r"\B") + .build() + .expect("Failed to create WORD_MATCH_TEST") +}); + impl SearchQuery { pub fn text( query: impl ToString, @@ -119,9 +125,17 @@ impl SearchQuery { let initial_query = Arc::from(query.as_str()); if whole_word { let mut word_query = String::new(); - word_query.push_str("\\b"); + if let Some(first) = query.get(0..1) { + if WORD_MATCH_TEST.is_match(first).is_ok_and(|x| !x) { + word_query.push_str("\\b"); + } + } word_query.push_str(&query); - word_query.push_str("\\b"); + if let Some(last) = query.get(query.len() - 1..) { + if WORD_MATCH_TEST.is_match(last).is_ok_and(|x| !x) { + word_query.push_str("\\b"); + } + } query = word_query } @@ -313,7 +327,9 @@ impl SearchQuery { let end_kind = classifier.kind(rope.reversed_chars_at(mat.end()).next().unwrap()); let next_kind = rope.chars_at(mat.end()).next().map(|c| classifier.kind(c)); - if Some(start_kind) == prev_kind || Some(end_kind) == next_kind { + if (Some(start_kind) == prev_kind && start_kind == CharKind::Word) + || (Some(end_kind) == next_kind && end_kind == CharKind::Word) + { continue; } } diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 41e5ba28df..b8603b8649 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1866,6 +1866,86 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_search_query_with_match_whole_word(cx: &mut TestAppContext) { + init_globals(cx); + let buffer_text = r#" + self.buffer.update(cx, |buffer, cx| { + buffer.edit( + edits, + Some(AutoindentMode::Block { + original_indent_columns, + }), + cx, + ) + }); + + this.buffer.update(cx, |buffer, cx| { + buffer.edit([(end_of_line..start_of_next_line, replace)], None, cx) + }); + "# + .unindent(); + let buffer = cx.new_model(|cx| Buffer::local(buffer_text, cx)); + let cx = cx.add_empty_window(); + + let editor = cx.new_view(|cx| Editor::for_buffer(buffer.clone(), None, cx)); + + let search_bar = cx.new_view(|cx| { + let mut search_bar = BufferSearchBar::new(cx); + search_bar.set_active_pane_item(Some(&editor), cx); + search_bar.show(cx); + search_bar + }); + + search_bar + .update(cx, |search_bar, cx| { + search_bar.search( + "edit\\(", + Some(SearchOptions::WHOLE_WORD | SearchOptions::REGEX), + cx, + ) + }) + .await + .unwrap(); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + search_bar.update(cx, |_, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + 2, + "Should select all `edit(` in the buffer, but got: {all_selections:?}" + ); + }); + + search_bar + .update(cx, |search_bar, cx| { + search_bar.search( + "edit(", + Some(SearchOptions::WHOLE_WORD | SearchOptions::CASE_SENSITIVE), + cx, + ) + }) + .await + .unwrap(); + + search_bar.update(cx, |search_bar, cx| { + search_bar.select_all_matches(&SelectAllMatches, cx); + }); + search_bar.update(cx, |_, cx| { + let all_selections = + editor.update(cx, |editor, cx| editor.selections.display_ranges(cx)); + assert_eq!( + all_selections.len(), + 2, + "Should select all `edit(` in the buffer, but got: {all_selections:?}" + ); + }); + } + #[gpui::test] async fn test_search_query_history(cx: &mut TestAppContext) { init_globals(cx); From 6cba467a4e218e85180ce271219d47b1923402de Mon Sep 17 00:00:00 2001 From: Gowtham K <73059450+dovakin0007@users.noreply.github.com> Date: Thu, 28 Nov 2024 16:50:10 +0530 Subject: [PATCH 061/215] project-panel: Fix playback GIF images (#21274) --- crates/image_viewer/src/image_viewer.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index ed87562e64..f7647223e5 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -301,7 +301,8 @@ impl Render for ImageView { img(image) .object_fit(ObjectFit::ScaleDown) .max_w_full() - .max_h_full(), + .max_h_full() + .id("img"), ), ) } From f30944543e6557b2cbb7527ebef014424043311a Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 28 Nov 2024 18:16:37 +0200 Subject: [PATCH 062/215] Do less resolves when showing the completion menu (#21286) Closes https://github.com/zed-industries/zed/issues/21205 Zed does completion resolve on every menu item selection and when applying the edit, so resolving all completion menu list is excessive indeed. In addition to that, removes the documentation-centric approach of menu resolves, as we're actually resolving these for more than that, e.g. additionalTextEdits and have to do that always, even if we do not show the documentation. Potentially, we can omit the second resolve too, but that seems relatively dangerous, and many servers remove the `data` after the first resolve, so a 2nd one is not that harmful given that we used to do much more Release Notes: - Reduced the amount of `completionItem/resolve` calls done in the completion menu --- crates/editor/src/editor.rs | 108 ++++------------- crates/editor/src/editor_tests.rs | 195 +++++++++++++++++------------- 2 files changed, 130 insertions(+), 173 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 78f0aab5a5..611ec9232e 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -596,7 +596,6 @@ pub struct Editor { auto_signature_help: Option, find_all_references_task_sources: Vec, next_completion_id: CompletionId, - completion_documentation_pre_resolve_debounce: DebouncedDelay, available_code_actions: Option<(Location, Arc<[AvailableCodeAction]>)>, code_actions_task: Option>>, document_highlights_task: Option>, @@ -1006,7 +1005,7 @@ struct CompletionsMenu { matches: Arc<[StringMatch]>, selected_item: usize, scroll_handle: UniformListScrollHandle, - selected_completion_documentation_resolve_debounce: Option>>, + selected_completion_resolve_debounce: Option>>, } impl CompletionsMenu { @@ -1038,9 +1037,7 @@ impl CompletionsMenu { matches: Vec::new().into(), selected_item: 0, scroll_handle: UniformListScrollHandle::new(), - selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new( - DebouncedDelay::new(), - ))), + selected_completion_resolve_debounce: Some(Arc::new(Mutex::new(DebouncedDelay::new()))), } } @@ -1093,15 +1090,12 @@ impl CompletionsMenu { matches, selected_item: 0, scroll_handle: UniformListScrollHandle::new(), - selected_completion_documentation_resolve_debounce: Some(Arc::new(Mutex::new( - DebouncedDelay::new(), - ))), + selected_completion_resolve_debounce: Some(Arc::new(Mutex::new(DebouncedDelay::new()))), } } fn suppress_documentation_resolution(mut self) -> Self { - self.selected_completion_documentation_resolve_debounce - .take(); + self.selected_completion_resolve_debounce.take(); self } @@ -1113,7 +1107,7 @@ impl CompletionsMenu { self.selected_item = 0; self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } @@ -1129,7 +1123,7 @@ impl CompletionsMenu { } self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } @@ -1145,7 +1139,7 @@ impl CompletionsMenu { } self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } @@ -1157,58 +1151,20 @@ impl CompletionsMenu { self.selected_item = self.matches.len() - 1; self.scroll_handle .scroll_to_item(self.selected_item, ScrollStrategy::Top); - self.attempt_resolve_selected_completion_documentation(provider, cx); + self.resolve_selected_completion(provider, cx); cx.notify(); } - fn pre_resolve_completion_documentation( - buffer: Model, - completions: Arc>>, - matches: Arc<[StringMatch]>, - editor: &Editor, - cx: &mut ViewContext, - ) -> Task<()> { - let settings = EditorSettings::get_global(cx); - if !settings.show_completion_documentation { - return Task::ready(()); - } - - let Some(provider) = editor.completion_provider.as_ref() else { - return Task::ready(()); - }; - - let resolve_task = provider.resolve_completions( - buffer, - matches.iter().map(|m| m.candidate_id).collect(), - completions.clone(), - cx, - ); - - cx.spawn(move |this, mut cx| async move { - if let Some(true) = resolve_task.await.log_err() { - this.update(&mut cx, |_, cx| cx.notify()).ok(); - } - }) - } - - fn attempt_resolve_selected_completion_documentation( + fn resolve_selected_completion( &mut self, provider: Option<&dyn CompletionProvider>, cx: &mut ViewContext, ) { - let settings = EditorSettings::get_global(cx); - if !settings.show_completion_documentation { - return; - } - let completion_index = self.matches[self.selected_item].candidate_id; let Some(provider) = provider else { return; }; - let Some(documentation_resolve) = self - .selected_completion_documentation_resolve_debounce - .as_ref() - else { + let Some(completion_resolve) = self.selected_completion_resolve_debounce.as_ref() else { return; }; @@ -1223,7 +1179,7 @@ impl CompletionsMenu { EditorSettings::get_global(cx).completion_documentation_secondary_query_debounce; let delay = Duration::from_millis(delay_ms); - documentation_resolve.lock().fire_new(delay, cx, |_, cx| { + completion_resolve.lock().fire_new(delay, cx, |_, cx| { cx.spawn(move |this, mut cx| async move { if let Some(true) = resolve_task.await.log_err() { this.update(&mut cx, |_, cx| cx.notify()).ok(); @@ -2118,7 +2074,6 @@ impl Editor { auto_signature_help: None, find_all_references_task_sources: Vec::new(), next_completion_id: 0, - completion_documentation_pre_resolve_debounce: DebouncedDelay::new(), next_inlay_id: 0, code_action_providers, available_code_actions: Default::default(), @@ -4523,9 +4478,9 @@ impl Editor { let sort_completions = provider.sort_completions(); let id = post_inc(&mut self.next_completion_id); - let task = cx.spawn(|this, mut cx| { + let task = cx.spawn(|editor, mut cx| { async move { - this.update(&mut cx, |this, _| { + editor.update(&mut cx, |this, _| { this.completion_tasks.retain(|(task_id, _)| *task_id >= id); })?; let completions = completions.await.log_err(); @@ -4543,34 +4498,14 @@ impl Editor { if menu.matches.is_empty() { None } else { - this.update(&mut cx, |editor, cx| { - let completions = menu.completions.clone(); - let matches = menu.matches.clone(); - - let delay_ms = EditorSettings::get_global(cx) - .completion_documentation_secondary_query_debounce; - let delay = Duration::from_millis(delay_ms); - editor - .completion_documentation_pre_resolve_debounce - .fire_new(delay, cx, |editor, cx| { - CompletionsMenu::pre_resolve_completion_documentation( - buffer, - completions, - matches, - editor, - cx, - ) - }); - }) - .ok(); Some(menu) } } else { None }; - this.update(&mut cx, |this, cx| { - let mut context_menu = this.context_menu.write(); + editor.update(&mut cx, |editor, cx| { + let mut context_menu = editor.context_menu.write(); match context_menu.as_ref() { None => {} @@ -4583,19 +4518,20 @@ impl Editor { _ => return, } - if this.focus_handle.is_focused(cx) && menu.is_some() { - let menu = menu.unwrap(); + if editor.focus_handle.is_focused(cx) && menu.is_some() { + let mut menu = menu.unwrap(); + menu.resolve_selected_completion(editor.completion_provider.as_deref(), cx); *context_menu = Some(ContextMenu::Completions(menu)); drop(context_menu); - this.discard_inline_completion(false, cx); + editor.discard_inline_completion(false, cx); cx.notify(); - } else if this.completion_tasks.len() <= 1 { + } else if editor.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 completion when available. drop(context_menu); - if this.hide_context_menu(cx).is_none() { - this.update_visible_inline_completion(cx); + if editor.hide_context_menu(cx).is_none() { + editor.update_visible_inline_completion(cx); } } })?; diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 669134ef10..b49b3fa33b 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -31,8 +31,8 @@ use project::{ project_settings::{LspSettings, ProjectSettings}, }; use serde_json::{self, json}; -use std::sync::atomic; use std::sync::atomic::AtomicUsize; +use std::sync::atomic::{self, AtomicBool}; use std::{cell::RefCell, future::Future, rc::Rc, time::Instant}; use unindent::Unindent; use util::{ @@ -10576,6 +10576,94 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo }, }; + let resolve_requests_number = Arc::new(AtomicUsize::new(0)); + let expect_first_item = Arc::new(AtomicBool::new(true)); + cx.lsp + .server + .on_request::({ + let closure_default_data = default_data.clone(); + let closure_resolve_requests_number = resolve_requests_number.clone(); + let closure_expect_first_item = expect_first_item.clone(); + let closure_default_commit_characters = default_commit_characters.clone(); + move |item_to_resolve, _| { + closure_resolve_requests_number.fetch_add(1, atomic::Ordering::Release); + let default_data = closure_default_data.clone(); + let default_commit_characters = closure_default_commit_characters.clone(); + let expect_first_item = closure_expect_first_item.clone(); + async move { + if expect_first_item.load(atomic::Ordering::Acquire) { + assert_eq!( + item_to_resolve.label, "Some(2)", + "Should have selected the first item" + ); + assert_eq!( + item_to_resolve.data, + Some(json!({ "very": "special"})), + "First item should bring its own data for resolving" + ); + assert_eq!( + item_to_resolve.commit_characters, + Some(default_commit_characters), + "First item had no own commit characters and should inherit the default ones" + ); + assert!( + matches!( + item_to_resolve.text_edit, + Some(lsp::CompletionTextEdit::InsertAndReplace { .. }) + ), + "First item should bring its own edit range for resolving" + ); + assert_eq!( + item_to_resolve.insert_text_format, + Some(default_insert_text_format), + "First item had no own insert text format and should inherit the default one" + ); + assert_eq!( + item_to_resolve.insert_text_mode, + Some(lsp::InsertTextMode::ADJUST_INDENTATION), + "First item should bring its own insert text mode for resolving" + ); + Ok(item_to_resolve) + } else { + assert_eq!( + item_to_resolve.label, "vec![2]", + "Should have selected the last item" + ); + assert_eq!( + item_to_resolve.data, + Some(default_data), + "Last item has no own resolve data and should inherit the default one" + ); + assert_eq!( + item_to_resolve.commit_characters, + Some(default_commit_characters), + "Last item had no own commit characters and should inherit the default ones" + ); + assert_eq!( + item_to_resolve.text_edit, + Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: default_edit_range, + new_text: "vec![2]".to_string() + })), + "Last item had no own edit range and should inherit the default one" + ); + assert_eq!( + item_to_resolve.insert_text_format, + Some(lsp::InsertTextFormat::PLAIN_TEXT), + "Last item should bring its own insert text format for resolving" + ); + assert_eq!( + item_to_resolve.insert_text_mode, + Some(default_insert_text_mode), + "Last item had no own insert text mode and should inherit the default one" + ); + + Ok(item_to_resolve) + } + } + } + }).detach(); + let completion_data = default_data.clone(); let completion_characters = default_commit_characters.clone(); cx.handle_request::(move |_, _, _| { @@ -10623,7 +10711,7 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo cx.condition(|editor, _| editor.context_menu_visible()) .await; - + cx.run_until_parked(); cx.update_editor(|editor, _| { let menu = editor.context_menu.read(); match menu.as_ref().expect("should have the completions menu") { @@ -10640,99 +10728,32 @@ async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppCo ContextMenu::CodeActions(_) => panic!("Expected to have the completions menu"), } }); + assert_eq!( + resolve_requests_number.load(atomic::Ordering::Acquire), + 1, + "While there are 2 items in the completion list, only 1 resolve request should have been sent, for the selected item" + ); cx.update_editor(|editor, cx| { editor.context_menu_first(&ContextMenuFirst, cx); }); - let first_item_resolve_characters = default_commit_characters.clone(); - cx.handle_request::(move |_, item_to_resolve, _| { - let default_commit_characters = first_item_resolve_characters.clone(); - - async move { - assert_eq!( - item_to_resolve.label, "Some(2)", - "Should have selected the first item" - ); - assert_eq!( - item_to_resolve.data, - Some(json!({ "very": "special"})), - "First item should bring its own data for resolving" - ); - assert_eq!( - item_to_resolve.commit_characters, - Some(default_commit_characters), - "First item had no own commit characters and should inherit the default ones" - ); - assert!( - matches!( - item_to_resolve.text_edit, - Some(lsp::CompletionTextEdit::InsertAndReplace { .. }) - ), - "First item should bring its own edit range for resolving" - ); - assert_eq!( - item_to_resolve.insert_text_format, - Some(default_insert_text_format), - "First item had no own insert text format and should inherit the default one" - ); - assert_eq!( - item_to_resolve.insert_text_mode, - Some(lsp::InsertTextMode::ADJUST_INDENTATION), - "First item should bring its own insert text mode for resolving" - ); - Ok(item_to_resolve) - } - }) - .next() - .await - .unwrap(); + cx.run_until_parked(); + assert_eq!( + resolve_requests_number.load(atomic::Ordering::Acquire), + 2, + "After re-selecting the first item, another resolve request should have been sent" + ); + expect_first_item.store(false, atomic::Ordering::Release); cx.update_editor(|editor, cx| { editor.context_menu_last(&ContextMenuLast, cx); }); - cx.handle_request::(move |_, item_to_resolve, _| { - let default_data = default_data.clone(); - let default_commit_characters = default_commit_characters.clone(); - async move { - assert_eq!( - item_to_resolve.label, "vec![2]", - "Should have selected the last item" - ); - assert_eq!( - item_to_resolve.data, - Some(default_data), - "Last item has no own resolve data and should inherit the default one" - ); - assert_eq!( - item_to_resolve.commit_characters, - Some(default_commit_characters), - "Last item had no own commit characters and should inherit the default ones" - ); - assert_eq!( - item_to_resolve.text_edit, - Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { - range: default_edit_range, - new_text: "vec![2]".to_string() - })), - "Last item had no own edit range and should inherit the default one" - ); - assert_eq!( - item_to_resolve.insert_text_format, - Some(lsp::InsertTextFormat::PLAIN_TEXT), - "Last item should bring its own insert text format for resolving" - ); - assert_eq!( - item_to_resolve.insert_text_mode, - Some(default_insert_text_mode), - "Last item had no own insert text mode and should inherit the default one" - ); - - Ok(item_to_resolve) - } - }) - .next() - .await - .unwrap(); + cx.run_until_parked(); + assert_eq!( + resolve_requests_number.load(atomic::Ordering::Acquire), + 3, + "After selecting the other item, another resolve request should have been sent" + ); } #[gpui::test] From 301a8900a5b7e3fda5d5ae01c8070b1023e4d558 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 28 Nov 2024 13:39:49 -0300 Subject: [PATCH 063/215] Add consistency between buffer and project search design (#20754) Follow up to https://github.com/zed-industries/zed/pull/20242 This PR adds the `SearchInputWidth` util, which sets a threshold container size in which an input's width stops filling the available space. In practice, this is in place to make the buffer and project search input fill the whole container width up to a certain point (where this point is really an arbitrary number that can be fine-tuned per taste). For folks using huge monitors, the UX isn't excellent if you have a gigantic input. In the future, upon further review, maybe it makes more sense to reorganize this code better, baking it in as a default behavior of the input component. Or even exposing this is a function many other components could use, given we may want to have dynamic width in different scenarios. For now, I just wanted to make the design of these search UIs better and more consistent. | Buffer Search | Project Search | |--------|--------| | Screenshot 2024-11-15 at 20 39 21 | Screenshot 2024-11-15 at 20 39 24 | Release Notes: - N/A --- crates/search/src/buffer_search.rs | 125 ++++++++++++++-------------- crates/search/src/project_search.rs | 38 +++++---- crates/ui/src/utils.rs | 2 + crates/ui/src/utils/search_input.rs | 22 +++++ 4 files changed, 109 insertions(+), 78 deletions(-) create mode 100644 crates/ui/src/utils/search_input.rs diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index b8603b8649..5b1a482f5e 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -27,7 +27,10 @@ use settings::Settings; use std::sync::Arc; use theme::ThemeSettings; -use ui::{h_flex, prelude::*, IconButton, IconButtonShape, IconName, Tooltip, BASE_REM_SIZE_IN_PX}; +use ui::{ + h_flex, prelude::*, utils::SearchInputWidth, IconButton, IconButtonShape, IconName, Tooltip, + BASE_REM_SIZE_IN_PX, +}; use util::ResultExt; use workspace::{ item::ItemHandle, @@ -38,8 +41,6 @@ use workspace::{ pub use registrar::DivRegistrar; use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults}; -const MIN_INPUT_WIDTH_REMS: f32 = 10.; -const MAX_INPUT_WIDTH_REMS: f32 = 30.; const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; #[derive(PartialEq, Clone, Deserialize)] @@ -160,12 +161,12 @@ impl Render for BufferSearchBar { query_editor.placeholder_text(cx).is_none() }) { self.query_editor.update(cx, |editor, cx| { - editor.set_placeholder_text("Search", cx); + editor.set_placeholder_text("Search…", cx); }); } self.replacement_editor.update(cx, |editor, cx| { - editor.set_placeholder_text("Replace with...", cx); + editor.set_placeholder_text("Replace with…", cx); }); let mut text_color = Color::Default; @@ -203,21 +204,26 @@ impl Render for BufferSearchBar { cx.theme().colors().border }; + let container_width = cx.viewport_size().width; + let input_width = SearchInputWidth::calc_width(container_width); + + let input_base_styles = || { + h_flex() + .w(input_width) + .h_8() + .px_2() + .py_1() + .border_1() + .border_color(editor_border) + .rounded_lg() + }; + let search_line = h_flex() .gap_2() .child( - h_flex() + input_base_styles() .id("editor-scroll") .track_scroll(&self.editor_scroll_handle) - .flex_1() - .h_8() - .px_2() - .py_1() - .border_1() - .border_color(editor_border) - .min_w(rems(MIN_INPUT_WIDTH_REMS)) - .max_w(rems(MAX_INPUT_WIDTH_REMS)) - .rounded_lg() .child(self.render_text_input(&self.query_editor, text_color.color(cx), cx)) .when(!hide_inline_icons, |div| { div.children(supported_options.case.then(|| { @@ -249,8 +255,8 @@ impl Render for BufferSearchBar { ) .child( h_flex() - .flex_none() - .gap_0p5() + .gap_1() + .min_w_64() .when(supported_options.replacement, |this| { this.child( IconButton::new( @@ -323,20 +329,27 @@ impl Render for BufferSearchBar { } }), ) - .child(render_nav_button( - ui::IconName::ChevronLeft, - self.active_match_index.is_some(), - "Select Previous Match", - &SelectPrevMatch, - focus_handle.clone(), - )) - .child(render_nav_button( - ui::IconName::ChevronRight, - self.active_match_index.is_some(), - "Select Next Match", - &SelectNextMatch, - focus_handle.clone(), - )) + .child( + h_flex() + .pl_2() + .ml_2() + .border_l_1() + .border_color(cx.theme().colors().border_variant) + .child(render_nav_button( + ui::IconName::ChevronLeft, + self.active_match_index.is_some(), + "Select Previous Match", + &SelectPrevMatch, + focus_handle.clone(), + )) + .child(render_nav_button( + ui::IconName::ChevronRight, + self.active_match_index.is_some(), + "Select Next Match", + &SelectNextMatch, + focus_handle.clone(), + )), + ) .when(!narrow_mode, |this| { this.child(h_flex().ml_2().min_w(rems_from_px(40.)).child( Label::new(match_text).size(LabelSize::Small).color( @@ -353,30 +366,15 @@ impl Render for BufferSearchBar { let replace_line = should_show_replace_input.then(|| { h_flex() .gap_2() - .flex_1() + .child(input_base_styles().child(self.render_text_input( + &self.replacement_editor, + cx.theme().colors().text, + cx, + ))) .child( h_flex() - .flex_1() - // We're giving this a fixed height to match the height of the search input, - // which has an icon inside that is increasing its height. - .h_8() - .px_2() - .py_1() - .border_1() - .border_color(cx.theme().colors().border) - .rounded_lg() - .min_w(rems(MIN_INPUT_WIDTH_REMS)) - .max_w(rems(MAX_INPUT_WIDTH_REMS)) - .child(self.render_text_input( - &self.replacement_editor, - cx.theme().colors().text, - cx, - )), - ) - .child( - h_flex() - .flex_none() - .gap_0p5() + .min_w_64() + .gap_1() .child( IconButton::new("search-replace-next", ui::IconName::ReplaceNext) .shape(IconButtonShape::Square) @@ -418,6 +416,7 @@ impl Render for BufferSearchBar { v_flex() .id("buffer_search") + .gap_2() .track_scroll(&self.scroll_handle) .key_context(key_context) .capture_action(cx.listener(Self::tab)) @@ -446,20 +445,22 @@ impl Render for BufferSearchBar { .when(self.supported_options().selection, |this| { this.on_action(cx.listener(Self::toggle_selection)) }) - .gap_2() .child( h_flex() + .relative() .child(search_line.w_full()) .when(!narrow_mode, |div| { div.child( - IconButton::new(SharedString::from("Close"), IconName::Close) - .shape(IconButtonShape::Square) - .tooltip(move |cx| { - Tooltip::for_action("Close Search Bar", &Dismiss, cx) - }) - .on_click(cx.listener(|this, _: &ClickEvent, cx| { - this.dismiss(&Dismiss, cx) - })), + h_flex().absolute().right_0().child( + IconButton::new(SharedString::from("Close"), IconName::Close) + .shape(IconButtonShape::Square) + .tooltip(move |cx| { + Tooltip::for_action("Close Search Bar", &Dismiss, cx) + }) + .on_click(cx.listener(|this, _: &ClickEvent, cx| { + this.dismiss(&Dismiss, cx) + })), + ), ) }), ) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 8430fd1f37..3ec2ac2aba 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -34,8 +34,8 @@ use std::{ }; use theme::ThemeSettings; use ui::{ - h_flex, prelude::*, v_flex, Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, - LabelCommon, LabelSize, Selectable, Tooltip, + h_flex, prelude::*, utils::SearchInputWidth, v_flex, Icon, IconButton, IconButtonShape, + IconName, KeyBinding, Label, LabelCommon, LabelSize, Selectable, Tooltip, }; use util::paths::PathMatcher; use workspace::{ @@ -669,7 +669,7 @@ impl ProjectSearchView { let query_editor = cx.new_view(|cx| { let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Search all files...", cx); + editor.set_placeholder_text("Search all files…", cx); editor.set_text(query_text, cx); editor }); @@ -692,7 +692,7 @@ impl ProjectSearchView { ); let replacement_editor = cx.new_view(|cx| { let mut editor = Editor::single_line(cx); - editor.set_placeholder_text("Replace in project...", cx); + editor.set_placeholder_text("Replace in project…", cx); if let Some(text) = replacement_text { editor.set_text(text, cx); } @@ -1586,9 +1586,12 @@ impl Render for ProjectSearchBar { let search = search.read(cx); let focus_handle = search.focus_handle(cx); + let container_width = cx.viewport_size().width; + let input_width = SearchInputWidth::calc_width(container_width); + let input_base_styles = || { h_flex() - .w_full() + .w(input_width) .h_8() .px_2() .py_1() @@ -1701,6 +1704,10 @@ impl Render for ProjectSearchBar { .unwrap_or_else(|| "0/0".to_string()); let matches_column = h_flex() + .pl_2() + .ml_2() + .border_l_1() + .border_color(cx.theme().colors().border_variant) .child( IconButton::new("project-search-prev-match", IconName::ChevronLeft) .shape(IconButtonShape::Square) @@ -1751,13 +1758,13 @@ impl Render for ProjectSearchBar { div() .id("matches") .ml_1() - .child( - Label::new(match_text).color(if search.active_match_index.is_some() { + .child(Label::new(match_text).size(LabelSize::Small).color( + if search.active_match_index.is_some() { Color::Default } else { Color::Disabled - }), - ) + }, + )) .when(limit_reached, |el| { el.tooltip(|cx| { Tooltip::text("Search limits reached.\nTry narrowing your search.", cx) @@ -1767,9 +1774,9 @@ impl Render for ProjectSearchBar { let search_line = h_flex() .w_full() - .gap_1p5() + .gap_2() .child(query_column) - .child(h_flex().min_w_40().child(mode_column).child(matches_column)); + .child(h_flex().min_w_64().child(mode_column).child(matches_column)); let replace_line = search.replace_enabled.then(|| { let replace_column = @@ -1779,7 +1786,7 @@ impl Render for ProjectSearchBar { let replace_actions = h_flex() - .min_w_40() + .min_w_64() .gap_1() .when(search.replace_enabled, |this| { this.child( @@ -1830,7 +1837,7 @@ impl Render for ProjectSearchBar { h_flex() .w_full() - .gap_1p5() + .gap_2() .child(replace_column) .child(replace_actions) }); @@ -1838,7 +1845,7 @@ impl Render for ProjectSearchBar { let filter_line = search.filters_enabled.then(|| { h_flex() .w_full() - .gap_1p5() + .gap_2() .child( input_base_styles() .on_action( @@ -1861,12 +1868,11 @@ impl Render for ProjectSearchBar { ) .child( h_flex() - .min_w_40() + .min_w_64() .gap_1() .child( IconButton::new("project-search-opened-only", IconName::FileSearch) .shape(IconButtonShape::Square) - .icon_size(IconSize::XSmall) .selected(self.is_opened_only_enabled(cx)) .tooltip(|cx| Tooltip::text("Only Search Open Files", cx)) .on_click(cx.listener(|this, _, cx| { diff --git a/crates/ui/src/utils.rs b/crates/ui/src/utils.rs index 25477194dc..e5c591a970 100644 --- a/crates/ui/src/utils.rs +++ b/crates/ui/src/utils.rs @@ -2,8 +2,10 @@ mod color_contrast; mod format_distance; +mod search_input; mod with_rem_size; pub use color_contrast::*; pub use format_distance::*; +pub use search_input::*; pub use with_rem_size::*; diff --git a/crates/ui/src/utils/search_input.rs b/crates/ui/src/utils/search_input.rs new file mode 100644 index 0000000000..3a507f9a5a --- /dev/null +++ b/crates/ui/src/utils/search_input.rs @@ -0,0 +1,22 @@ +#![allow(missing_docs)] + +use gpui::Pixels; + +pub struct SearchInputWidth; + +impl SearchInputWidth { + /// The containzer size in which the input stops filling the whole width. + pub const THRESHOLD_WIDTH: f32 = 1200.0; + + /// The maximum width for the search input when the container is larger than the threshold. + pub const MAX_WIDTH: f32 = 1200.0; + + /// Calculates the actual width in pixels based on the container width. + pub fn calc_width(container_width: Pixels) -> Pixels { + if container_width.0 < Self::THRESHOLD_WIDTH { + container_width + } else { + Pixels(container_width.0.min(Self::MAX_WIDTH)) + } + } +} From 4a96db026c6abba56aadd666f4627edf246e5935 Mon Sep 17 00:00:00 2001 From: Matin Aniss <76515905+MatinAniss@users.noreply.github.com> Date: Fri, 29 Nov 2024 03:45:10 +1100 Subject: [PATCH 064/215] gpui: Implement hover for Windows (#20894) --- crates/gpui/src/platform/windows/events.rs | 38 +++++++++++++++++++++- crates/gpui/src/platform/windows/window.rs | 11 +++++-- crates/gpui/src/window.rs | 6 +++- 3 files changed, 50 insertions(+), 5 deletions(-) diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 5f45d260d9..025fbba4ac 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -7,6 +7,7 @@ use windows::Win32::{ Graphics::Gdi::*, System::SystemServices::*, UI::{ + Controls::*, HiDpi::*, Input::{Ime::*, KeyboardAndMouse::*}, WindowsAndMessaging::*, @@ -43,7 +44,8 @@ pub(crate) fn handle_msg( WM_PAINT => handle_paint_msg(handle, state_ptr), WM_CLOSE => handle_close_msg(state_ptr), WM_DESTROY => handle_destroy_msg(handle, state_ptr), - WM_MOUSEMOVE => handle_mouse_move_msg(lparam, wparam, state_ptr), + WM_MOUSEMOVE => handle_mouse_move_msg(handle, lparam, wparam, state_ptr), + WM_MOUSELEAVE => handle_mouse_leave_msg(state_ptr), WM_NCMOUSEMOVE => handle_nc_mouse_move_msg(handle, lparam, state_ptr), WM_NCLBUTTONDOWN => { handle_nc_mouse_down_msg(handle, MouseButton::Left, wparam, lparam, state_ptr) @@ -234,10 +236,32 @@ fn handle_destroy_msg(handle: HWND, state_ptr: Rc) -> Opt } fn handle_mouse_move_msg( + handle: HWND, lparam: LPARAM, wparam: WPARAM, state_ptr: Rc, ) -> Option { + let mut lock = state_ptr.state.borrow_mut(); + if !lock.hovered { + lock.hovered = true; + unsafe { + TrackMouseEvent(&mut TRACKMOUSEEVENT { + cbSize: std::mem::size_of::() as u32, + dwFlags: TME_LEAVE, + hwndTrack: handle, + dwHoverTime: HOVER_DEFAULT, + }) + .log_err() + }; + if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { + drop(lock); + callback(true); + state_ptr.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + } + } else { + drop(lock); + } + let mut lock = state_ptr.state.borrow_mut(); if let Some(mut callback) = lock.callbacks.input.take() { let scale_factor = lock.scale_factor; @@ -272,6 +296,18 @@ fn handle_mouse_move_msg( Some(1) } +fn handle_mouse_leave_msg(state_ptr: Rc) -> Option { + let mut lock = state_ptr.state.borrow_mut(); + lock.hovered = false; + if let Some(mut callback) = lock.callbacks.hovered_status_change.take() { + drop(lock); + callback(false); + state_ptr.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + } + + Some(0) +} + fn handle_syskeydown_msg( wparam: WPARAM, lparam: LPARAM, diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index f2600d3c6f..93671f9b89 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -42,6 +42,7 @@ pub struct WindowsWindowState { pub callbacks: Callbacks, pub input_handler: Option, pub system_key_handled: bool, + pub hovered: bool, pub renderer: BladeRenderer, @@ -95,6 +96,7 @@ impl WindowsWindowState { let callbacks = Callbacks::default(); let input_handler = None; let system_key_handled = false; + let hovered = false; let click_state = ClickState::new(); let system_settings = WindowsSystemSettings::new(display); let nc_button_pressed = None; @@ -110,6 +112,7 @@ impl WindowsWindowState { callbacks, input_handler, system_key_handled, + hovered, renderer, click_state, system_settings, @@ -326,6 +329,7 @@ pub(crate) struct Callbacks { pub(crate) request_frame: Option>, pub(crate) input: Option DispatchEventResult>>, pub(crate) active_status_change: Option>, + pub(crate) hovered_status_change: Option>, pub(crate) resize: Option, f32)>>, pub(crate) moved: Option>, pub(crate) should_close: Option bool>>, @@ -635,9 +639,8 @@ impl PlatformWindow for WindowsWindow { self.0.hwnd == unsafe { GetActiveWindow() } } - // is_hovered is unused on Windows. See WindowContext::is_window_hovered. fn is_hovered(&self) -> bool { - false + self.0.state.borrow().hovered } fn set_title(&mut self, title: &str) { @@ -728,7 +731,9 @@ impl PlatformWindow for WindowsWindow { self.0.state.borrow_mut().callbacks.active_status_change = Some(callback); } - fn on_hover_status_change(&self, _: Box) {} + fn on_hover_status_change(&self, callback: Box) { + self.0.state.borrow_mut().callbacks.hovered_status_change = Some(callback); + } fn on_resize(&self, callback: Box, f32)>) { self.0.state.borrow_mut().callbacks.resize = Some(callback); diff --git a/crates/gpui/src/window.rs b/crates/gpui/src/window.rs index 902c699cb7..06298a81ad 100644 --- a/crates/gpui/src/window.rs +++ b/crates/gpui/src/window.rs @@ -1241,7 +1241,11 @@ impl<'a> WindowContext<'a> { /// that currently owns the mouse cursor. /// On mac, this is equivalent to `is_window_active`. pub fn is_window_hovered(&self) -> bool { - if cfg!(any(target_os = "linux", target_os = "freebsd")) { + if cfg!(any( + target_os = "windows", + target_os = "linux", + target_os = "freebsd" + )) { self.window.hovered.get() } else { self.is_window_active() From 0acd98a07e949cdd0e6de09cd0061f7fb7bd48db Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 28 Nov 2024 20:42:57 +0200 Subject: [PATCH 065/215] Do not show cursor position for empty files (#21295) Closes https://github.com/zed-industries/zed/issues/21289 Fixes most of the issues: does not display cursor position in empty multi buffers and on non-full editors. Does not fix the startup issue, as it's caused by the AssistantPanel's `ContextEditor` acting as an `Editor`, so whenever default prompts are added, those are registered as added editors, and Zed shows some line numbers for them. We cannot replace `item.act_as::(cx)` with `item.downcast::()` as then multi bufers' navigation will fall off (arguably, those line numbers do not make that much sense too, but still seem useful). This will will fix itself in the future, when assistant panel gets reworked into readonly view by default, as `assistant2` crate already shows (there's no `act_as` impl there and nothing cause issue). Since the remaining issue is minor and will go away on any focus change, and future changes will alter this, closing the original issue. Release Notes: - Improved cursor position display --- crates/go_to_line/src/cursor_position.rs | 52 ++++++++++++++---------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index 3931cac284..4f27c64256 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -52,34 +52,44 @@ impl CursorPosition { editor .update(&mut cx, |editor, cx| { - let buffer = editor.buffer().read(cx).snapshot(cx); cursor_position.update(cx, |cursor_position, cx| { cursor_position.selected_count = SelectionStats::default(); cursor_position.selected_count.selections = editor.selections.count(); - let mut last_selection = None::>; - for selection in editor.selections.all::(cx) { - cursor_position.selected_count.characters += buffer - .text_for_range(selection.start..selection.end) - .map(|t| t.chars().count()) - .sum::(); - if last_selection - .as_ref() - .map_or(true, |last_selection| selection.id > last_selection.id) - { - last_selection = Some(selection); + match editor.mode() { + editor::EditorMode::AutoHeight { .. } + | editor::EditorMode::SingleLine { .. } => { + cursor_position.position = None } - } - for selection in editor.selections.all::(cx) { - if selection.end != selection.start { - cursor_position.selected_count.lines += - (selection.end.row - selection.start.row) as usize; - if selection.end.column != 0 { - cursor_position.selected_count.lines += 1; + editor::EditorMode::Full => { + let mut last_selection = None::>; + let buffer = editor.buffer().read(cx).snapshot(cx); + if buffer.excerpts().count() > 0 { + for selection in editor.selections.all::(cx) { + cursor_position.selected_count.characters += buffer + .text_for_range(selection.start..selection.end) + .map(|t| t.chars().count()) + .sum::(); + if last_selection.as_ref().map_or(true, |last_selection| { + selection.id > last_selection.id + }) { + last_selection = Some(selection); + } + } + for selection in editor.selections.all::(cx) { + if selection.end != selection.start { + cursor_position.selected_count.lines += + (selection.end.row - selection.start.row) as usize; + if selection.end.column != 0 { + cursor_position.selected_count.lines += 1; + } + } + } } + cursor_position.position = + last_selection.map(|s| s.head().to_point(&buffer)); } } - cursor_position.position = - last_selection.map(|s| s.head().to_point(&buffer)); + cx.notify(); }) }) From ae85ecba2d54abe2dbdfc11c408c78bc2d256aa0 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:26:59 -0300 Subject: [PATCH 066/215] Make fetch slash command visible in the command selector (#21302) The `/fetch` command is naturally already accessible via the completion menu when you type / in the assistant panel, but it wasn't on the "Add Context" command selector. I think it should! It's a super nice/powerful one, and I've seen folks not knowing it existed. Side-note: maybe, in the near future, it'd be best to rename it to "`/web`, as that's an easier name to parse and assume what it does. Screenshot 2024-11-28 at 16 52 07 Release Notes: - N/A --- assets/icons/globe.svg | 1 + crates/assistant/src/assistant.rs | 3 +-- crates/assistant/src/slash_command/fetch_command.rs | 6 +++++- crates/ui/src/components/icon.rs | 9 +++++---- 4 files changed, 12 insertions(+), 7 deletions(-) create mode 100644 assets/icons/globe.svg diff --git a/assets/icons/globe.svg b/assets/icons/globe.svg new file mode 100644 index 0000000000..2082a43984 --- /dev/null +++ b/assets/icons/globe.svg @@ -0,0 +1 @@ + diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 7e4e38e320..6d619a76b9 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -342,8 +342,7 @@ fn register_slash_commands(prompt_builder: Option>, cx: &mut slash_command_registry.register_command(terminal_command::TerminalSlashCommand, true); slash_command_registry.register_command(now_command::NowSlashCommand, false); slash_command_registry.register_command(diagnostics_command::DiagnosticsSlashCommand, true); - slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); - slash_command_registry.register_command(fetch_command::FetchSlashCommand, false); + slash_command_registry.register_command(fetch_command::FetchSlashCommand, true); if let Some(prompt_builder) = prompt_builder { cx.observe_flag::({ diff --git a/crates/assistant/src/slash_command/fetch_command.rs b/crates/assistant/src/slash_command/fetch_command.rs index 4d38bb20a7..96ea05c302 100644 --- a/crates/assistant/src/slash_command/fetch_command.rs +++ b/crates/assistant/src/slash_command/fetch_command.rs @@ -108,6 +108,10 @@ impl SlashCommand for FetchSlashCommand { "Insert fetched URL contents".into() } + fn icon(&self) -> IconName { + IconName::Globe + } + fn menu_text(&self) -> String { self.description() } @@ -162,7 +166,7 @@ impl SlashCommand for FetchSlashCommand { text, sections: vec![SlashCommandOutputSection { range, - icon: IconName::AtSign, + icon: IconName::Globe, label: format!("fetch {}", url).into(), metadata: None, }], diff --git a/crates/ui/src/components/icon.rs b/crates/ui/src/components/icon.rs index 161f4c60b7..03000f0638 100644 --- a/crates/ui/src/components/icon.rs +++ b/crates/ui/src/components/icon.rs @@ -160,7 +160,6 @@ pub enum IconName { Copy, CountdownTimer, CursorIBeam, - TextSnippet, Dash, DatabaseZap, Delete, @@ -171,8 +170,8 @@ pub enum IconName { EllipsisVertical, Envelope, Escape, - Exit, ExpandVertical, + Exit, ExternalLink, Eye, File, @@ -198,6 +197,7 @@ pub enum IconName { GenericMinimize, GenericRestore, Github, + Globe, Hash, HistoryRerun, Indicator, @@ -223,13 +223,13 @@ pub enum IconName { PageUp, Pencil, Person, + PhoneIncoming, Pin, Play, Plus, PocketKnife, Public, PullRequest, - PhoneIncoming, Quote, RefreshTitle, Regex, @@ -275,6 +275,7 @@ pub enum IconName { SwatchBook, Tab, Terminal, + TextSnippet, Trash, TrashAlt, Triangle, @@ -287,11 +288,11 @@ pub enum IconName { Wand, Warning, WholeWord, + X, XCircle, ZedAssistant, ZedAssistantFilled, ZedXCopilot, - X, } impl From for Icon { From e76589107dd7677ee80d8313a2c2e2662a4023e8 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:28:05 -0300 Subject: [PATCH 067/215] Improve the "go to line" modal (#21301) Just a small, mostly visual refinement to this component. Screenshot 2024-11-28 at 16 30 27 Release Notes: - N/A --- crates/go_to_line/src/go_to_line.rs | 45 ++++++++++++----------------- 1 file changed, 18 insertions(+), 27 deletions(-) diff --git a/crates/go_to_line/src/go_to_line.rs b/crates/go_to_line/src/go_to_line.rs index c848d28eaa..df673ef823 100644 --- a/crates/go_to_line/src/go_to_line.rs +++ b/crates/go_to_line/src/go_to_line.rs @@ -9,7 +9,7 @@ use gpui::{ use settings::Settings; use text::{Bias, Point}; use theme::ActiveTheme; -use ui::{h_flex, prelude::*, v_flex, Label}; +use ui::prelude::*; use util::paths::FILE_ROW_COLUMN_DELIMITER; use workspace::ModalView; @@ -73,7 +73,7 @@ impl GoToLine { let last_line = editor.buffer().read(cx).snapshot(cx).max_point().row; let scroll_position = active_editor.update(cx, |editor, cx| editor.scroll_position(cx)); - let current_text = format!("line {} of {} (column {})", line, last_line + 1, column); + let current_text = format!("{} of {} (column {})", line, last_line + 1, column); Self { line_editor, @@ -186,36 +186,27 @@ impl Render for GoToLine { } } - div() + v_flex() + .w(rems(24.)) .elevation_2(cx) .key_context("GoToLine") .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(Self::confirm)) - .w_96() .child( - v_flex() - .px_1() - .pt_0p5() - .gap_px() - .child( - v_flex() - .py_0p5() - .px_1() - .child(div().px_1().py_0p5().child(self.line_editor.clone())), - ) - .child( - div() - .h_px() - .w_full() - .bg(cx.theme().colors().element_background), - ) - .child( - h_flex() - .justify_between() - .px_2() - .py_1() - .child(Label::new(help_text).color(Color::Muted)), - ), + div() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .px_2() + .py_1() + .child(self.line_editor.clone()), + ) + .child( + h_flex() + .px_2() + .py_1() + .gap_1() + .child(Label::new("Current Line:").color(Color::Muted)) + .child(Label::new(help_text).color(Color::Muted)), ) } } From 3458687300e6d226531019738ad0669993b5c17d Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 28 Nov 2024 18:28:20 -0300 Subject: [PATCH 068/215] Add keybinding to the language selector tooltip (#21299) Just making sure sure we're always making keyboard navigation discoverable. Screenshot 2024-11-28 at 16 05 40 Release Notes: - N/A --- crates/language_selector/src/active_buffer_language.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index 1d5f82d285..bfa31b2f69 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -6,6 +6,8 @@ use workspace::{item::ItemHandle, StatusItemView, Workspace}; use crate::LanguageSelector; +gpui::actions!(language_selector, [Toggle]); + pub struct ActiveBufferLanguage { active_language: Option>, workspace: WeakView, @@ -54,7 +56,7 @@ impl Render for ActiveBufferLanguage { }); } })) - .tooltip(|cx| Tooltip::text("Select Language", cx)), + .tooltip(|cx| Tooltip::for_action("Select Language", &Toggle, cx)), ) }) } From eb2c0b33dff361a268b0276f8ec8bb38811d2c5e Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 28 Nov 2024 19:15:30 -0300 Subject: [PATCH 069/215] Fine-tune status bar left-side spacing (#21306) Closes https://github.com/zed-industries/zed/issues/21291 This PR also adds a small divider separating the panel-opening controls from the other items that appear on the left side of the status bar. The spacing was a bit bigger before because all three items on the left open panels, whereas each other item does different things (e.g., open the diagnostics tab, update the app, display language server status, etc.). Therefore, they needed to be separated somehow to communicate the difference in behavior. Hopefully, now, the border will help sort of figuring this out. | With error | Normal state | |--------|--------| | Screenshot 2024-11-28 at 18 52 58 | Screenshot 2024-11-28 at 18 53 03 | Release Notes: - N/A --- crates/diagnostics/src/items.rs | 8 +++++--- crates/workspace/src/status_bar.rs | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 2c580c44de..495987c516 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -1,7 +1,7 @@ use editor::Editor; use gpui::{ - rems, EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View, - ViewContext, WeakView, + EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View, ViewContext, + WeakView, }; use language::Diagnostic; use ui::{h_flex, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip}; @@ -77,8 +77,10 @@ impl Render for DiagnosticIndicator { }; h_flex() - .h(rems(1.375)) .gap_2() + .pl_1() + .border_l_1() + .border_color(cx.theme().colors().border) .child( ButtonLike::new("diagnostic-indicator") .child(diagnostic_indicator) diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 00a0078032..274aee063c 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -64,7 +64,7 @@ impl Render for StatusBar { impl StatusBar { fn render_left_tools(&self, cx: &mut ViewContext) -> impl IntoElement { h_flex() - .gap(DynamicSpacing::Base08.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) .overflow_x_hidden() .children(self.left_items.iter().map(|item| item.to_any())) } From 73f546ea5fcb7945b3d7d48b1b6a96e6a8411f9c Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 29 Nov 2024 11:02:56 +0200 Subject: [PATCH 070/215] Force `ashpd` crate to not use `tokio` (#21315) https://github.com/zed-industries/zed/issues/21304 Fixes a regression after https://github.com/zed-industries/zed/pull/20939 Release Notes: - N/A --- Cargo.lock | 12 +++++++++--- Cargo.toml | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc4de8bd7c..e046359cc7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -346,13 +346,14 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e9c39d707614dbcc6bed00015539f488d8e3fe3e66ed60961efc0c90f4b380b3" dependencies = [ + "async-fs 2.1.2", + "async-net 2.0.0", "enumflags2", "futures-channel", "futures-util", "rand 0.8.5", "serde", "serde_repr", - "tokio", "url", "zbus 5.1.1", ] @@ -12796,7 +12797,6 @@ dependencies = [ "signal-hook-registry", "socket2 0.5.7", "tokio-macros", - "tracing", "windows-sys 0.52.0", ] @@ -15602,8 +15602,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1162094dc63b1629fcc44150bcceeaa80798cd28bcbe7fa987b65a034c258608" dependencies = [ "async-broadcast", + "async-executor", + "async-fs 2.1.2", + "async-io 2.4.0", + "async-lock 3.4.0", + "async-process 2.3.0", "async-recursion 1.1.1", + "async-task", "async-trait", + "blocking", "enumflags2", "event-listener 5.3.1", "futures-core", @@ -15614,7 +15621,6 @@ dependencies = [ "serde", "serde_repr", "static_assertions", - "tokio", "tracing", "uds_windows", "windows-sys 0.59.0", diff --git a/Cargo.toml b/Cargo.toml index 996d41e803..b50b6d9f9d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -333,7 +333,7 @@ alacritty_terminal = { git = "https://github.com/alacritty/alacritty", rev = "91 any_vec = "0.14" anyhow = "1.0.86" arrayvec = { version = "0.7.4", features = ["serde"] } -ashpd = "0.10.0" +ashpd = { version = "0.10", default-features = false, features = ["async-std"]} async-compat = "0.2.1" async-compression = { version = "0.4", features = ["gzip", "futures-io"] } async-dispatcher = "0.1" From 94faf9dd56c494d369513e885fe1e08a95256bd3 Mon Sep 17 00:00:00 2001 From: Stanislav Alekseev <43210583+WeetHet@users.noreply.github.com> Date: Fri, 29 Nov 2024 11:09:33 +0200 Subject: [PATCH 071/215] nix: Return to building with crane (#21292) This removes .envrc, putting it into gitignore as well as building with crane, as it does not require an up to date hash for a FOD. Release Notes: - N/A cc @mrnugget @jaredramirez --- .envrc | 2 - .gitignore | 1 + flake.lock | 16 +++ flake.nix | 14 ++- nix/build.nix | 342 ++++++++++++++++++++++++-------------------------- 5 files changed, 193 insertions(+), 182 deletions(-) delete mode 100644 .envrc diff --git a/.envrc b/.envrc deleted file mode 100644 index 082c01feeb..0000000000 --- a/.envrc +++ /dev/null @@ -1,2 +0,0 @@ -watch_file nix/shell.nix -use flake diff --git a/.gitignore b/.gitignore index d19c5a102a..fc6263eb7e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /.direnv +.envrc .idea **/target **/cargo-target diff --git a/flake.lock b/flake.lock index 4011b38c4b..ae27b51678 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,20 @@ { "nodes": { + "crane": { + "locked": { + "lastModified": 1732407143, + "narHash": "sha256-qJOGDT6PACoX+GbNH2PPx2ievlmtT1NVeTB80EkRLys=", + "owner": "ipetkov", + "repo": "crane", + "rev": "f2b4b472983817021d9ffb60838b2b36b9376b20", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, "flake-compat": { "locked": { "lastModified": 1696426674, @@ -33,6 +48,7 @@ }, "root": { "inputs": { + "crane": "crane", "flake-compat": "flake-compat", "nixpkgs": "nixpkgs", "rust-overlay": "rust-overlay" diff --git a/flake.nix b/flake.nix index 3258522eb4..f797227fba 100644 --- a/flake.nix +++ b/flake.nix @@ -7,11 +7,17 @@ url = "github:oxalica/rust-overlay"; inputs.nixpkgs.follows = "nixpkgs"; }; + crane.url = "github:ipetkov/crane"; flake-compat.url = "github:edolstra/flake-compat"; }; outputs = - { nixpkgs, rust-overlay, ... }: + { + nixpkgs, + rust-overlay, + crane, + ... + }: let systems = [ "x86_64-linux" @@ -27,10 +33,8 @@ }; zed-editor = final: prev: { zed-editor = final.callPackage ./nix/build.nix { - rustPlatform = final.makeRustPlatform { - cargo = final.rustToolchain; - rustc = final.rustToolchain; - }; + crane = crane.mkLib final; + rustToolchain = final.rustToolchain; }; }; }; diff --git a/nix/build.nix b/nix/build.nix index d3d3d1aab1..e78025dffd 100644 --- a/nix/build.nix +++ b/nix/build.nix @@ -1,6 +1,7 @@ { lib, - rustPlatform, + crane, + rustToolchain, fetchpatch, clang, cmake, @@ -26,7 +27,6 @@ vulkan-loader, envsubst, cargo-about, - versionCheckHook, cargo-bundle, git, apple-sdk_15, @@ -50,207 +50,199 @@ let in !( inRootDir - && ( - baseName == "docs" - || baseName == ".github" - || baseName == "script" - || baseName == ".git" - || baseName == "target" - ) + && (baseName == "docs" || baseName == ".github" || baseName == ".git" || baseName == "target") ); -in -rustPlatform.buildRustPackage rec { - pname = "zed-editor"; - version = "nightly"; - - src = lib.cleanSourceWith { + craneLib = crane.overrideToolchain rustToolchain; + commonSrc = lib.cleanSourceWith { src = nix-gitignore.gitignoreSource [ ] ../.; filter = includeFilter; name = "source"; }; + commonArgs = rec { + pname = "zed-editor"; + version = "nightly"; - patches = - [ - # Zed uses cargo-install to install cargo-about during the script execution. - # We provide cargo-about ourselves and can skip this step. - # Until https://github.com/zed-industries/zed/issues/19971 is fixed, - # we also skip any crate for which the license cannot be determined. - (fetchpatch { - url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0001-generate-licenses.patch"; - hash = "sha256-cLgqLDXW1JtQ2OQFLd5UolAjfy7bMoTw40lEx2jA2pk="; - }) - ] - ++ lib.optionals stdenv.hostPlatform.isDarwin [ - # Livekit requires Swift 6 - # We need this until livekit-rust sdk is used - (fetchpatch { - url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0002-disable-livekit-darwin.patch"; - hash = "sha256-whZ7RaXv8hrVzWAveU3qiBnZSrvGNEHTuyNhxgMIo5w="; - }) - ]; + src = commonSrc; - useFetchCargoVendor = true; - cargoHash = "sha256-KURM1W9UP65BU9gbvEBgQj3jwSYfQT7X18gcSmOMguI="; + nativeBuildInputs = + [ + clang + cmake + copyDesktopItems + curl + perl + pkg-config + protobuf + cargo-about + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ makeWrapper ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ cargo-bundle ]; - nativeBuildInputs = - [ - clang - cmake - copyDesktopItems - curl - perl - pkg-config - protobuf - rustPlatform.bindgenHook - cargo-about - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ makeWrapper ] - ++ lib.optionals stdenv.hostPlatform.isDarwin [ cargo-bundle ]; - - dontUseCmakeConfigure = true; - - buildInputs = - [ - curl - fontconfig - freetype - libgit2 - openssl - sqlite - zlib - zstd - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ - alsa-lib - libxkbcommon - wayland - xorg.libxcb - ] - ++ lib.optionals stdenv.hostPlatform.isDarwin [ - apple-sdk_15 - (darwinMinVersionHook "10.15") - ]; - - cargoBuildFlags = [ - "--package=zed" - "--package=cli" - ]; - - buildFeatures = lib.optionals stdenv.hostPlatform.isDarwin [ "gpui/runtime_shaders" ]; - - env = { - ZSTD_SYS_USE_PKG_CONFIG = true; - FONTCONFIG_FILE = makeFontsConf { - fontDirectories = [ - "${src}/assets/fonts/plex-mono" - "${src}/assets/fonts/plex-sans" + buildInputs = + [ + curl + fontconfig + freetype + libgit2 + openssl + sqlite + zlib + zstd + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + alsa-lib + libxkbcommon + wayland + xorg.libxcb + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + apple-sdk_15 + (darwinMinVersionHook "10.15") ]; + + env = { + ZSTD_SYS_USE_PKG_CONFIG = true; + FONTCONFIG_FILE = makeFontsConf { + fontDirectories = [ + "${src}/assets/fonts/plex-mono" + "${src}/assets/fonts/plex-sans" + ]; + }; + ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; + RELEASE_VERSION = version; }; - ZED_UPDATE_EXPLANATION = "Zed has been installed using Nix. Auto-updates have thus been disabled."; - RELEASE_VERSION = version; }; + cargoArtifacts = craneLib.buildDepsOnly commonArgs; +in +craneLib.buildPackage ( + commonArgs + // rec { + inherit cargoArtifacts; - RUSTFLAGS = if withGLES then "--cfg gles" else ""; - gpu-lib = if withGLES then libglvnd else vulkan-loader; + patches = + [ + # Zed uses cargo-install to install cargo-about during the script execution. + # We provide cargo-about ourselves and can skip this step. + # Until https://github.com/zed-industries/zed/issues/19971 is fixed, + # we also skip any crate for which the license cannot be determined. + (fetchpatch { + url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0001-generate-licenses.patch"; + hash = "sha256-cLgqLDXW1JtQ2OQFLd5UolAjfy7bMoTw40lEx2jA2pk="; + }) + ] + ++ lib.optionals stdenv.hostPlatform.isDarwin [ + # Livekit requires Swift 6 + # We need this until livekit-rust sdk is used + (fetchpatch { + url = "https://raw.githubusercontent.com/NixOS/nixpkgs/1fd02d90c6c097f91349df35da62d36c19359ba7/pkgs/by-name/ze/zed-editor/0002-disable-livekit-darwin.patch"; + hash = "sha256-whZ7RaXv8hrVzWAveU3qiBnZSrvGNEHTuyNhxgMIo5w="; + }) + ]; - preBuild = '' - bash script/generate-licenses - ''; + cargoExtraArgs = "--package=zed --package=cli --features=gpui/runtime_shaders"; - postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' - patchelf --add-rpath ${gpu-lib}/lib $out/libexec/* - patchelf --add-rpath ${wayland}/lib $out/libexec/* - wrapProgram $out/libexec/zed-editor --suffix PATH : ${lib.makeBinPath [ nodejs_22 ]} - ''; + dontUseCmakeConfigure = true; + preBuild = '' + bash script/generate-licenses + ''; - preCheck = '' - export HOME=$(mktemp -d); - ''; + postFixup = lib.optionalString stdenv.hostPlatform.isLinux '' + patchelf --add-rpath ${gpu-lib}/lib $out/libexec/* + patchelf --add-rpath ${wayland}/lib $out/libexec/* + wrapProgram $out/libexec/zed-editor --suffix PATH : ${lib.makeBinPath [ nodejs_22 ]} + ''; - checkFlags = - [ - # Flaky: unreliably fails on certain hosts (including Hydra) - "--skip=zed::tests::test_window_edit_state_restoring_enabled" - ] - ++ lib.optionals stdenv.hostPlatform.isLinux [ - # Fails on certain hosts (including Hydra) for unclear reason - "--skip=test_open_paths_action" - ]; + RUSTFLAGS = if withGLES then "--cfg gles" else ""; + gpu-lib = if withGLES then libglvnd else vulkan-loader; - installPhase = - if stdenv.hostPlatform.isDarwin then - '' - runHook preInstall + preCheck = '' + export HOME=$(mktemp -d); + ''; - # cargo-bundle expects the binary in target/release - mv target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/zed target/release/zed + cargoTestExtraArgs = + "-- " + + lib.concatStringsSep " " ( + [ + # Flaky: unreliably fails on certain hosts (including Hydra) + "--skip=zed::tests::test_window_edit_state_restoring_enabled" + ] + ++ lib.optionals stdenv.hostPlatform.isLinux [ + # Fails on certain hosts (including Hydra) for unclear reason + "--skip=test_open_paths_action" + ] + ); - pushd crates/zed + installPhase = + if stdenv.hostPlatform.isDarwin then + '' + runHook preInstall - # Note that this is GNU sed, while Zed's bundle-mac uses BSD sed - sed -i "s/package.metadata.bundle-stable/package.metadata.bundle/" Cargo.toml - export CARGO_BUNDLE_SKIP_BUILD=true - app_path=$(cargo bundle --release | xargs) + # cargo-bundle expects the binary in target/release + mv target/release/zed target/release/zed - # We're not using the fork of cargo-bundle, so we must manually append plist extensions - # Remove closing tags from Info.plist (last two lines) - head -n -2 $app_path/Contents/Info.plist > Info.plist - # Append extensions - cat resources/info/*.plist >> Info.plist - # Add closing tags - printf "\n\n" >> Info.plist - mv Info.plist $app_path/Contents/Info.plist + pushd crates/zed - popd + # Note that this is GNU sed, while Zed's bundle-mac uses BSD sed + sed -i "s/package.metadata.bundle-stable/package.metadata.bundle/" Cargo.toml + export CARGO_BUNDLE_SKIP_BUILD=true + app_path=$(cargo bundle --release | xargs) - mkdir -p $out/Applications $out/bin - # Zed expects git next to its own binary - ln -s ${git}/bin/git $app_path/Contents/MacOS/git - mv target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/cli $app_path/Contents/MacOS/cli - mv $app_path $out/Applications/ + # We're not using the fork of cargo-bundle, so we must manually append plist extensions + # Remove closing tags from Info.plist (last two lines) + head -n -2 $app_path/Contents/Info.plist > Info.plist + # Append extensions + cat resources/info/*.plist >> Info.plist + # Add closing tags + printf "\n\n" >> Info.plist + mv Info.plist $app_path/Contents/Info.plist - # Physical location of the CLI must be inside the app bundle as this is used - # to determine which app to start - ln -s $out/Applications/Zed.app/Contents/MacOS/cli $out/bin/zed + popd - runHook postInstall - '' - else - '' - runHook preInstall + mkdir -p $out/Applications $out/bin + # Zed expects git next to its own binary + ln -s ${git}/bin/git $app_path/Contents/MacOS/git + mv target/release/cli $app_path/Contents/MacOS/cli + mv $app_path $out/Applications/ - mkdir -p $out/bin $out/libexec - cp target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/zed $out/libexec/zed-editor - cp target/${stdenv.hostPlatform.rust.cargoShortTarget}/release/cli $out/bin/zed + # Physical location of the CLI must be inside the app bundle as this is used + # to determine which app to start + ln -s $out/Applications/Zed.app/Contents/MacOS/cli $out/bin/zed - install -D ${src}/crates/zed/resources/app-icon@2x.png $out/share/icons/hicolor/1024x1024@2x/apps/zed.png - install -D ${src}/crates/zed/resources/app-icon.png $out/share/icons/hicolor/512x512/apps/zed.png + runHook postInstall + '' + else + '' + runHook preInstall - # extracted from https://github.com/zed-industries/zed/blob/v0.141.2/script/bundle-linux (envsubst) - # and https://github.com/zed-industries/zed/blob/v0.141.2/script/install.sh (final desktop file name) - ( - export DO_STARTUP_NOTIFY="true" - export APP_CLI="zed" - export APP_ICON="zed" - export APP_NAME="Zed" - export APP_ARGS="%U" - mkdir -p "$out/share/applications" - ${lib.getExe envsubst} < "crates/zed/resources/zed.desktop.in" > "$out/share/applications/dev.zed.Zed.desktop" - ) + mkdir -p $out/bin $out/libexec + cp target/release/zed $out/libexec/zed-editor + cp target/release/cli $out/bin/zed - runHook postInstall - ''; + install -D ${commonSrc}/crates/zed/resources/app-icon@2x.png $out/share/icons/hicolor/1024x1024@2x/apps/zed.png + install -D ${commonSrc}/crates/zed/resources/app-icon.png $out/share/icons/hicolor/512x512/apps/zed.png - nativeInstallCheckInputs = [ - versionCheckHook - ]; + # extracted from https://github.com/zed-industries/zed/blob/v0.141.2/script/bundle-linux (envsubst) + # and https://github.com/zed-industries/zed/blob/v0.141.2/script/install.sh (final desktop file name) + ( + export DO_STARTUP_NOTIFY="true" + export APP_CLI="zed" + export APP_ICON="zed" + export APP_NAME="Zed" + export APP_ARGS="%U" + mkdir -p "$out/share/applications" + ${lib.getExe envsubst} < "crates/zed/resources/zed.desktop.in" > "$out/share/applications/dev.zed.Zed.desktop" + ) - meta = { - description = "High-performance, multiplayer code editor from the creators of Atom and Tree-sitter"; - homepage = "https://zed.dev"; - changelog = "https://zed.dev/releases/preview"; - license = lib.licenses.gpl3Only; - mainProgram = "zed"; - platforms = lib.platforms.linux ++ lib.platforms.darwin; - }; -} + runHook postInstall + ''; + + meta = { + description = "High-performance, multiplayer code editor from the creators of Atom and Tree-sitter"; + homepage = "https://zed.dev"; + changelog = "https://zed.dev/releases/preview"; + license = lib.licenses.gpl3Only; + mainProgram = "zed"; + platforms = lib.platforms.linux ++ lib.platforms.darwin; + }; + } +) From eadb107339341cc7b0deec1c516f303fba2c45d7 Mon Sep 17 00:00:00 2001 From: Haru Kim Date: Fri, 29 Nov 2024 20:04:58 +0900 Subject: [PATCH 072/215] Allow `workspace::ActivatePaneInDirection` to navigate out of the terminal panel (#21313) Enhancement for #21238 Release Notes: - N/A --- crates/terminal_view/src/terminal_panel.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 1bc8a9e19b..1799d24c7d 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -957,6 +957,13 @@ impl Render for TerminalPanel { cx, ) { cx.focus_view(&pane); + } else { + terminal_panel + .workspace + .update(cx, |workspace, cx| { + workspace.activate_pane_in_direction(action.0, cx) + }) + .ok(); } }) }) From f9d5de834a33c266ceadf098423f6f4c0276fb28 Mon Sep 17 00:00:00 2001 From: Haru Kim Date: Fri, 29 Nov 2024 20:51:36 +0900 Subject: [PATCH 073/215] Disable editor autoscroll on mouse clicks (#20287) Closes #18148 Release Notes: - Stop scrolling when clicking to the edges of the visible text area. Use `autoscroll_on_clicks` to configure this behavior. https://github.com/user-attachments/assets/3afd5cbb-5957-4e39-94c6-cd2e927038fd --------- Co-authored-by: Kirill Bulatov --- assets/settings/default.json | 2 ++ crates/editor/src/editor.rs | 3 ++- crates/editor/src/editor_settings.rs | 5 +++++ docs/src/configuring-zed.md | 10 ++++++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index efb0cc9479..b844be7fa2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -300,6 +300,8 @@ "scroll_beyond_last_line": "one_page", // The number of lines to keep above/below the cursor when scrolling. "vertical_scroll_margin": 3, + // Whether to scroll when clicking near the edge of the visible text area. + "autoscroll_on_clicks": false, // Scroll sensitivity multiplier. This multiplier is applied // to both the horizontal and vertical delta values while scrolling. "scroll_sensitivity": 1.0, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 611ec9232e..24ae84b035 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2931,7 +2931,7 @@ impl Editor { let start; let end; let mode; - let auto_scroll; + let mut auto_scroll; match click_count { 1 => { start = buffer.anchor_before(position.to_point(&display_map)); @@ -2967,6 +2967,7 @@ impl Editor { auto_scroll = false; } } + auto_scroll &= !EditorSettings::get_global(cx).autoscroll_on_clicks; let point_to_delete: Option = { let selected_points: Vec> = diff --git a/crates/editor/src/editor_settings.rs b/crates/editor/src/editor_settings.rs index ff743db9b6..e669c21554 100644 --- a/crates/editor/src/editor_settings.rs +++ b/crates/editor/src/editor_settings.rs @@ -18,6 +18,7 @@ pub struct EditorSettings { pub gutter: Gutter, pub scroll_beyond_last_line: ScrollBeyondLastLine, pub vertical_scroll_margin: f32, + pub autoscroll_on_clicks: bool, pub scroll_sensitivity: f32, pub relative_line_numbers: bool, pub seed_search_query_from_cursor: SeedQuerySetting, @@ -222,6 +223,10 @@ pub struct EditorSettingsContent { /// /// Default: 3. pub vertical_scroll_margin: Option, + /// Whether to scroll when clicking near the edge of the visible text area. + /// + /// Default: false + pub autoscroll_on_clicks: Option, /// Scroll sensitivity multiplier. This multiplier is applied /// to both the horizontal and vertical delta values while scrolling. /// diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index 5eacf4136d..bd1da9ece8 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -133,6 +133,16 @@ Define extensions which should be installed (`true`) or never installed (`false` } ``` +## Autoscroll on Clicks + +- Description: Whether to scroll when clicking near the edge of the visible text area. +- Setting: `autoscroll_on_clicks` +- Default: `false` + +**Options** + +`boolean` values + ## Auto Update - Description: Whether or not to automatically check for updates. From 74f265e5cfc932a3bfdf3dbdef8e136080bc7ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=BD=D0=B0=D0=B1?= Date: Fri, 29 Nov 2024 13:43:40 +0100 Subject: [PATCH 074/215] Update to embed-resource 3.0 (fixes build below windows \?\ path) (#21288) Accd'g to https://github.com/zed-industries/zed/pull/9009#issuecomment-1983599232 the manifest is required Followup for https://github.com/nabijaczleweli/rust-embed-resource/issues/71 Release Notes: - N/A --- crates/gpui/Cargo.toml | 2 +- crates/gpui/build.rs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/gpui/Cargo.toml b/crates/gpui/Cargo.toml index 347e5502ca..ed523c769a 100644 --- a/crates/gpui/Cargo.toml +++ b/crates/gpui/Cargo.toml @@ -119,7 +119,7 @@ http_client = { workspace = true, features = ["test-support"] } unicode-segmentation.workspace = true [build-dependencies] -embed-resource = "2.4" +embed-resource = "3.0" [target.'cfg(target_os = "macos")'.build-dependencies] bindgen = "0.70.0" diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index 5a015106c7..ef29d7cc82 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -18,7 +18,9 @@ fn main() { let rc_file = std::path::Path::new("resources/windows/gpui.rc"); println!("cargo:rerun-if-changed={}", manifest.display()); println!("cargo:rerun-if-changed={}", rc_file.display()); - embed_resource::compile(rc_file, embed_resource::NONE); + embed_resource::compile(rc_file, embed_resource::NONE) + .manifest_required() + .unwrap(); } _ => (), }; From a593a04da42a7ea8e20dcc093ca61fd5fd48796d Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 29 Nov 2024 15:39:18 +0200 Subject: [PATCH 075/215] Update the lockfile after a recent dependency update (#21328) Follow-up of https://github.com/zed-industries/zed/pull/21288 Release Notes: - N/A --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e046359cc7..bdb839e78b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3902,9 +3902,9 @@ dependencies = [ [[package]] name = "embed-resource" -version = "2.5.1" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b68b6f9f63a0b6a38bc447d4ce84e2b388f3ec95c99c641c8ff0dd3ef89a6379" +checksum = "4762ce03154ba57ebaeee60cc631901ceae4f18219cbb874e464347471594742" dependencies = [ "cc", "memchr", From de55bd8307fd683780e013aae581db7aa78f3b69 Mon Sep 17 00:00:00 2001 From: yoleuh Date: Fri, 29 Nov 2024 08:56:32 -0500 Subject: [PATCH 076/215] Status bar: Reduce right tools lateral margin (#21329) Closes #21316 | Before | After | |--------|-------| | ![image](https://github.com/user-attachments/assets/525d16b0-c1f0-4d93-9a8e-19112b927e78)| ![image](https://github.com/user-attachments/assets/c6947c3e-6b46-4498-a672-5f418f5faad0)| Changes: changed `Base08` to `Base04` in `render_right_tools` Release Notes: - N/A --- crates/workspace/src/status_bar.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/workspace/src/status_bar.rs b/crates/workspace/src/status_bar.rs index 274aee063c..585b2700b4 100644 --- a/crates/workspace/src/status_bar.rs +++ b/crates/workspace/src/status_bar.rs @@ -71,7 +71,7 @@ impl StatusBar { fn render_right_tools(&self, cx: &mut ViewContext) -> impl IntoElement { h_flex() - .gap(DynamicSpacing::Base08.rems(cx)) + .gap(DynamicSpacing::Base04.rems(cx)) .children(self.right_items.iter().rev().map(|item| item.to_any())) } } From 0306bdc695494af0ef7564e6a409423c40ab23a8 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Fri, 29 Nov 2024 16:02:57 +0200 Subject: [PATCH 077/215] Use a single action for toggling the language (#21331) Follow-up of https://github.com/zed-industries/zed/pull/21299 Release Notes: - N/A --- crates/language_selector/src/active_buffer_language.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/language_selector/src/active_buffer_language.rs b/crates/language_selector/src/active_buffer_language.rs index bfa31b2f69..eeaa403e20 100644 --- a/crates/language_selector/src/active_buffer_language.rs +++ b/crates/language_selector/src/active_buffer_language.rs @@ -4,9 +4,7 @@ use language::LanguageName; use ui::{Button, ButtonCommon, Clickable, FluentBuilder, LabelSize, Tooltip}; use workspace::{item::ItemHandle, StatusItemView, Workspace}; -use crate::LanguageSelector; - -gpui::actions!(language_selector, [Toggle]); +use crate::{LanguageSelector, Toggle}; pub struct ActiveBufferLanguage { active_language: Option>, From 69c761f5a5e8a33e86966fa59d2a58622b5cda62 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 29 Nov 2024 11:39:02 -0300 Subject: [PATCH 078/215] Adjust project search landing page layout (#21332) Closes https://github.com/zed-industries/zed/issues/21317 https://github.com/user-attachments/assets/a4970c08-9715-4c90-ad48-8f6e80c6fcd0 Release Notes: - N/A --- crates/search/src/project_search.rs | 34 +++++++++++++++-------------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 3ec2ac2aba..ce894397c3 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -333,20 +333,20 @@ impl Render for ProjectSearchView { let model = self.model.read(cx); let has_no_results = model.no_results.unwrap_or(false); let is_search_underway = model.pending_search.is_some(); - let major_text = if is_search_underway { - "Searching..." + + let heading_text = if is_search_underway { + "Searching…" } else if has_no_results { - "No results" + "No Results" } else { - "Search all files" + "Search All Files" }; - let major_text = div() + let heading_text = div() .justify_center() - .max_w_96() - .child(Label::new(major_text).size(LabelSize::Large)); + .child(Label::new(heading_text).size(LabelSize::Large)); - let minor_text: Option = if let Some(no_results) = model.no_results { + let page_content: Option = if let Some(no_results) = model.no_results { if model.pending_search.is_none() && no_results { Some( Label::new("No results found in this project for the provided query") @@ -359,20 +359,22 @@ impl Render for ProjectSearchView { } else { Some(self.landing_text_minor(cx).into_any_element()) }; - let minor_text = minor_text.map(|text| div().items_center().max_w_96().child(text)); + + let page_content = page_content.map(|text| div().child(text)); + v_flex() - .flex_1() .size_full() + .items_center() .justify_center() + .overflow_hidden() .bg(cx.theme().colors().editor_background) .track_focus(&self.focus_handle(cx)) .child( - h_flex() - .size_full() - .justify_center() - .child(h_flex().flex_1()) - .child(v_flex().gap_1().child(major_text).children(minor_text)) - .child(h_flex().flex_1()), + v_flex() + .max_w_80() + .gap_1() + .child(heading_text) + .children(page_content), ) } } From 1903a29cca012e68431d96adb13fe8f2fb6a03ac Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:38:12 -0300 Subject: [PATCH 079/215] Expose "Column Git Blame" in the editor controls menu (#21336) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes https://github.com/zed-industries/zed/issues/10196 I think having this action exposed in the editor controls menu, close to the inline Git Blame option, makes more sense than a more prominent item somewhere else in the app. Maybe having it there will increase its discoverability. I myself didn't know this until a few weeks ago! Next steps would be ensuring the menu exposes its keybindings. (Quick note about the menu item name: I think maybe "_Git Blame Column_" would make more sense and feel grammatically more correct, but then we would have two Git Blame-related options, one with "Git Blame" at the start (Inline...) and another with "Git Blame" at the end (... Column). I guess one had to be sacrificed for the sake of consistency 😅.) Screenshot 2024-11-29 at 12 01 33 Release Notes: - N/A --- assets/icons/cursor_i_beam.svg | 6 ++- crates/editor/src/editor.rs | 4 ++ crates/zed/src/zed/quick_action_bar.rs | 63 ++++++++++++++++++-------- 3 files changed, 52 insertions(+), 21 deletions(-) diff --git a/assets/icons/cursor_i_beam.svg b/assets/icons/cursor_i_beam.svg index 2e7b95b203..93ac068fe2 100644 --- a/assets/icons/cursor_i_beam.svg +++ b/assets/icons/cursor_i_beam.svg @@ -1 +1,5 @@ - + + + + + diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 24ae84b035..6e729a654d 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11796,6 +11796,10 @@ impl Editor { self.blame.as_ref() } + pub fn show_git_blame_gutter(&self) -> bool { + self.show_git_blame_gutter + } + pub fn render_git_blame_gutter(&mut self, cx: &mut WindowContext) -> bool { self.show_git_blame_gutter && self.has_blame_entries(cx) } diff --git a/crates/zed/src/zed/quick_action_bar.rs b/crates/zed/src/zed/quick_action_bar.rs index 85090a1b97..bfcd3fa391 100644 --- a/crates/zed/src/zed/quick_action_bar.rs +++ b/crates/zed/src/zed/quick_action_bar.rs @@ -91,6 +91,7 @@ impl Render for QuickActionBar { inlay_hints_enabled, supports_inlay_hints, git_blame_inline_enabled, + show_git_blame_gutter, auto_signature_help_enabled, ) = { let editor = editor.read(cx); @@ -98,6 +99,7 @@ impl Render for QuickActionBar { let inlay_hints_enabled = editor.inlay_hints_enabled(); let supports_inlay_hints = editor.supports_inlay_hints(cx); let git_blame_inline_enabled = editor.git_blame_inline_enabled(); + let show_git_blame_gutter = editor.show_git_blame_gutter(); let auto_signature_help_enabled = editor.auto_signature_help_enabled(cx); ( @@ -105,6 +107,7 @@ impl Render for QuickActionBar { inlay_hints_enabled, supports_inlay_hints, git_blame_inline_enabled, + show_git_blame_gutter, auto_signature_help_enabled, ) }; @@ -235,26 +238,6 @@ impl Render for QuickActionBar { ); } - menu = menu.toggleable_entry( - "Inline Git Blame", - git_blame_inline_enabled, - IconPosition::Start, - Some(editor::actions::ToggleGitBlameInline.boxed_clone()), - { - let editor = editor.clone(); - move |cx| { - editor - .update(cx, |editor, cx| { - editor.toggle_git_blame_inline( - &editor::actions::ToggleGitBlameInline, - cx, - ) - }) - .ok(); - } - }, - ); - menu = menu.toggleable_entry( "Selection Menu", selection_menu_enabled, @@ -295,6 +278,46 @@ impl Render for QuickActionBar { }, ); + menu = menu.separator(); + + menu = menu.toggleable_entry( + "Inline Git Blame", + git_blame_inline_enabled, + IconPosition::Start, + Some(editor::actions::ToggleGitBlameInline.boxed_clone()), + { + let editor = editor.clone(); + move |cx| { + editor + .update(cx, |editor, cx| { + editor.toggle_git_blame_inline( + &editor::actions::ToggleGitBlameInline, + cx, + ) + }) + .ok(); + } + }, + ); + + menu = menu.toggleable_entry( + "Column Git Blame", + show_git_blame_gutter, + IconPosition::Start, + Some(editor::actions::ToggleGitBlame.boxed_clone()), + { + let editor = editor.clone(); + move |cx| { + editor + .update(cx, |editor, cx| { + editor + .toggle_git_blame(&editor::actions::ToggleGitBlame, cx) + }) + .ok(); + } + }, + ); + menu }); Some(menu) From 4137d1adb9574d9f9c99b9e9bb3d351ba036ee02 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 29 Nov 2024 12:45:08 -0300 Subject: [PATCH 080/215] Make project search landing page scrollable if too small (#21338) Address https://github.com/zed-industries/zed/issues/21317#issuecomment-2508011556 https://github.com/user-attachments/assets/089844fc-a485-44a6-8e8b-d294f28e9ea2 Release Notes: - N/A --- crates/search/src/project_search.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index ce894397c3..4055def5b0 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -371,6 +371,8 @@ impl Render for ProjectSearchView { .track_focus(&self.focus_handle(cx)) .child( v_flex() + .id("project-search-landing-page") + .overflow_y_scroll() .max_w_80() .gap_1() .child(heading_text) From aea6fa0c09828e74986cad67882f7726b704246b Mon Sep 17 00:00:00 2001 From: moshyfawn Date: Fri, 29 Nov 2024 15:37:24 -0500 Subject: [PATCH 081/215] Remove project panel trash action for remote projects (#21300) Closes #20845 I'm uncertain about my placement for the logic to remove actions from the command palette list. If anyone has insights or alternative approaches, I'm open to changing the code. Release Notes: - Removed project panel `Trash` action for remote projects. --------- Co-authored-by: Finn Evers --- Cargo.lock | 1 + crates/project_panel/Cargo.toml | 1 + crates/project_panel/src/project_panel.rs | 17 +++++++++++++++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bdb839e78b..7768dac710 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9313,6 +9313,7 @@ dependencies = [ "anyhow", "client", "collections", + "command_palette_hooks", "db", "editor", "file_icons", diff --git a/crates/project_panel/Cargo.toml b/crates/project_panel/Cargo.toml index dbcabc9f83..af913d9d6b 100644 --- a/crates/project_panel/Cargo.toml +++ b/crates/project_panel/Cargo.toml @@ -15,6 +15,7 @@ doctest = false [dependencies] anyhow.workspace = true collections.workspace = true +command_palette_hooks.workspace = true db.workspace = true editor.workspace = true file_icons.workspace = true diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 9803742966..bfb07fc7fd 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -17,6 +17,7 @@ use file_icons::FileIcons; use anyhow::{anyhow, Context as _, Result}; use collections::{hash_map, BTreeSet, HashMap}; +use command_palette_hooks::CommandPaletteFilter; use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action, @@ -38,6 +39,7 @@ use project_panel_settings::{ }; use serde::{Deserialize, Serialize}; use smallvec::SmallVec; +use std::any::TypeId; use std::{ cell::OnceCell, cmp, @@ -311,6 +313,15 @@ impl ProjectPanel { }) .detach(); + let trash_action = [TypeId::of::()]; + let is_remote = project.read(cx).is_via_collab(); + + if is_remote { + CommandPaletteFilter::update_global(cx, |filter, _cx| { + filter.hide_action_types(&trash_action); + }); + } + let filename_editor = cx.new_view(Editor::single_line); cx.subscribe( @@ -655,9 +666,11 @@ impl ProjectPanel { .action("Copy Relative Path", Box::new(CopyRelativePath)) .separator() .action("Rename", Box::new(Rename)) - .when(!is_root, |menu| { + .when(!is_root & !is_remote, |menu| { menu.action("Trash", Box::new(Trash { skip_prompt: false })) - .action("Delete", Box::new(Delete { skip_prompt: false })) + }) + .when(!is_root, |menu| { + menu.action("Delete", Box::new(Delete { skip_prompt: false })) }) .when(!is_remote & is_root, |menu| { menu.separator() From 4bf59393ecb1317f1494d945883240c0c2a94d04 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Sat, 30 Nov 2024 02:29:04 +0530 Subject: [PATCH 082/215] linux: Fix Zed not visible in "Open With" list in file manager for Flatpak (#21177) - Closes #19030 When `%U` is used in desktop entries, file managers pick this and use it: - When you right-click a file and choose "Open with..." - When you drag and drop files onto an application icon image Adding it to CLI args, changes Flatpak desktop entry `Exec` from: ```diff - Exec=/usr/bin/flatpak run --branch=master --arch=x86_64 --command=zed dev.zed.ZedDev --foreground + Exec=/usr/bin/flatpak run --branch=master --arch=x86_64 --command=zed --file-forwarding dev.zed.ZedDev --foreground @@u %U @@ ``` This is Flatpak's way of doing `%U`, by adding `--file-forwarding` and wrapping arg with `@@u` and `@@`. Read more below ([source](https://docs.flatpak.org/en/latest/flatpak-command-reference.html)): > --file-forwarding > > If this option is specified, the remaining arguments are scanned, and all arguments that are enclosed between a pair of '@@' arguments are interpreted as file paths, exported in the document store, and passed to the command in the form of the resulting document path. Arguments between "@@u" and "@@" are considered URIs, and any "file:" URIs are exported. The exports are non-persistent and with read and write permissions for the application. Release Notes: - Fixed Zed not visible in the "Open with" list in the file manager for Flatpak. --- crates/zed/resources/flatpak/manifest-template.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/resources/flatpak/manifest-template.json b/crates/zed/resources/flatpak/manifest-template.json index 7905058f44..1560027e9f 100644 --- a/crates/zed/resources/flatpak/manifest-template.json +++ b/crates/zed/resources/flatpak/manifest-template.json @@ -32,7 +32,7 @@ "BRANDING_LIGHT": "$BRANDING_LIGHT", "BRANDING_DARK": "$BRANDING_DARK", "APP_CLI": "zed", - "APP_ARGS": "--foreground", + "APP_ARGS": "--foreground %U", "DO_STARTUP_NOTIFY": "false" } }, From 5f29f214c3ac8a981b8951b06bd0c7555c3deb17 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Sat, 30 Nov 2024 02:31:29 +0530 Subject: [PATCH 083/215] linux: Fix file not opening from file explorer (#21137) Closes #20070 Release Notes: - Fixed issue where files wouldn't open from the file explorer. - Fixed "Open a new workspace" option on the desktop entry right-click menu. Context: Zed consists of two binaries: - `zed` (CLI component, located at `crates/cli/main.rs`) - `zed-editor` (GUI component, located at `crates/zed/main.rs`) When `zed` is used in the terminal, it checks if an existing instance is running. If one is found, it sends data via a socket to open the specified file. Otherwise, it launches a new instance of `zed-editor`. For more details, see the `detect` and `boot_background` functions in `crates/cli/main.rs`. Root Cause: Install process creates directories like `.local/zed.app` and `.local/zed-preview.app`, which contain desktop entries for the corresponding release. For example, `.local/zed.app/share/applications` contains `zed.desktop`. This desktop entry includes a generic `Exec` field, which is correct by default: ```sh Comment=A high-performance, multiplayer code editor. TryExec=zed StartupNotify=true ``` The issue is in the `install.sh` script. This script copies the above desktop file to the common directory for desktop entries (.local/share/applications). During this process, it replaces the `TryExec` value from `zed` with the exact binary path to avoid relying on the shell's PATH resolution and to make it explicit. However, replacement incorrectly uses the path for `zed-editor` instead of the `zed` CLI binary. This results in not opening a file as if you use `zed-editor` directly to do this it will throw `zed is already running` error on production and open new instance on dev. Note: This PR solves it for new users. For existing users, they will either have to update `.desktop` file manually, or use `install.sh` script again. I'm not aware of zed auto-update method, if it runs `install.sh` under the hood. --- script/install.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/install.sh b/script/install.sh index 3f2c690779..9cd21119b7 100755 --- a/script/install.sh +++ b/script/install.sh @@ -125,7 +125,7 @@ linux() { desktop_file_path="$HOME/.local/share/applications/${appid}.desktop" cp "$HOME/.local/zed$suffix.app/share/applications/zed$suffix.desktop" "${desktop_file_path}" sed -i "s|Icon=zed|Icon=$HOME/.local/zed$suffix.app/share/icons/hicolor/512x512/apps/zed.png|g" "${desktop_file_path}" - sed -i "s|Exec=zed|Exec=$HOME/.local/zed$suffix.app/libexec/zed-editor|g" "${desktop_file_path}" + sed -i "s|Exec=zed|Exec=$HOME/.local/zed$suffix.app/bin/zed|g" "${desktop_file_path}" } macos() { From 57a45d80ad1e3d2b7c87d68fc4e3499527543d1f Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 30 Nov 2024 00:50:38 +0200 Subject: [PATCH 084/215] Add a keybinding to the Go to Line button (#21350) Release Notes: - N/A --- crates/go_to_line/src/cursor_position.rs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/crates/go_to_line/src/cursor_position.rs b/crates/go_to_line/src/cursor_position.rs index 4f27c64256..2dc60475d3 100644 --- a/crates/go_to_line/src/cursor_position.rs +++ b/crates/go_to_line/src/cursor_position.rs @@ -1,5 +1,5 @@ use editor::{Editor, ToPoint}; -use gpui::{AppContext, Subscription, Task, View, WeakView}; +use gpui::{AppContext, FocusHandle, FocusableView, Subscription, Task, View, WeakView}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsSources}; @@ -22,6 +22,7 @@ pub(crate) struct SelectionStats { pub struct CursorPosition { position: Option, selected_count: SelectionStats, + context: Option, workspace: WeakView, update_position: Task<()>, _observe_active_editor: Option, @@ -31,6 +32,7 @@ impl CursorPosition { pub fn new(workspace: &Workspace) -> Self { Self { position: None, + context: None, selected_count: Default::default(), workspace: workspace.weak_handle(), update_position: Task::ready(()), @@ -58,7 +60,8 @@ impl CursorPosition { match editor.mode() { editor::EditorMode::AutoHeight { .. } | editor::EditorMode::SingleLine { .. } => { - cursor_position.position = None + cursor_position.position = None; + cursor_position.context = None; } editor::EditorMode::Full => { let mut last_selection = None::>; @@ -87,6 +90,7 @@ impl CursorPosition { } cursor_position.position = last_selection.map(|s| s.head().to_point(&buffer)); + cursor_position.context = Some(editor.focus_handle(cx)); } } @@ -158,6 +162,8 @@ impl Render for CursorPosition { ); self.write_position(&mut text, cx); + let context = self.context.clone(); + el.child( Button::new("go-to-line-column", text) .label_size(LabelSize::Small) @@ -174,12 +180,18 @@ impl Render for CursorPosition { }); } })) - .tooltip(|cx| { - Tooltip::for_action( + .tooltip(move |cx| match context.as_ref() { + Some(context) => Tooltip::for_action_in( + "Go to Line/Column", + &editor::actions::ToggleGoToLine, + context, + cx, + ), + None => Tooltip::for_action( "Go to Line/Column", &editor::actions::ToggleGoToLine, cx, - ) + ), }), ) }) From c1de606581b091d1db51857c7f0710b9b5f2c3d6 Mon Sep 17 00:00:00 2001 From: Haru Kim Date: Sat, 30 Nov 2024 21:30:27 +0900 Subject: [PATCH 085/215] Fix the `autoscroll_on_clicks` setting working incorrectly (#21362) --- crates/editor/src/editor.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 6e729a654d..339401ee46 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2967,7 +2967,7 @@ impl Editor { auto_scroll = false; } } - auto_scroll &= !EditorSettings::get_global(cx).autoscroll_on_clicks; + auto_scroll &= EditorSettings::get_global(cx).autoscroll_on_clicks; let point_to_delete: Option = { let selected_points: Vec> = From fd7180134661e772bf33487820115f7b9c6ac524 Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Sat, 30 Nov 2024 13:55:14 +0100 Subject: [PATCH 086/215] Improve JavaScript runnable detection followup (#21363) Followup: https://github.com/zed-industries/zed/pull/21246 **Before** Screenshot 2024-11-30 at 13 27 15 **After** Screenshot 2024-11-30 at 13 27 36 We did not need to add the `*` as it was already matching one of them, we actually need at least one of them, so making it optional was a mistake. Don't think we need to add release notes, as the change is only on main the branch now. Release Notes: - N/A --- crates/languages/src/javascript/outline.scm | 4 ++-- crates/languages/src/javascript/runnables.scm | 2 +- crates/languages/src/tsx/outline.scm | 4 ++-- crates/languages/src/tsx/runnables.scm | 2 +- crates/languages/src/typescript/outline.scm | 4 ++-- crates/languages/src/typescript/runnables.scm | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/crates/languages/src/javascript/outline.scm b/crates/languages/src/javascript/outline.scm index da6a1e0d31..0159d452cc 100644 --- a/crates/languages/src/javascript/outline.scm +++ b/crates/languages/src/javascript/outline.scm @@ -70,9 +70,9 @@ object: [ (identifier) @_name (member_expression object: (identifier) @_name) - ]* + ] ) - ]* @context + ] @context (#any-of? @_name "it" "test" "describe") arguments: ( arguments . (string (string_fragment) @name) diff --git a/crates/languages/src/javascript/runnables.scm b/crates/languages/src/javascript/runnables.scm index 615bd2f51a..af619dacb7 100644 --- a/crates/languages/src/javascript/runnables.scm +++ b/crates/languages/src/javascript/runnables.scm @@ -8,7 +8,7 @@ object: [ (identifier) @_name (member_expression object: (identifier) @_name) - ]* + ] ) ] (#any-of? @_name "it" "test" "describe") diff --git a/crates/languages/src/tsx/outline.scm b/crates/languages/src/tsx/outline.scm index 14dbf1cc0a..34b80b733b 100644 --- a/crates/languages/src/tsx/outline.scm +++ b/crates/languages/src/tsx/outline.scm @@ -78,9 +78,9 @@ object: [ (identifier) @_name (member_expression object: (identifier) @_name) - ]* + ] ) - ]* @context + ] @context (#any-of? @_name "it" "test" "describe") arguments: ( arguments . (string (string_fragment) @name) diff --git a/crates/languages/src/tsx/runnables.scm b/crates/languages/src/tsx/runnables.scm index 615bd2f51a..af619dacb7 100644 --- a/crates/languages/src/tsx/runnables.scm +++ b/crates/languages/src/tsx/runnables.scm @@ -8,7 +8,7 @@ object: [ (identifier) @_name (member_expression object: (identifier) @_name) - ]* + ] ) ] (#any-of? @_name "it" "test" "describe") diff --git a/crates/languages/src/typescript/outline.scm b/crates/languages/src/typescript/outline.scm index 14dbf1cc0a..34b80b733b 100644 --- a/crates/languages/src/typescript/outline.scm +++ b/crates/languages/src/typescript/outline.scm @@ -78,9 +78,9 @@ object: [ (identifier) @_name (member_expression object: (identifier) @_name) - ]* + ] ) - ]* @context + ] @context (#any-of? @_name "it" "test" "describe") arguments: ( arguments . (string (string_fragment) @name) diff --git a/crates/languages/src/typescript/runnables.scm b/crates/languages/src/typescript/runnables.scm index 615bd2f51a..af619dacb7 100644 --- a/crates/languages/src/typescript/runnables.scm +++ b/crates/languages/src/typescript/runnables.scm @@ -8,7 +8,7 @@ object: [ (identifier) @_name (member_expression object: (identifier) @_name) - ]* + ] ) ] (#any-of? @_name "it" "test" "describe") From d609931e1c27e9c42aa18ce328808bbce3149b64 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Sun, 1 Dec 2024 02:49:44 +0530 Subject: [PATCH 087/215] linux: Fix mouse cursor size and blur on Wayland (#21373) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #15788, #13258 This is a long-standing issue with a few previous attempts to fix it, such as [this one](https://github.com/zed-industries/zed/pull/17496). However, that fix was later reverted because it resolved the blur issue but caused a size issue. Currently, both blur and size issues persist when you set a custom cursor size from GNOME Settings and use fractional scaling. This PR addresses both issues. --- ### Context A new Wayland protocol, [cursor-shape-v1](https://gitlab.freedesktop.org/wayland/wayland-protocols/-/merge_requests/194), allows the compositor to handle rendering the cursor at the correct size and shape. This protocol is implemented by KDE, wlroots (Sway-like environments), etc. Zed supports this protocol, so there are no issues on these desktop environments. However, GNOME has not yet [adopted](https://gitlab.gnome.org/GNOME/gtk/-/merge_requests/6212) this protocol. As a result, apps must fall back to manually rendering the cursor by specifying the theme, size, scale, etc., themselves. Zed also implements this fallback but does not correctly account for the display scale. --- ### Scale Fix For example, if your cursor size is `64px` and you’re using fractional scaling (e.g., `150%`), the display scale reported by the window query will be an integer value, `2` in this case. Why `2` if the scale is `150%`? That’s what the new protocol aims to improve. However, since GNOME Wayland uses this integer scale everywhere, it’s sufficient for our use case. To fix the issue, we set the `buffer_scale` to this value. But that alone doesn’t solve the problem. We also need to generate a matching theme cursor size for this scaled version. This can be calculated as `64px` * `2`, resulting in `128px` as the theme cursor size. --- ### Size Fix The XDG Desktop Portal’s `cursor-size` event fails to read the cursor size because it expects an `i32` but encounters a type error with `u32`. Due to this, the cursor size was interpreted as the default `24px` instead of the actual size set via user. --- ### Tested This fix has been tested with all possible combinations of the following: - [x] GNOME Normal Scale (100%, 200%, etc.) - [x] GNOME Fractional Scaling (125%, 150%, etc.) - [x] GNOME Cursor Sizes (**Settings > Accessibility > Seeing**, e.g., `24px`, `64px`, etc.) - [x] GNOME Experimental Feature `scale-monitor-framebuffer` (both enabled and disabled) - [x] KDE (`cursor-shape-v1` protocol) --- **Result:** 64px custom cursor size + 150% Fractional Scale: https://github.com/user-attachments/assets/cf3b1a0f-9a25-45d0-ab03-75059d3305e7 --- Release Notes: - Fixed mouse cursor size and blur issues on Wayland --- .../gpui/src/platform/linux/wayland/client.rs | 17 +++--- .../gpui/src/platform/linux/wayland/cursor.rs | 53 ++++++++++++++----- .../gpui/src/platform/linux/wayland/window.rs | 42 ++++++++------- .../src/platform/linux/xdg_desktop_portal.rs | 10 ++-- 4 files changed, 79 insertions(+), 43 deletions(-) diff --git a/crates/gpui/src/platform/linux/wayland/client.rs b/crates/gpui/src/platform/linux/wayland/client.rs index e193201957..2cafffa725 100644 --- a/crates/gpui/src/platform/linux/wayland/client.rs +++ b/crates/gpui/src/platform/linux/wayland/client.rs @@ -496,7 +496,7 @@ impl WaylandClient { XDPEvent::CursorTheme(theme) => { if let Some(client) = client.0.upgrade() { let mut client = client.borrow_mut(); - client.cursor.set_theme(theme.as_str(), None); + client.cursor.set_theme(theme.as_str()); } } XDPEvent::CursorSize(size) => { @@ -649,15 +649,16 @@ impl LinuxClient for WaylandClient { if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, style.to_shape()); - } else if state.mouse_focused_window.is_some() { + } else if let Some(focused_window) = &state.mouse_focused_window { // cursor-shape-v1 isn't supported, set the cursor using a surface. let wl_pointer = state .wl_pointer .clone() .expect("window is focused by pointer"); + let scale = focused_window.primary_output_scale(); state .cursor - .set_icon(&wl_pointer, serial, &style.to_icon_name()); + .set_icon(&wl_pointer, serial, &style.to_icon_name(), scale); } } } @@ -1439,9 +1440,13 @@ impl Dispatch for WaylandClientStatePtr { if let Some(cursor_shape_device) = &state.cursor_shape_device { cursor_shape_device.set_shape(serial, style.to_shape()); } else { - state - .cursor - .set_icon(&wl_pointer, serial, &style.to_icon_name()); + let scale = window.primary_output_scale(); + state.cursor.set_icon( + &wl_pointer, + serial, + &style.to_icon_name(), + scale, + ); } } drop(state); diff --git a/crates/gpui/src/platform/linux/wayland/cursor.rs b/crates/gpui/src/platform/linux/wayland/cursor.rs index 6a52765042..09aa414deb 100644 --- a/crates/gpui/src/platform/linux/wayland/cursor.rs +++ b/crates/gpui/src/platform/linux/wayland/cursor.rs @@ -9,6 +9,7 @@ use wayland_cursor::{CursorImageBuffer, CursorTheme}; pub(crate) struct Cursor { theme: Option, theme_name: Option, + theme_size: u32, surface: WlSurface, size: u32, shm: WlShm, @@ -27,6 +28,7 @@ impl Cursor { Self { theme: CursorTheme::load(&connection, globals.shm.clone(), size).log_err(), theme_name: None, + theme_size: size, surface: globals.compositor.create_surface(&globals.qh, ()), shm: globals.shm.clone(), connection: connection.clone(), @@ -34,26 +36,26 @@ impl Cursor { } } - pub fn set_theme(&mut self, theme_name: &str, size: Option) { - if let Some(size) = size { - self.size = size; - } - if let Some(theme) = - CursorTheme::load_from_name(&self.connection, self.shm.clone(), theme_name, self.size) - .log_err() + pub fn set_theme(&mut self, theme_name: &str) { + if let Some(theme) = CursorTheme::load_from_name( + &self.connection, + self.shm.clone(), + theme_name, + self.theme_size, + ) + .log_err() { self.theme = Some(theme); self.theme_name = Some(theme_name.to_string()); } else if let Some(theme) = - CursorTheme::load(&self.connection, self.shm.clone(), self.size).log_err() + CursorTheme::load(&self.connection, self.shm.clone(), self.theme_size).log_err() { self.theme = Some(theme); self.theme_name = None; } } - pub fn set_size(&mut self, size: u32) { - self.size = size; + fn set_theme_size(&mut self, theme_size: u32) { self.theme = self .theme_name .as_ref() @@ -62,14 +64,29 @@ impl Cursor { &self.connection, self.shm.clone(), name.as_str(), - self.size, + theme_size, ) .log_err() }) - .or_else(|| CursorTheme::load(&self.connection, self.shm.clone(), self.size).log_err()); + .or_else(|| { + CursorTheme::load(&self.connection, self.shm.clone(), theme_size).log_err() + }); } - pub fn set_icon(&mut self, wl_pointer: &WlPointer, serial_id: u32, mut cursor_icon_name: &str) { + pub fn set_size(&mut self, size: u32) { + self.size = size; + self.set_theme_size(size); + } + + pub fn set_icon( + &mut self, + wl_pointer: &WlPointer, + serial_id: u32, + mut cursor_icon_name: &str, + scale: i32, + ) { + self.set_theme_size(self.size * scale as u32); + if let Some(theme) = &mut self.theme { let mut buffer: Option<&CursorImageBuffer>; @@ -91,7 +108,15 @@ impl Cursor { let (width, height) = buffer.dimensions(); let (hot_x, hot_y) = buffer.hotspot(); - wl_pointer.set_cursor(serial_id, Some(&self.surface), hot_x as i32, hot_y as i32); + self.surface.set_buffer_scale(scale); + + wl_pointer.set_cursor( + serial_id, + Some(&self.surface), + hot_x as i32 / scale, + hot_y as i32 / scale, + ); + self.surface.attach(Some(&buffer), 0, 0); self.surface.damage(0, 0, width as i32, height as i32); self.surface.commit(); diff --git a/crates/gpui/src/platform/linux/wayland/window.rs b/crates/gpui/src/platform/linux/wayland/window.rs index 55ba4f6004..4cdf88e262 100644 --- a/crates/gpui/src/platform/linux/wayland/window.rs +++ b/crates/gpui/src/platform/linux/wayland/window.rs @@ -194,6 +194,23 @@ impl WaylandWindowState { self.decorations == WindowDecorations::Client || self.background_appearance != WindowBackgroundAppearance::Opaque } + + pub fn primary_output_scale(&mut self) -> i32 { + let mut scale = 1; + let mut current_output = self.display.take(); + for (id, output) in self.outputs.iter() { + if let Some((_, output_data)) = ¤t_output { + if output.scale > output_data.scale { + current_output = Some((id.clone(), output.clone())); + } + } else { + current_output = Some((id.clone(), output.clone())); + } + scale = scale.max(output.scale); + } + self.display = current_output; + scale + } } pub(crate) struct WaylandWindow(pub WaylandWindowStatePtr); @@ -560,7 +577,7 @@ impl WaylandWindowStatePtr { state.outputs.insert(id, output.clone()); - let scale = primary_output_scale(&mut state); + let scale = state.primary_output_scale(); // We use `PreferredBufferScale` instead to set the scale if it's available if state.surface.version() < wl_surface::EVT_PREFERRED_BUFFER_SCALE_SINCE { @@ -572,7 +589,7 @@ impl WaylandWindowStatePtr { wl_surface::Event::Leave { output } => { state.outputs.remove(&output.id()); - let scale = primary_output_scale(&mut state); + let scale = state.primary_output_scale(); // We use `PreferredBufferScale` instead to set the scale if it's available if state.surface.version() < wl_surface::EVT_PREFERRED_BUFFER_SCALE_SINCE { @@ -719,6 +736,10 @@ impl WaylandWindowStatePtr { (fun)() } } + + pub fn primary_output_scale(&self) -> i32 { + self.state.borrow_mut().primary_output_scale() + } } fn extract_states<'a, S: TryFrom + 'a>(states: &'a [u8]) -> impl Iterator + 'a @@ -732,23 +753,6 @@ where .flat_map(S::try_from) } -fn primary_output_scale(state: &mut RefMut) -> i32 { - let mut scale = 1; - let mut current_output = state.display.take(); - for (id, output) in state.outputs.iter() { - if let Some((_, output_data)) = ¤t_output { - if output.scale > output_data.scale { - current_output = Some((id.clone(), output.clone())); - } - } else { - current_output = Some((id.clone(), output.clone())); - } - scale = scale.max(output.scale); - } - state.display = current_output; - scale -} - impl rwh::HasWindowHandle for WaylandWindow { fn window_handle(&self) -> Result, rwh::HandleError> { unimplemented!() diff --git a/crates/gpui/src/platform/linux/xdg_desktop_portal.rs b/crates/gpui/src/platform/linux/xdg_desktop_portal.rs index 64aa3975b8..722947a299 100644 --- a/crates/gpui/src/platform/linux/xdg_desktop_portal.rs +++ b/crates/gpui/src/platform/linux/xdg_desktop_portal.rs @@ -42,11 +42,13 @@ impl XDPEventSource { { sender.send(Event::CursorTheme(initial_theme))?; } + + // If u32 is used here, it throws invalid type error if let Ok(initial_size) = settings - .read::("org.gnome.desktop.interface", "cursor-size") + .read::("org.gnome.desktop.interface", "cursor-size") .await { - sender.send(Event::CursorSize(initial_size))?; + sender.send(Event::CursorSize(initial_size as u32))?; } if let Ok(mut cursor_theme_changed) = settings @@ -69,7 +71,7 @@ impl XDPEventSource { } if let Ok(mut cursor_size_changed) = settings - .receive_setting_changed_with_args::( + .receive_setting_changed_with_args::( "org.gnome.desktop.interface", "cursor-size", ) @@ -80,7 +82,7 @@ impl XDPEventSource { .spawn(async move { while let Some(size) = cursor_size_changed.next().await { let size = size?; - sender.send(Event::CursorSize(size))?; + sender.send(Event::CursorSize(size as u32))?; } anyhow::Ok(()) }) From c2cd84a749f605473ba293292766264a4027e600 Mon Sep 17 00:00:00 2001 From: Agustin Gomes Date: Sat, 30 Nov 2024 22:20:31 +0100 Subject: [PATCH 088/215] Add musl-gcc as dependency (#21366) This addition comes after attempting building Zed from source. As part of the process, one of the components (a crate I presume) called `ring` failed to compile due to the following sequence of console messages: ```log warning: ring@0.17.8: Compiler family detection failed due to error: ToolNotFound: Failed to find tool. Is `musl-gcc` installed? warning: ring@0.17.8: Compiler family detection failed due to error: ToolNotFound: Failed to find tool. Is `musl-gcc` installed? error: failed to run custom build command for `ring v0.17.8` ``` Adding this library should help fix the issue on Fedora 41 at least, and possibly will help fixing it for other RedHat based distributions as well. Closes #ISSUE Release Notes: - Add musl-gcc as dependency Signed-off-by: Agustin Gomes --- script/linux | 1 + 1 file changed, 1 insertion(+) diff --git a/script/linux b/script/linux index eecf70f90e..f1fe751154 100755 --- a/script/linux +++ b/script/linux @@ -67,6 +67,7 @@ yum=$(command -v yum || true) if [[ -n $dnf ]] || [[ -n $yum ]]; then pkg_cmd="${dnf:-${yum}}" deps=( + musl-gcc gcc clang cmake From 28849dd2a8859002a77804048ea60a5a735df3d7 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sun, 1 Dec 2024 01:48:31 +0200 Subject: [PATCH 089/215] Fix item closing overly triggering save dialogues (#21374) Closes https://github.com/zed-industries/zed/issues/12029 Allows to introspect project items inside items more deeply, checking them for being dirty. For that: * renames `project::Item` into `project::ProjectItem` * adds an `is_dirty(&self) -> bool` method to the renamed trait * changes the closing logic to only care about dirty project items when checking for save prompts conditions * save prompts are raised only if the item is singleton without a project path; or if the item has dirty project items that are not open elsewhere Release Notes: - Fixed item closing overly triggering save dialogues --- crates/diagnostics/src/diagnostics.rs | 2 +- crates/editor/src/editor.rs | 4 +- crates/editor/src/git/blame.rs | 2 +- crates/editor/src/items.rs | 6 +- crates/image_viewer/src/image_viewer.rs | 2 +- crates/outline_panel/src/outline_panel.rs | 2 +- crates/project/src/buffer_store.rs | 2 +- crates/project/src/image_store.rs | 8 +- crates/project/src/lsp_store.rs | 2 +- crates/project/src/project.rs | 9 +- crates/project_panel/src/project_panel.rs | 6 +- crates/repl/src/notebook/notebook_ui.rs | 27 +- crates/repl/src/repl_editor.rs | 2 +- crates/repl/src/repl_sessions_ui.rs | 2 +- crates/search/src/project_search.rs | 2 +- crates/workspace/src/item.rs | 20 +- crates/workspace/src/pane.rs | 112 +++-- crates/workspace/src/workspace.rs | 473 +++++++++++++++++++++- crates/zed/src/zed.rs | 2 +- 19 files changed, 600 insertions(+), 85 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 6db831c1ff..48a92d906e 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -716,7 +716,7 @@ impl Item for ProjectDiagnosticsEditor { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { self.editor.for_each_project_item(cx, f) } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 339401ee46..d5d96436e8 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -125,8 +125,8 @@ use parking_lot::{Mutex, RwLock}; use project::{ lsp_store::{FormatTarget, FormatTrigger}, project_settings::{GitGutterSetting, ProjectSettings}, - CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Item, Location, - LocationLink, Project, ProjectTransaction, TaskSourceKind, + CodeAction, Completion, CompletionIntent, DocumentHighlight, InlayHint, Location, LocationLink, + Project, ProjectItem, ProjectTransaction, TaskSourceKind, }; use rand::prelude::*; use rpc::{proto::*, ErrorExt}; diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index 9dfc379ae7..c5cfb2e850 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -10,7 +10,7 @@ use gpui::{Model, ModelContext, Subscription, Task}; use http_client::HttpClient; use language::{markdown, Bias, Buffer, BufferSnapshot, Edit, LanguageRegistry, ParsedMarkdown}; use multi_buffer::MultiBufferRow; -use project::{Item, Project}; +use project::{Project, ProjectItem}; use smallvec::SmallVec; use sum_tree::SumTree; use url::Url; diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 813b212761..2f2eb493bb 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -22,8 +22,8 @@ use language::{ use lsp::DiagnosticSeverity; use multi_buffer::AnchorRangeExt; use project::{ - lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Item as _, - Project, ProjectPath, + lsp_store::FormatTrigger, project_settings::ProjectSettings, search::SearchQuery, Project, + ProjectItem as _, ProjectPath, }; use rpc::proto::{self, update_view, PeerId}; use settings::Settings; @@ -665,7 +665,7 @@ impl Item for Editor { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.buffer .read(cx) diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index f7647223e5..c3f264d863 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -78,7 +78,7 @@ impl Item for ImageView { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { f(self.image_item.entity_id(), self.image_item.read(cx)) } diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index f878b582d9..66db3a3103 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -36,7 +36,7 @@ use language::{BufferId, BufferSnapshot, OffsetRangeExt, OutlineItem}; use menu::{Cancel, SelectFirst, SelectLast, SelectNext, SelectPrev}; use outline_panel_settings::{OutlinePanelDockPosition, OutlinePanelSettings, ShowIndentGuides}; -use project::{File, Fs, Item, Project}; +use project::{File, Fs, Project, ProjectItem}; use search::{BufferSearchBar, ProjectSearchView}; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 55b0f413a9..7a54f7cc47 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -1,7 +1,7 @@ use crate::{ search::SearchQuery, worktree_store::{WorktreeStore, WorktreeStoreEvent}, - Item, ProjectPath, + ProjectItem as _, ProjectPath, }; use ::git::{parse_git_remote_url, BuildPermalinkParams, GitHostingProviderRegistry}; use anyhow::{anyhow, Context as _, Result}; diff --git a/crates/project/src/image_store.rs b/crates/project/src/image_store.rs index 9f794d5248..949e1f484e 100644 --- a/crates/project/src/image_store.rs +++ b/crates/project/src/image_store.rs @@ -1,6 +1,6 @@ use crate::{ worktree_store::{WorktreeStore, WorktreeStoreEvent}, - Project, ProjectEntryId, ProjectPath, + Project, ProjectEntryId, ProjectItem, ProjectPath, }; use anyhow::{Context as _, Result}; use collections::{hash_map, HashMap, HashSet}; @@ -114,7 +114,7 @@ impl ImageItem { } } -impl crate::Item for ImageItem { +impl ProjectItem for ImageItem { fn try_open( project: &Model, path: &ProjectPath, @@ -151,6 +151,10 @@ impl crate::Item for ImageItem { fn project_path(&self, cx: &AppContext) -> Option { Some(self.project_path(cx).clone()) } + + fn is_dirty(&self) -> bool { + false + } } trait ImageStoreImpl { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 7d75347cf0..41a3ccc0a3 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -10,7 +10,7 @@ use crate::{ toolchain_store::{EmptyToolchainStore, ToolchainStoreEvent}, worktree_store::{WorktreeStore, WorktreeStoreEvent}, yarn::YarnPathStore, - CodeAction, Completion, CoreCompletion, Hover, InlayHint, Item as _, ProjectPath, + CodeAction, Completion, CoreCompletion, Hover, InlayHint, ProjectItem as _, ProjectPath, ProjectTransaction, ResolveState, Symbol, ToolchainStore, }; use anyhow::{anyhow, Context as _, Result}; diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 40da76ff3a..30732fc8b2 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -111,7 +111,7 @@ const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; const MAX_SEARCH_RESULT_FILES: usize = 5_000; const MAX_SEARCH_RESULT_RANGES: usize = 10_000; -pub trait Item { +pub trait ProjectItem { fn try_open( project: &Model, path: &ProjectPath, @@ -121,6 +121,7 @@ pub trait Item { Self: Sized; fn entry_id(&self, cx: &AppContext) -> Option; fn project_path(&self, cx: &AppContext) -> Option; + fn is_dirty(&self) -> bool; } #[derive(Clone)] @@ -4354,7 +4355,7 @@ impl ResolvedPath { } } -impl Item for Buffer { +impl ProjectItem for Buffer { fn try_open( project: &Model, path: &ProjectPath, @@ -4373,6 +4374,10 @@ impl Item for Buffer { path: file.path().clone(), }) } + + fn is_dirty(&self) -> bool { + self.is_dirty() + } } impl Completion { diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index bfb07fc7fd..df78ff1118 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -7511,7 +7511,7 @@ mod tests { path: ProjectPath, } - impl project::Item for TestProjectItem { + impl project::ProjectItem for TestProjectItem { fn try_open( _project: &Model, path: &ProjectPath, @@ -7528,6 +7528,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { Some(self.path.clone()) } + + fn is_dirty(&self) -> bool { + false + } } impl ProjectItem for TestProjectItemView { diff --git a/crates/repl/src/notebook/notebook_ui.rs b/crates/repl/src/notebook/notebook_ui.rs index d10da13fd8..435dab2d0c 100644 --- a/crates/repl/src/notebook/notebook_ui.rs +++ b/crates/repl/src/notebook/notebook_ui.rs @@ -158,16 +158,6 @@ impl NotebookEditor { }) } - fn is_dirty(&self, cx: &AppContext) -> bool { - self.cell_map.values().any(|cell| { - if let Cell::Code(code_cell) = cell { - code_cell.read(cx).is_dirty(cx) - } else { - false - } - }) - } - fn clear_outputs(&mut self, cx: &mut ViewContext) { for cell in self.cell_map.values() { if let Cell::Code(code_cell) = cell { @@ -500,7 +490,7 @@ pub struct NotebookItem { id: ProjectEntryId, } -impl project::Item for NotebookItem { +impl project::ProjectItem for NotebookItem { fn try_open( project: &Model, path: &ProjectPath, @@ -561,6 +551,10 @@ impl project::Item for NotebookItem { fn project_path(&self, _: &AppContext) -> Option { Some(self.project_path.clone()) } + + fn is_dirty(&self) -> bool { + false + } } impl NotebookItem { @@ -656,7 +650,7 @@ impl Item for NotebookEditor { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(gpui::EntityId, &dyn project::Item), + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), ) { f(self.notebook_item.entity_id(), self.notebook_item.read(cx)) } @@ -734,8 +728,13 @@ impl Item for NotebookEditor { } fn is_dirty(&self, cx: &AppContext) -> bool { - // self.is_dirty(cx) TODO - false + self.cell_map.values().any(|cell| { + if let Cell::Code(code_cell) = cell { + code_cell.read(cx).is_dirty(cx) + } else { + false + } + }) } } diff --git a/crates/repl/src/repl_editor.rs b/crates/repl/src/repl_editor.rs index b032b1804a..3c203900da 100644 --- a/crates/repl/src/repl_editor.rs +++ b/crates/repl/src/repl_editor.rs @@ -7,7 +7,7 @@ use anyhow::{Context, Result}; use editor::Editor; use gpui::{prelude::*, Entity, View, WeakView, WindowContext}; use language::{BufferSnapshot, Language, LanguageName, Point}; -use project::{Item as _, WorktreeId}; +use project::{ProjectItem as _, WorktreeId}; use crate::repl_store::ReplStore; use crate::session::SessionEvent; diff --git a/crates/repl/src/repl_sessions_ui.rs b/crates/repl/src/repl_sessions_ui.rs index 32b91ce28c..11db19ef84 100644 --- a/crates/repl/src/repl_sessions_ui.rs +++ b/crates/repl/src/repl_sessions_ui.rs @@ -3,7 +3,7 @@ use gpui::{ actions, prelude::*, AnyElement, AppContext, EventEmitter, FocusHandle, FocusableView, Subscription, View, }; -use project::Item as _; +use project::ProjectItem as _; use ui::{prelude::*, ButtonLike, ElevationIndex, KeyBinding}; use util::ResultExt as _; use workspace::item::ItemEvent; diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 4055def5b0..9caec6af34 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -449,7 +449,7 @@ impl Item for ProjectSearchView { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.results_editor.for_each_project_item(cx, f) } diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 20437145cb..40d92666a0 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -208,7 +208,7 @@ pub trait Item: FocusableView + EventEmitter { fn for_each_project_item( &self, _: &AppContext, - _: &mut dyn FnMut(EntityId, &dyn project::Item), + _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { } fn is_singleton(&self, _cx: &AppContext) -> bool { @@ -386,7 +386,7 @@ pub trait ItemHandle: 'static + Send { fn for_each_project_item( &self, _: &AppContext, - _: &mut dyn FnMut(EntityId, &dyn project::Item), + _: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ); fn is_singleton(&self, cx: &AppContext) -> bool; fn boxed_clone(&self) -> Box; @@ -563,7 +563,7 @@ impl ItemHandle for View { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.read(cx).for_each_project_item(cx, f) } @@ -891,7 +891,7 @@ impl WeakItemHandle for WeakView { } pub trait ProjectItem: Item { - type Item: project::Item; + type Item: project::ProjectItem; fn for_project_item( project: Model, @@ -1045,6 +1045,7 @@ pub mod test { pub struct TestProjectItem { pub entry_id: Option, pub project_path: Option, + pub is_dirty: bool, } pub struct TestItem { @@ -1065,7 +1066,7 @@ pub mod test { focus_handle: gpui::FocusHandle, } - impl project::Item for TestProjectItem { + impl project::ProjectItem for TestProjectItem { fn try_open( _project: &Model, _path: &ProjectPath, @@ -1073,7 +1074,6 @@ pub mod test { ) -> Option>>> { None } - fn entry_id(&self, _: &AppContext) -> Option { self.entry_id } @@ -1081,6 +1081,10 @@ pub mod test { fn project_path(&self, _: &AppContext) -> Option { self.project_path.clone() } + + fn is_dirty(&self) -> bool { + self.is_dirty + } } pub enum TestItemEvent { @@ -1097,6 +1101,7 @@ pub mod test { cx.new_model(|_| Self { entry_id, project_path, + is_dirty: false, }) } @@ -1104,6 +1109,7 @@ pub mod test { cx.new_model(|_| Self { project_path: None, entry_id: None, + is_dirty: false, }) } } @@ -1225,7 +1231,7 @@ pub mod test { fn for_each_project_item( &self, cx: &AppContext, - f: &mut dyn FnMut(EntityId, &dyn project::Item), + f: &mut dyn FnMut(EntityId, &dyn project::ProjectItem), ) { self.project_items .iter() diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index dc7b92a13b..66db71553f 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1295,10 +1295,12 @@ impl Pane { ) -> Task> { // Find the items to close. let mut items_to_close = Vec::new(); + let mut item_ids_to_close = HashSet::default(); let mut dirty_items = Vec::new(); for item in &self.items { if should_close(item.item_id()) { items_to_close.push(item.boxed_clone()); + item_ids_to_close.insert(item.item_id()); if item.is_dirty(cx) { dirty_items.push(item.boxed_clone()); } @@ -1339,16 +1341,23 @@ impl Pane { } } let mut saved_project_items_ids = HashSet::default(); - for item in items_to_close.clone() { - // Find the item's current index and its set of project item models. Avoid + for item_to_close in items_to_close { + // Find the item's current index and its set of dirty project item models. Avoid // storing these in advance, in case they have changed since this task // was started. - let (item_ix, mut project_item_ids) = pane.update(&mut cx, |pane, cx| { - (pane.index_for_item(&*item), item.project_item_model_ids(cx)) - })?; - let item_ix = if let Some(ix) = item_ix { - ix - } else { + let mut dirty_project_item_ids = Vec::new(); + let Some(item_ix) = pane.update(&mut cx, |pane, cx| { + item_to_close.for_each_project_item( + cx, + &mut |project_item_id, project_item| { + if project_item.is_dirty() { + dirty_project_item_ids.push(project_item_id); + } + }, + ); + pane.index_for_item(&*item_to_close) + })? + else { continue; }; @@ -1356,27 +1365,34 @@ impl Pane { // in the workspace, AND that the user has not already been prompted to save. // If there are any such project entries, prompt the user to save this item. let project = workspace.update(&mut cx, |workspace, cx| { - for item in workspace.items(cx) { - if !items_to_close - .iter() - .any(|item_to_close| item_to_close.item_id() == item.item_id()) - { - let other_project_item_ids = item.project_item_model_ids(cx); - project_item_ids.retain(|id| !other_project_item_ids.contains(id)); + for open_item in workspace.items(cx) { + let open_item_id = open_item.item_id(); + if !item_ids_to_close.contains(&open_item_id) { + let other_project_item_ids = open_item.project_item_model_ids(cx); + dirty_project_item_ids + .retain(|id| !other_project_item_ids.contains(id)); } } workspace.project().clone() })?; - let should_save = project_item_ids + let should_save = dirty_project_item_ids .iter() - .any(|id| saved_project_items_ids.insert(*id)); + .any(|id| saved_project_items_ids.insert(*id)) + // Always propose to save singleton files without any project paths: those cannot be saved via multibuffer, as require a file path selection modal. + || cx + .update(|cx| { + item_to_close.is_dirty(cx) + && item_to_close.is_singleton(cx) + && item_to_close.project_path(cx).is_none() + }) + .unwrap_or(false); if should_save && !Self::save_item( project.clone(), &pane, item_ix, - &*item, + &*item_to_close, save_intent, &mut cx, ) @@ -1390,7 +1406,7 @@ impl Pane { if let Some(item_ix) = pane .items .iter() - .position(|i| i.item_id() == item.item_id()) + .position(|i| i.item_id() == item_to_close.item_id()) { pane.remove_item(item_ix, false, true, cx); } @@ -3725,9 +3741,18 @@ mod tests { assert_item_labels(&pane, [], cx); - add_labeled_item(&pane, "A", true, cx); - add_labeled_item(&pane, "B", true, cx); - add_labeled_item(&pane, "C", true, cx); + add_labeled_item(&pane, "A", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new(1, "A.txt", cx)) + }); + add_labeled_item(&pane, "B", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new(2, "B.txt", cx)) + }); + add_labeled_item(&pane, "C", true, cx).update(cx, |item, cx| { + item.project_items + .push(TestProjectItem::new(3, "C.txt", cx)) + }); assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); let save = pane @@ -3746,6 +3771,30 @@ mod tests { cx.simulate_prompt_answer(2); save.await.unwrap(); assert_item_labels(&pane, [], cx); + + add_labeled_item(&pane, "A", true, cx); + add_labeled_item(&pane, "B", true, cx); + add_labeled_item(&pane, "C", true, cx); + assert_item_labels(&pane, ["A^", "B^", "C*^"], cx); + let save = pane + .update(cx, |pane, cx| { + pane.close_all_items( + &CloseAllItems { + save_intent: None, + close_pinned: false, + }, + cx, + ) + }) + .unwrap(); + + cx.executor().run_until_parked(); + cx.simulate_prompt_answer(2); + cx.executor().run_until_parked(); + cx.simulate_prompt_answer(2); + cx.executor().run_until_parked(); + save.await.unwrap(); + assert_item_labels(&pane, ["A*^", "B^", "C^"], cx); } #[gpui::test] @@ -3833,14 +3882,14 @@ mod tests { } // Assert the item label, with the active item label suffixed with a '*' + #[track_caller] fn assert_item_labels( pane: &View, expected_states: [&str; COUNT], cx: &mut VisualTestContext, ) { - pane.update(cx, |pane, cx| { - let actual_states = pane - .items + let actual_states = pane.update(cx, |pane, cx| { + pane.items .iter() .enumerate() .map(|(ix, item)| { @@ -3859,12 +3908,11 @@ mod tests { } state }) - .collect::>(); - - assert_eq!( - actual_states, expected_states, - "pane items do not match expectation" - ); - }) + .collect::>() + }); + assert_eq!( + actual_states, expected_states, + "pane items do not match expectation" + ); } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ed5aaa6e49..7945c4e404 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -391,12 +391,12 @@ impl Global for ProjectItemOpeners {} pub fn register_project_item(cx: &mut AppContext) { let builders = cx.default_global::(); builders.push(|project, project_path, cx| { - let project_item = ::try_open(project, project_path, cx)?; + let project_item = ::try_open(project, project_path, cx)?; let project = project.clone(); Some(cx.spawn(|cx| async move { let project_item = project_item.await?; let project_entry_id: Option = - project_item.read_with(&cx, project::Item::entry_id)?; + project_item.read_with(&cx, project::ProjectItem::entry_id)?; let build_workspace_item = Box::new(|cx: &mut ViewContext| { Box::new(cx.new_view(|cx| I::for_project_item(project, project_item, cx))) as Box @@ -2721,7 +2721,7 @@ impl Workspace { where T: ProjectItem, { - use project::Item as _; + use project::ProjectItem as _; let project_item = project_item.read(cx); let entry_id = project_item.entry_id(cx); let project_path = project_item.project_path(cx); @@ -6422,24 +6422,26 @@ mod tests { let item1 = cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) - .with_project_items(&[TestProjectItem::new(1, "1.txt", cx)]) + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) }); let item2 = cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) .with_conflict(true) - .with_project_items(&[TestProjectItem::new(2, "2.txt", cx)]) + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) }); let item3 = cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) .with_conflict(true) - .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + .with_project_items(&[dirty_project_item(3, "3.txt", cx)]) }); let item4 = cx.new_view(|cx| { - TestItem::new(cx) - .with_dirty(true) - .with_project_items(&[TestProjectItem::new_untitled(cx)]) + TestItem::new(cx).with_dirty(true).with_project_items(&[{ + let project_item = TestProjectItem::new_untitled(cx); + project_item.update(cx, |project_item, _| project_item.is_dirty = true); + project_item + }]) }); let pane = workspace.update(cx, |workspace, cx| { workspace.add_item_to_active_pane(Box::new(item1.clone()), None, true, cx); @@ -6531,7 +6533,7 @@ mod tests { cx.new_view(|cx| { TestItem::new(cx) .with_dirty(true) - .with_project_items(&[TestProjectItem::new( + .with_project_items(&[dirty_project_item( project_entry_id, &format!("{project_entry_id}.txt"), cx, @@ -6713,6 +6715,9 @@ mod tests { }) }); item.is_dirty = true; + for project_item in &mut item.project_items { + project_item.update(cx, |project_item, _| project_item.is_dirty = true); + } }); pane.update(cx, |pane, cx| { @@ -7411,6 +7416,434 @@ mod tests { }); } + #[gpui::test] + async fn test_no_save_prompt_when_multi_buffer_dirty_items_closed(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let dirty_multi_buffer_with_both = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + ]) + }); + let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id(); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer_2.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer_with_both.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(2, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + multi_buffer_with_both_files_id, + "Should select the multi buffer in the pane" + ); + }); + let close_all_but_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_inactive_items( + &CloseInactiveItems { + save_intent: Some(SaveIntent::Save), + close_pinned: true, + }, + cx, + ) + }) + .expect("should have inactive files to close"); + cx.background_executor.run_until_parked(); + assert!( + !cx.has_pending_prompt(), + "Multi buffer still has the unsaved buffer inside, so no save prompt should be shown" + ); + close_all_but_multi_buffer_task + .await + .expect("Closing all buffers but the multi buffer failed"); + pane.update(cx, |pane, cx| { + assert_eq!(dirty_regular_buffer.read(cx).save_count, 0); + assert_eq!(dirty_multi_buffer_with_both.read(cx).save_count, 0); + assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0); + assert_eq!(pane.items_len(), 1); + assert_eq!( + pane.active_item().unwrap().item_id(), + multi_buffer_with_both_files_id, + "Should have only the multi buffer left in the pane" + ); + assert!( + dirty_multi_buffer_with_both.read(cx).is_dirty, + "The multi buffer containing the unsaved buffer should still be dirty" + ); + }); + + let close_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item( + &CloseActiveItem { + save_intent: Some(SaveIntent::Close), + }, + cx, + ) + }) + .expect("should have the multi buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + cx.has_pending_prompt(), + "Dirty multi buffer should prompt a save dialog" + ); + cx.simulate_prompt_answer(0); + cx.background_executor.run_until_parked(); + close_multi_buffer_task + .await + .expect("Closing the multi buffer failed"); + pane.update(cx, |pane, cx| { + assert_eq!( + dirty_multi_buffer_with_both.read(cx).save_count, + 1, + "Multi buffer item should get be saved" + ); + // Test impl does not save inner items, so we do not assert them + assert_eq!( + pane.items_len(), + 0, + "No more items should be left in the pane" + ); + assert!(pane.active_item().is_none()); + }); + } + + #[gpui::test] + async fn test_no_save_prompt_when_dirty_singleton_buffer_closed_with_a_multi_buffer_containing_it_present_in_the_pane( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let clear_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_label("3.txt") + .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + }); + + let dirty_multi_buffer_with_both = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + clear_regular_buffer.read(cx).project_items[0].clone(), + ]) + }); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer_with_both.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(0, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + dirty_regular_buffer.item_id(), + "Should select the dirty singleton buffer in the pane" + ); + }); + let close_singleton_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) + }) + .expect("should have active singleton buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + !cx.has_pending_prompt(), + "Multi buffer is still in the pane and has the unsaved buffer inside, so no save prompt should be shown" + ); + + close_singleton_buffer_task + .await + .expect("Should not fail closing the singleton buffer"); + pane.update(cx, |pane, cx| { + assert_eq!(dirty_regular_buffer.read(cx).save_count, 0); + assert_eq!( + dirty_multi_buffer_with_both.read(cx).save_count, + 0, + "Multi buffer itself should not be saved" + ); + assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0); + assert_eq!( + pane.items_len(), + 1, + "A dirty multi buffer should be present in the pane" + ); + assert_eq!( + pane.active_item().unwrap().item_id(), + dirty_multi_buffer_with_both.item_id(), + "Should activate the only remaining item in the pane" + ); + }); + } + + #[gpui::test] + async fn test_save_prompt_when_dirty_multi_buffer_closed_with_some_of_its_dirty_items_not_present_in_the_pane( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let clear_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_label("3.txt") + .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + }); + + let dirty_multi_buffer_with_both = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + clear_regular_buffer.read(cx).project_items[0].clone(), + ]) + }); + let multi_buffer_with_both_files_id = dirty_multi_buffer_with_both.item_id(); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer_with_both.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(1, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + multi_buffer_with_both_files_id, + "Should select the multi buffer in the pane" + ); + }); + let _close_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) + }) + .expect("should have active multi buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + cx.has_pending_prompt(), + "With one dirty item from the multi buffer not being in the pane, a save prompt should be shown" + ); + } + + #[gpui::test] + async fn test_no_save_prompt_when_dirty_multi_buffer_closed_with_all_of_its_dirty_items_present_in_the_pane( + cx: &mut TestAppContext, + ) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + let project = Project::test(fs, [], cx).await; + let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); + let pane = workspace.update(cx, |workspace, _| workspace.active_pane().clone()); + + let dirty_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("1.txt") + .with_project_items(&[dirty_project_item(1, "1.txt", cx)]) + }); + let dirty_regular_buffer_2 = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_label("2.txt") + .with_project_items(&[dirty_project_item(2, "2.txt", cx)]) + }); + let clear_regular_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_label("3.txt") + .with_project_items(&[TestProjectItem::new(3, "3.txt", cx)]) + }); + + let dirty_multi_buffer = cx.new_view(|cx| { + TestItem::new(cx) + .with_dirty(true) + .with_singleton(false) + .with_label("Fake Project Search") + .with_project_items(&[ + dirty_regular_buffer.read(cx).project_items[0].clone(), + dirty_regular_buffer_2.read(cx).project_items[0].clone(), + clear_regular_buffer.read(cx).project_items[0].clone(), + ]) + }); + workspace.update(cx, |workspace, cx| { + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_regular_buffer_2.clone()), + None, + false, + false, + cx, + ); + workspace.add_item( + pane.clone(), + Box::new(dirty_multi_buffer.clone()), + None, + false, + false, + cx, + ); + }); + + pane.update(cx, |pane, cx| { + pane.activate_item(2, true, true, cx); + assert_eq!( + pane.active_item().unwrap().item_id(), + dirty_multi_buffer.item_id(), + "Should select the multi buffer in the pane" + ); + }); + let close_multi_buffer_task = pane + .update(cx, |pane, cx| { + pane.close_active_item(&CloseActiveItem { save_intent: None }, cx) + }) + .expect("should have active multi buffer to close"); + cx.background_executor.run_until_parked(); + assert!( + !cx.has_pending_prompt(), + "All dirty items from the multi buffer are in the pane still, no save prompts should be shown" + ); + close_multi_buffer_task + .await + .expect("Closing multi buffer failed"); + pane.update(cx, |pane, cx| { + assert_eq!(dirty_regular_buffer.read(cx).save_count, 0); + assert_eq!(dirty_multi_buffer.read(cx).save_count, 0); + assert_eq!(dirty_regular_buffer_2.read(cx).save_count, 0); + assert_eq!( + pane.items() + .map(|item| item.item_id()) + .sorted() + .collect::>(), + vec![ + dirty_regular_buffer.item_id(), + dirty_regular_buffer_2.item_id(), + ], + "Should have no multi buffer left in the pane" + ); + assert!(dirty_regular_buffer.read(cx).is_dirty); + assert!(dirty_regular_buffer_2.read(cx).is_dirty); + }); + } + mod register_project_item_tests { use ui::Context as _; @@ -7423,7 +7856,7 @@ mod tests { // Model struct TestPngItem {} - impl project::Item for TestPngItem { + impl project::ProjectItem for TestPngItem { fn try_open( _project: &Model, path: &ProjectPath, @@ -7443,6 +7876,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { None } + + fn is_dirty(&self) -> bool { + false + } } impl Item for TestPngItemView { @@ -7485,7 +7922,7 @@ mod tests { // Model struct TestIpynbItem {} - impl project::Item for TestIpynbItem { + impl project::ProjectItem for TestIpynbItem { fn try_open( _project: &Model, path: &ProjectPath, @@ -7505,6 +7942,10 @@ mod tests { fn project_path(&self, _: &AppContext) -> Option { None } + + fn is_dirty(&self) -> bool { + false + } } impl Item for TestIpynbItemView { @@ -7702,4 +8143,12 @@ mod tests { Project::init_settings(cx); }); } + + fn dirty_project_item(id: u64, path: &str, cx: &mut AppContext) -> Model { + let item = TestProjectItem::new(id, path, cx); + item.update(cx, |item, _| { + item.is_dirty = true; + }); + item + } } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e3d05d2fb..2adb287b4d 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -29,7 +29,7 @@ use gpui::{ pub use open_listener::*; use outline_panel::OutlinePanel; use paths::{local_settings_file_relative_path, local_tasks_file_relative_path}; -use project::{DirectoryLister, Item}; +use project::{DirectoryLister, ProjectItem}; use project_panel::ProjectPanel; use quick_action_bar::QuickActionBar; use recent_projects::open_ssh_project; From bf569d720e8628d78d3ab4449ec202ea746c0a42 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sun, 1 Dec 2024 01:49:41 +0200 Subject: [PATCH 090/215] Always change editor selection when navigating outline panel entries (#21375) Also scroll to the center when doing so. This way, related editor's breadcrumbs always update, bringing more information. Release Notes: - Adjust outline panel item opening behavior to always change the editor selection, and center it --- crates/outline_panel/src/outline_panel.rs | 39 +++++++++++++++-------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 66db3a3103..103bf10eec 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -811,7 +811,7 @@ impl OutlinePanel { if self.filter_editor.focus_handle(cx).is_focused(cx) { cx.propagate() } else if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, true, cx); + self.open_entry(&selected_entry, true, false, cx); } } @@ -834,7 +834,7 @@ impl OutlinePanel { } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { - self.open_entry(&selected_entry, true, cx); + self.open_entry(&selected_entry, true, true, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts(action, cx)); } } @@ -849,7 +849,7 @@ impl OutlinePanel { } else if let Some((active_editor, selected_entry)) = self.active_editor().zip(self.selected_entry().cloned()) { - self.open_entry(&selected_entry, true, cx); + self.open_entry(&selected_entry, true, true, cx); active_editor.update(cx, |editor, cx| editor.open_excerpts_in_split(action, cx)); } } @@ -858,6 +858,7 @@ impl OutlinePanel { &mut self, entry: &PanelEntry, change_selection: bool, + change_focus: bool, cx: &mut ViewContext, ) { let Some(active_editor) = self.active_editor() else { @@ -929,9 +930,9 @@ impl OutlinePanel { .workspace .update(cx, |workspace, cx| match self.active_item() { Some(active_item) => { - workspace.activate_item(active_item.as_ref(), true, change_selection, cx) + workspace.activate_item(active_item.as_ref(), true, change_focus, cx) } - None => workspace.activate_item(&active_editor, true, change_selection, cx), + None => workspace.activate_item(&active_editor, true, change_focus, cx), }); if activate.is_ok() { @@ -939,16 +940,20 @@ impl OutlinePanel { if change_selection { active_editor.update(cx, |editor, cx| { editor.change_selections( - Some(Autoscroll::Strategy(AutoscrollStrategy::Top)), + Some(Autoscroll::Strategy(AutoscrollStrategy::Center)), cx, |s| s.select_ranges(Some(anchor..anchor)), ); }); - active_editor.focus_handle(cx).focus(cx); } else { active_editor.update(cx, |editor, cx| { editor.set_scroll_anchor(ScrollAnchor { offset, anchor }, cx); }); + } + + if change_focus { + active_editor.focus_handle(cx).focus(cx); + } else { self.focus_handle.focus(cx); } } @@ -969,7 +974,7 @@ impl OutlinePanel { self.select_first(&SelectFirst {}, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, false, cx); + self.open_entry(&selected_entry, true, false, cx); } } @@ -988,7 +993,7 @@ impl OutlinePanel { self.select_last(&SelectLast, cx) } if let Some(selected_entry) = self.selected_entry().cloned() { - self.open_entry(&selected_entry, false, cx); + self.open_entry(&selected_entry, true, false, cx); } } @@ -2027,9 +2032,9 @@ impl OutlinePanel { if event.down.button == MouseButton::Right || event.down.first_mouse { return; } - let change_selection = event.down.click_count > 1; + let change_focus = event.down.click_count > 1; outline_panel.toggle_expanded(&clicked_entry, cx); - outline_panel.open_entry(&clicked_entry, change_selection, cx); + outline_panel.open_entry(&clicked_entry, true, change_focus, cx); }) }) .cursor_pointer() @@ -4863,9 +4868,13 @@ mod tests { ), select_first_in_all_matches(navigated_outline_selection) ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + outline_panel.update(cx, |_, cx| { assert_eq!( selected_row_text(&active_editor, cx), - initial_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes + navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should still have the initial caret position after SelectNext calls" ); }); @@ -4895,9 +4904,13 @@ mod tests { ), select_first_in_all_matches(next_navigated_outline_selection) ); + }); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + outline_panel.update(cx, |_, cx| { assert_eq!( selected_row_text(&active_editor, cx), - navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes + next_navigated_outline_selection.replace("search: ", ""), // Clear outline metadata prefixes "Should again preserve the selection after another SelectNext call" ); }); From 4d5415273ea4b3798748b6c66dc8b7694db91d68 Mon Sep 17 00:00:00 2001 From: yoleuh Date: Sun, 1 Dec 2024 03:59:29 -0500 Subject: [PATCH 091/215] Docs: Update developing zed docs to match (#21379) Some changes just so the build docs for the different os matches each other :) macos: - moved `rust wasm toolchain install` up under `rust install` (match windows docs) - add instructions to update rust if already installed (match windows and linux docs) windows: - add `(required by a dependency)` to cmake install (match macos docs) Release Notes: - N/A --- docs/src/development/linux.md | 6 +----- docs/src/development/macos.md | 9 ++------- docs/src/development/windows.md | 14 ++------------ 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/docs/src/development/linux.md b/docs/src/development/linux.md index 5dba44d2f0..1505f99e88 100644 --- a/docs/src/development/linux.md +++ b/docs/src/development/linux.md @@ -6,11 +6,7 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). ## Dependencies -- Install [Rust](https://www.rust-lang.org/tools/install). If it's already installed, make sure it's up-to-date: - - ```sh - rustup update - ``` +- Install [rustup](https://www.rust-lang.org/tools/install) - Install the necessary system libraries: diff --git a/docs/src/development/macos.md b/docs/src/development/macos.md index 2fd076b0fa..fe15e9f56e 100644 --- a/docs/src/development/macos.md +++ b/docs/src/development/macos.md @@ -6,7 +6,8 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). ## Dependencies -- Install [Rust](https://www.rust-lang.org/tools/install) +- Install [rustup](https://www.rust-lang.org/tools/install) + - Install [Xcode](https://apps.apple.com/us/app/xcode/id497799835?mt=12) from the macOS App Store, or from the [Apple Developer](https://developer.apple.com/download/all/) website. Note this requires a developer account. > Ensure you launch Xcode after installing, and install the macOS components, which is the default option. @@ -24,12 +25,6 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). sudo xcodebuild -license accept ``` -- Install the Rust wasm toolchain: - - ```sh - rustup target add wasm32-wasip1 - ``` - - Install `cmake` (required by [a dependency](https://docs.rs/wasmtime-c-api-impl/latest/wasmtime_c_api/)) ```sh diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index f95cfb3ed0..9cb539366d 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -8,21 +8,11 @@ Clone down the [Zed repository](https://github.com/zed-industries/zed). ## Dependencies -- Install [Rust](https://www.rust-lang.org/tools/install). If it's already installed, make sure it's up-to-date: - - ```sh - rustup update - ``` - -- Install the Rust wasm toolchain: - - ```sh - rustup target add wasm32-wasip1 - ``` +- Install [rustup](https://www.rust-lang.org/tools/install) - Install [Visual Studio](https://visualstudio.microsoft.com/downloads/) with the optional components `MSVC v*** - VS YYYY C++ x64/x86 build tools` and `MSVC v*** - VS YYYY C++ x64/x86 Spectre-mitigated libs (latest)` (`v***` is your VS version and `YYYY` is year when your VS was released. Pay attention to the architecture and change it to yours if needed.) - Install Windows 11 or 10 SDK depending on your system, but ensure that at least `Windows 10 SDK version 2104 (10.0.20348.0)` is installed on your machine. You can download it from the [Windows SDK Archive](https://developer.microsoft.com/windows/downloads/windows-sdk/) -- Install [CMake](https://cmake.org/download) +- Install [CMake](https://cmake.org/download) (required by [a dependency](https://docs.rs/wasmtime-c-api-impl/latest/wasmtime_c_api/)) ## Backend dependencies From 5f6b200d8d206b77b0c3aac9edb4b8d80f17eb5a Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sun, 1 Dec 2024 14:28:48 +0200 Subject: [PATCH 092/215] Do not change selections when opening FS entries (#21382) Follow-up of https://github.com/zed-industries/zed/pull/21375 When changing selections for FS entries, outline panel will be forced to change item to the first excerpt which is not what we want. Release Notes: - N/A --- crates/outline_panel/src/outline_panel.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 103bf10eec..f36e144c88 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -857,7 +857,7 @@ impl OutlinePanel { fn open_entry( &mut self, entry: &PanelEntry, - change_selection: bool, + prefer_selection_change: bool, change_focus: bool, cx: &mut ViewContext, ) { @@ -872,9 +872,11 @@ impl OutlinePanel { Point::new(0.0, -(active_editor.read(cx).file_header_size() as f32)) }; + let mut change_selection = prefer_selection_change; let scroll_target = match entry { PanelEntry::FoldedDirs(..) | PanelEntry::Fs(FsEntry::Directory(..)) => None, PanelEntry::Fs(FsEntry::ExternalFile(buffer_id, _)) => { + change_selection = false; let scroll_target = multi_buffer_snapshot.excerpts().find_map( |(excerpt_id, buffer_snapshot, excerpt_range)| { if &buffer_snapshot.remote_id() == buffer_id { @@ -888,6 +890,7 @@ impl OutlinePanel { Some(offset_from_top).zip(scroll_target) } PanelEntry::Fs(FsEntry::File(_, file_entry, ..)) => { + change_selection = false; let scroll_target = self .project .update(cx, |project, cx| { From 89a56968f6570bc650b0283a45f60f83479beb84 Mon Sep 17 00:00:00 2001 From: moskirathe <39177599+moskirathe@users.noreply.github.com> Date: Sun, 1 Dec 2024 17:02:12 -0500 Subject: [PATCH 093/215] Fix typos in key-bindings documentation (#21390) Release Notes: Fixes two minor typos in the key-bindings documentation. --- docs/src/key-bindings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 68db517480..660a80ebd4 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -130,7 +130,7 @@ When multiple keybindings have the same keystroke and are active at the same tim The other kind of conflict that arises is when you have two bindings, one of which is a prefix of the other. For example if you have `"ctrl-w":"editor::DeleteToNextWordEnd"` and `"ctrl-w left":"editor::DeleteToEndOfLine"`. -When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you tupe `ctrl-w` to se if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. +When this happens, and both bindings are active in the current context, Zed will wait for 1 second after you type `ctrl-w` to see if you're about to type `left`. If you don't type anything, or if you type a different key, then `DeleteToNextWordEnd` will be triggered. If you do, then `DeleteToEndOfLine` will be triggered. ### Non-QWERTY keyboards From 380679fcc23ba978401a8bb091716d4a05fab937 Mon Sep 17 00:00:00 2001 From: fred-sch <73998525+fred-sch@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:35:29 +0100 Subject: [PATCH 094/215] Fix: Copilot Chat is logged out (#21360) Closes #21255 Release Notes: - Fixed Copilot Chat OAuth Token parsing --------- Co-authored-by: Bennet Bo Fenner --- crates/copilot/src/copilot_chat.rs | 41 ++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index 075c3b69b1..daddefb579 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -197,7 +197,7 @@ pub fn init(fs: Arc, client: Arc, cx: &mut AppContext) { cx.set_global(GlobalCopilotChat(copilot_chat)); } -fn copilot_chat_config_path() -> &'static PathBuf { +fn copilot_chat_config_dir() -> &'static PathBuf { static COPILOT_CHAT_CONFIG_DIR: OnceLock = OnceLock::new(); COPILOT_CHAT_CONFIG_DIR.get_or_init(|| { @@ -207,10 +207,14 @@ fn copilot_chat_config_path() -> &'static PathBuf { home_dir().join(".config") } .join("github-copilot") - .join("hosts.json") }) } +fn copilot_chat_config_paths() -> [PathBuf; 2] { + let base_dir = copilot_chat_config_dir(); + [base_dir.join("hosts.json"), base_dir.join("apps.json")] +} + impl CopilotChat { pub fn global(cx: &AppContext) -> Option> { cx.try_global::() @@ -218,13 +222,24 @@ impl CopilotChat { } pub fn new(fs: Arc, client: Arc, cx: &AppContext) -> Self { - let mut config_file_rx = watch_config_file( - cx.background_executor(), - fs, - copilot_chat_config_path().clone(), - ); + let config_paths = copilot_chat_config_paths(); + + let resolve_config_path = { + let fs = fs.clone(); + async move { + for config_path in config_paths.iter() { + if fs.metadata(config_path).await.is_ok_and(|v| v.is_some()) { + return config_path.clone(); + } + } + config_paths[0].clone() + } + }; cx.spawn(|cx| async move { + let config_file = resolve_config_path.await; + let mut config_file_rx = watch_config_file(cx.background_executor(), fs, config_file); + while let Some(contents) = config_file_rx.next().await { let oauth_token = extract_oauth_token(contents); @@ -318,9 +333,15 @@ async fn request_api_token(oauth_token: &str, client: Arc) -> Re fn extract_oauth_token(contents: String) -> Option { serde_json::from_str::(&contents) .map(|v| { - v["github.com"]["oauth_token"] - .as_str() - .map(|v| v.to_string()) + v.as_object().and_then(|obj| { + obj.iter().find_map(|(key, value)| { + if key.starts_with("github.com") { + value["oauth_token"].as_str().map(|v| v.to_string()) + } else { + None + } + }) + }) }) .ok() .flatten() From 740ba7817bfa94cfc38f3523bc1cc492d950ecdc Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 2 Dec 2024 07:47:57 -0300 Subject: [PATCH 095/215] Fine-tune terminal tab bar actions spacing (#21391) Just quickly reducing the spacing between the terminal tab bar actions so they're tighter and matching other similar components. | Before | After | |--------|--------| | Screenshot 2024-12-01 at 19 20 50 | Screenshot 2024-12-01 at 19 18 19 | Release Notes: - N/A --- crates/terminal_view/src/terminal_panel.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 1799d24c7d..532d5d9040 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -26,8 +26,8 @@ use terminal::{ Terminal, }; use ui::{ - div, h_flex, ButtonCommon, Clickable, ContextMenu, FluentBuilder, IconButton, IconSize, - InteractiveElement, PopoverMenu, Selectable, Tooltip, + prelude::*, ButtonCommon, Clickable, ContextMenu, FluentBuilder, PopoverMenu, Selectable, + Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::{ @@ -139,14 +139,13 @@ impl TerminalPanel { } let focus_handle = pane.focus_handle(cx); let right_children = h_flex() - .gap_2() - .children(assistant_tab_bar_button.clone()) + .gap(DynamicSpacing::Base02.rems(cx)) .child( PopoverMenu::new("terminal-tab-bar-popover-menu") .trigger( IconButton::new("plus", IconName::Plus) .icon_size(IconSize::Small) - .tooltip(|cx| Tooltip::text("New...", cx)), + .tooltip(|cx| Tooltip::text("New…", cx)), ) .anchor(AnchorCorner::TopRight) .with_handle(pane.new_item_context_menu_handle.clone()) @@ -170,6 +169,7 @@ impl TerminalPanel { Some(menu) }), ) + .children(assistant_tab_bar_button.clone()) .child( PopoverMenu::new("terminal-pane-tab-bar-split") .trigger( From dacd919e27aebbdc3dd466e395d6afbfd514b32a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 2 Dec 2024 07:48:10 -0300 Subject: [PATCH 096/215] Add setting for making the tab's close button always visible (#21352) Closes https://github.com/zed-industries/zed/issues/20422 Screenshot 2024-11-29 at 22 00 20 Release Notes: - N/A --- assets/settings/default.json | 2 ++ crates/workspace/src/item.rs | 5 +++++ crates/workspace/src/pane.rs | 8 ++++++-- docs/src/configuring-zed.md | 9 ++++++++- 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index b844be7fa2..5930537856 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -559,6 +559,8 @@ "close_position": "right", // Whether to show the file icon for a tab. "file_icons": false, + // Whether to always show the close button on tabs. + "always_show_close_button": false, // What to do after closing the current tab. // // 1. Activate the tab that was open previously (default) diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 40d92666a0..eab3ddc755 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -42,6 +42,7 @@ pub struct ItemSettings { pub close_position: ClosePosition, pub activate_on_close: ActivateOnClose, pub file_icons: bool, + pub always_show_close_button: bool, } #[derive(Deserialize)] @@ -85,6 +86,10 @@ pub struct ItemSettingsContent { /// /// Default: history pub activate_on_close: Option, + /// Whether to always show the close button on tabs. + /// + /// Default: false + always_show_close_button: Option, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 66db71553f..83cc911a91 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1951,7 +1951,9 @@ impl Pane { }; let icon = item.tab_icon(cx); - let close_side = &ItemSettings::get_global(cx).close_position; + let settings = ItemSettings::get_global(cx); + let close_side = &settings.close_position; + let always_show_close_button = settings.always_show_close_button; let indicator = render_item_indicator(item.boxed_clone(), cx); let item_id = item.item_id(); let is_first_item = ix == 0; @@ -2046,7 +2048,9 @@ impl Pane { end_slot_action = &CloseActiveItem { save_intent: None }; end_slot_tooltip_text = "Close Tab"; IconButton::new("close tab", IconName::Close) - .visible_on_hover("") + .when(!always_show_close_button, |button| { + button.visible_on_hover("") + }) .shape(IconButtonShape::Square) .icon_color(Color::Muted) .size(ButtonSize::None) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index bd1da9ece8..e71266a01f 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -634,7 +634,8 @@ List of `string` values "close_position": "right", "file_icons": false, "git_status": false, - "activate_on_close": "history" + "activate_on_close": "history", + "always_show_close_button": false }, ``` @@ -698,6 +699,12 @@ List of `string` values } ``` +### Always show the close button + +- Description: Whether to always show the close button on tabs. +- Setting: `always_show_close_button` +- Default: `false` + ## Editor Toolbar - Description: Whether or not to show various elements in the editor toolbar. From 2300f40cd987bdb3602769d312786eb4118d711c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 2 Dec 2024 09:28:46 -0300 Subject: [PATCH 097/215] Add consistent placeholder text for terminal inline assist (#21398) Ensuring it is consistent with the buffer inline assistant. Just thought of not having "Transform" here as that felt it made less sense for terminal-related prompts, where arguably more frequently, one would be suggesting for actual commands rather than code transformation. Screenshot 2024-12-02 at 09 11 00 Release Notes: - N/A --- crates/assistant/src/terminal_inline_assistant.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/assistant/src/terminal_inline_assistant.rs b/crates/assistant/src/terminal_inline_assistant.rs index a5424a8d7e..d60a556cf0 100644 --- a/crates/assistant/src/terminal_inline_assistant.rs +++ b/crates/assistant/src/terminal_inline_assistant.rs @@ -32,7 +32,7 @@ use telemetry_events::{AssistantEvent, AssistantKind, AssistantPhase}; use terminal::Terminal; use terminal_view::TerminalView; use theme::ThemeSettings; -use ui::{prelude::*, IconButtonShape, Tooltip}; +use ui::{prelude::*, text_for_action, IconButtonShape, Tooltip}; use util::ResultExt; use workspace::{notifications::NotificationId, Toast, Workspace}; @@ -704,7 +704,7 @@ impl PromptEditor { cx, ); editor.set_soft_wrap_mode(language::language_settings::SoftWrap::EditorWidth, cx); - editor.set_placeholder_text("Add a prompt…", cx); + editor.set_placeholder_text(Self::placeholder_text(cx), cx); editor }); @@ -737,6 +737,14 @@ impl PromptEditor { this } + fn placeholder_text(cx: &WindowContext) -> String { + let context_keybinding = text_for_action(&crate::ToggleFocus, cx) + .map(|keybinding| format!(" • {keybinding} for context")) + .unwrap_or_default(); + + format!("Generate…{context_keybinding} • ↓↑ for history") + } + fn subscribe_to_editor(&mut self, cx: &mut ViewContext) { self.editor_subscriptions.clear(); self.editor_subscriptions From 0cb3a6ed0ebbb0eec6bc7f1d15732a6b0da1c262 Mon Sep 17 00:00:00 2001 From: Delyan Angelov Date: Mon, 2 Dec 2024 15:51:28 +0200 Subject: [PATCH 098/215] Add V file icon (#20017) Here is a preview of the new `v.svg` in comparison with some of the existing icons: ![image](https://github.com/user-attachments/assets/451762ff-b13a-42b9-89ac-695f25a43a84) --------- Co-authored-by: Danilo Leal --- assets/icons/file_icons/file_types.json | 6 ++++++ assets/icons/file_icons/v.svg | 4 ++++ 2 files changed, 10 insertions(+) create mode 100644 assets/icons/file_icons/v.svg diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index fe293256b3..8c6a624416 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -173,6 +173,9 @@ "tsx": "react", "ttf": "font", "txt": "document", + "v": "v", + "vsh": "v", + "vv": "v", "vue": "vue", "wav": "audio", "webm": "video", @@ -379,6 +382,9 @@ "typescript": { "icon": "icons/file_icons/typescript.svg" }, + "v": { + "icon": "icons/file_icons/v.svg" + }, "vcs": { "icon": "icons/file_icons/git.svg" }, diff --git a/assets/icons/file_icons/v.svg b/assets/icons/file_icons/v.svg new file mode 100644 index 0000000000..485e27a378 --- /dev/null +++ b/assets/icons/file_icons/v.svg @@ -0,0 +1,4 @@ + + + + From 6cb758a1cd2a5c46b7074dfd1f455ea4159654be Mon Sep 17 00:00:00 2001 From: loczek <30776250+loczek@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:37:41 +0100 Subject: [PATCH 099/215] theme_importer: Add more mappings (#21393) This PR adds `search_match_background` and `editor_document_highlight_bracket_background` color mappings as they appear to be missing. --- crates/theme_importer/src/vscode/converter.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/theme_importer/src/vscode/converter.rs b/crates/theme_importer/src/vscode/converter.rs index cca4b56321..a1a6c7a27c 100644 --- a/crates/theme_importer/src/vscode/converter.rs +++ b/crates/theme_importer/src/vscode/converter.rs @@ -159,7 +159,9 @@ impl VsCodeThemeConverter { .active_background .clone() .or(vscode_tab_inactive_background.clone()), + search_match_background: vscode_colors.editor.find_match_background.clone(), panel_background: vscode_colors.panel.background.clone(), + pane_group_border: vscode_colors.editor_group.border.clone(), scrollbar_thumb_background: vscode_scrollbar_slider_background.clone(), scrollbar_thumb_hover_background: vscode_colors .scrollbar_slider @@ -168,7 +170,6 @@ impl VsCodeThemeConverter { scrollbar_thumb_border: vscode_scrollbar_slider_background.clone(), scrollbar_track_background: vscode_editor_background.clone(), scrollbar_track_border: vscode_colors.editor_overview_ruler.border.clone(), - pane_group_border: vscode_colors.editor_group.border.clone(), editor_foreground: vscode_editor_foreground .clone() .or(vscode_token_colors_foreground.clone()), @@ -179,6 +180,10 @@ impl VsCodeThemeConverter { editor_active_line_number: vscode_colors.editor.foreground.clone(), editor_wrap_guide: vscode_panel_border.clone(), editor_active_wrap_guide: vscode_panel_border.clone(), + editor_document_highlight_bracket_background: vscode_colors + .editor_bracket_match + .background + .clone(), terminal_background: vscode_colors.terminal.background.clone(), terminal_ansi_black: vscode_colors.terminal.ansi_black.clone(), terminal_ansi_bright_black: vscode_colors.terminal.ansi_bright_black.clone(), From 3987d0d7317408091c0ac0a706c5508a3c97af92 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 2 Dec 2024 16:56:47 +0100 Subject: [PATCH 100/215] Treat `.pcss` files as CSS (#21402) This addresses https://github.com/zed-industries/zed/pull/19416#discussion_r1865019293 and also follows the [associated PostCSS file extensions for VS Code](https://github.com/csstools/postcss-language/blob/5d003170c5ed962b09b9a0f3725a6cae885df292/package.json#L37). Release Notes: - `.pcss` files are now recognized as CSS --------- Co-authored-by: Marshall Bowers --- assets/icons/file_icons/file_types.json | 1 + crates/languages/src/css/config.toml | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index 8c6a624416..5e927369d3 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -127,6 +127,7 @@ "ogg": "audio", "opus": "audio", "otf": "font", + "pcss": "css", "pdb": "storage", "pdf": "document", "php": "php", diff --git a/crates/languages/src/css/config.toml b/crates/languages/src/css/config.toml index 9b0c9c703c..d6ea2f9c7f 100644 --- a/crates/languages/src/css/config.toml +++ b/crates/languages/src/css/config.toml @@ -1,6 +1,6 @@ name = "CSS" grammar = "css" -path_suffixes = ["css", "postcss"] +path_suffixes = ["css", "postcss", "pcss"] autoclose_before = ";:.,=}])>" brackets = [ { start = "{", end = "}", close = true, newline = true }, From 89e46396f6d06a32c3a917fa4a392ab82b32e345 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 2 Dec 2024 17:08:16 +0100 Subject: [PATCH 101/215] workspace: Serialize active panel even if it's not visible (#21408) Fixes #21285 Closes #21285 Release Notes: - Fixed workspace serialization of collapsed panels --- crates/workspace/src/workspace.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7945c4e404..a8681f22c5 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -4144,30 +4144,30 @@ impl Workspace { let left_dock = this.left_dock.read(cx); let left_visible = left_dock.is_open(); let left_active_panel = left_dock - .visible_panel() + .active_panel() .map(|panel| panel.persistent_name().to_string()); let left_dock_zoom = left_dock - .visible_panel() + .active_panel() .map(|panel| panel.is_zoomed(cx)) .unwrap_or(false); let right_dock = this.right_dock.read(cx); let right_visible = right_dock.is_open(); let right_active_panel = right_dock - .visible_panel() + .active_panel() .map(|panel| panel.persistent_name().to_string()); let right_dock_zoom = right_dock - .visible_panel() + .active_panel() .map(|panel| panel.is_zoomed(cx)) .unwrap_or(false); let bottom_dock = this.bottom_dock.read(cx); let bottom_visible = bottom_dock.is_open(); let bottom_active_panel = bottom_dock - .visible_panel() + .active_panel() .map(|panel| panel.persistent_name().to_string()); let bottom_dock_zoom = bottom_dock - .visible_panel() + .active_panel() .map(|panel| panel.is_zoomed(cx)) .unwrap_or(false); From 995b40f1498b20a27ed1c11adb9551a273884d7b Mon Sep 17 00:00:00 2001 From: uncenter <47499684+uncenter@users.noreply.github.com> Date: Mon, 2 Dec 2024 11:19:42 -0500 Subject: [PATCH 102/215] Add "Copy Extension ID" action to extension card dropdown (#21395) Adds a new "Copy Extension ID" action to the dropdown of remote extension cards in the extensions list UI. Would have liked for it to be a context menu where you could click anywhere on the card, but couldn't figure out how to integrate that with the existing setup. I've been missing this from VSCode's extension panel, which allows this on right click: ![CleanShot 2024-12-01 at 22 03 14](https://github.com/user-attachments/assets/64796f96-1a37-4ba2-bfe1-971b939aa50a) This is useful if you, say, want to add some extensions to https://zed.dev/docs/configuring-zed#auto-install-extensions, where you need the IDs. Release Notes: - Added "Copy Extension ID" action to extension card dropdown --------- Co-authored-by: Marshall Bowers --- crates/extensions_ui/src/extensions_ui.rs | 24 +++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index eaffdafa41..aef99e6167 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -14,7 +14,7 @@ use editor::{Editor, EditorElement, EditorStyle}; use extension_host::{ExtensionManifest, ExtensionOperation, ExtensionStore}; use fuzzy::{match_strings, StringMatchCandidate}; use gpui::{ - actions, uniform_list, Action, AppContext, EventEmitter, Flatten, FocusableView, + actions, uniform_list, Action, AppContext, ClipboardItem, EventEmitter, Flatten, FocusableView, InteractiveElement, KeyContext, ParentElement, Render, Styled, Task, TextStyle, UniformListScrollHandle, View, ViewContext, VisualContext, WeakView, WindowContext, }; @@ -637,13 +637,21 @@ impl ExtensionsPage { cx: &mut WindowContext, ) -> View { let context_menu = ContextMenu::build(cx, |context_menu, cx| { - context_menu.entry( - "Install Another Version...", - None, - cx.handler_for(this, move |this, cx| { - this.show_extension_version_list(extension_id.clone(), cx) - }), - ) + context_menu + .entry( + "Install Another Version...", + None, + cx.handler_for(this, { + let extension_id = extension_id.clone(); + move |this, cx| this.show_extension_version_list(extension_id.clone(), cx) + }), + ) + .entry("Copy Extension ID", None, { + let extension_id = extension_id.clone(); + move |cx| { + cx.write_to_clipboard(ClipboardItem::new_string(extension_id.to_string())); + } + }) }); context_menu From f795ce9623cade05e7ba361632aea3b00d062f65 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 2 Dec 2024 15:01:09 -0300 Subject: [PATCH 103/215] Add language icons to the language selector (#21298) Closes https://github.com/zed-industries/zed/issues/21290 This is a first attempt to show the language icons to the selector. Ideally, I wouldn't like to have yet another place mapping extensions to icons, as we already have the `file_types.json` file doing that, but I'm not so sure how to pull from it yet. Maybe in a future pass we'll improve this and make it more solid. Screenshot 2024-11-28 at 16 10 27 Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- Cargo.lock | 3 + assets/icons/file_icons/diff.svg | 5 ++ assets/icons/file_icons/file_types.json | 6 ++ crates/extension/src/extension_host_proxy.rs | 4 +- crates/extension_host/src/extension_host.rs | 3 + .../src/extension_store_test.rs | 2 + crates/extension_host/src/headless_host.rs | 1 + crates/file_finder/src/file_finder.rs | 2 +- crates/language/src/language.rs | 8 ++ crates/language/src/language_registry.rs | 15 ++-- .../src/language_extension.rs | 3 +- crates/language_selector/Cargo.toml | 3 + .../src/language_selector.rs | 76 ++++++++++++++++--- crates/languages/src/jsdoc/config.toml | 1 + crates/languages/src/lib.rs | 4 + crates/languages/src/regex/config.toml | 1 + 16 files changed, 119 insertions(+), 18 deletions(-) create mode 100644 assets/icons/file_icons/diff.svg diff --git a/Cargo.lock b/Cargo.lock index 7768dac710..e3bdc89f5f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6709,11 +6709,14 @@ version = "0.1.0" dependencies = [ "anyhow", "editor", + "file_finder", + "file_icons", "fuzzy", "gpui", "language", "picker", "project", + "settings", "ui", "util", "workspace", diff --git a/assets/icons/file_icons/diff.svg b/assets/icons/file_icons/diff.svg new file mode 100644 index 0000000000..07c46f1799 --- /dev/null +++ b/assets/icons/file_icons/diff.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/icons/file_icons/file_types.json b/assets/icons/file_icons/file_types.json index 5e927369d3..89da63ddda 100644 --- a/assets/icons/file_icons/file_types.json +++ b/assets/icons/file_icons/file_types.json @@ -34,6 +34,7 @@ "dat": "storage", "db": "storage", "dbf": "storage", + "diff": "diff", "dll": "storage", "doc": "document", "docx": "document", @@ -112,6 +113,7 @@ "mkv": "video", "ml": "ocaml", "mli": "ocaml", + "mod": "go", "mov": "video", "mp3": "audio", "mp4": "video", @@ -185,6 +187,7 @@ "wmv": "video", "woff": "font", "woff2": "font", + "work": "go", "wv": "audio", "xls": "document", "xlsx": "document", @@ -239,6 +242,9 @@ "default": { "icon": "icons/file_icons/file.svg" }, + "diff": { + "icon": "icons/file_icons/diff.svg" + }, "docker": { "icon": "icons/file_icons/docker.svg" }, diff --git a/crates/extension/src/extension_host_proxy.rs b/crates/extension/src/extension_host_proxy.rs index 8909a6082d..3fa35597a8 100644 --- a/crates/extension/src/extension_host_proxy.rs +++ b/crates/extension/src/extension_host_proxy.rs @@ -159,6 +159,7 @@ pub trait ExtensionLanguageProxy: Send + Sync + 'static { language: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + Send + Sync + 'static>, ); @@ -175,13 +176,14 @@ impl ExtensionLanguageProxy for ExtensionHostProxy { language: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + Send + Sync + 'static>, ) { let Some(proxy) = self.language_proxy.read().clone() else { return; }; - proxy.register_language(language, grammar, matcher, load) + proxy.register_language(language, grammar, matcher, hidden, load) } fn remove_languages( diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index aab5c258f5..7ceb1fa714 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -162,6 +162,7 @@ pub struct ExtensionIndexLanguageEntry { pub extension: Arc, pub path: PathBuf, pub matcher: LanguageMatcher, + pub hidden: bool, pub grammar: Option>, } @@ -1097,6 +1098,7 @@ impl ExtensionStore { language_name.clone(), language.grammar.clone(), language.matcher.clone(), + language.hidden, Arc::new(move || { let config = std::fs::read_to_string(language_path.join("config.toml"))?; let config: LanguageConfig = ::toml::from_str(&config)?; @@ -1324,6 +1326,7 @@ impl ExtensionStore { extension: extension_id.clone(), path: relative_path, matcher: config.matcher, + hidden: config.hidden, grammar: config.grammar, }, ); diff --git a/crates/extension_host/src/extension_store_test.rs b/crates/extension_host/src/extension_store_test.rs index 1359b5b202..8b5a2a7821 100644 --- a/crates/extension_host/src/extension_store_test.rs +++ b/crates/extension_host/src/extension_store_test.rs @@ -203,6 +203,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { extension: "zed-ruby".into(), path: "languages/erb".into(), grammar: Some("embedded_template".into()), + hidden: false, matcher: LanguageMatcher { path_suffixes: vec!["erb".into()], first_line_pattern: None, @@ -215,6 +216,7 @@ async fn test_extension_store(cx: &mut TestAppContext) { extension: "zed-ruby".into(), path: "languages/ruby".into(), grammar: Some("ruby".into()), + hidden: false, matcher: LanguageMatcher { path_suffixes: vec!["rb".into()], first_line_pattern: None, diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index 19a574b9d4..687f05db47 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -156,6 +156,7 @@ impl HeadlessExtensionStore { config.name.clone(), None, config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 6a758211f8..62e0818b74 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1,7 +1,7 @@ #[cfg(test)] mod file_finder_tests; -mod file_finder_settings; +pub mod file_finder_settings; mod new_path_prompt; mod open_path_prompt; diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 2725122990..e9590448f8 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -129,6 +129,10 @@ pub static PLAIN_TEXT: LazyLock> = LazyLock::new(|| { LanguageConfig { name: "Plain Text".into(), soft_wrap: Some(SoftWrap::EditorWidth), + matcher: LanguageMatcher { + path_suffixes: vec!["txt".to_owned()], + first_line_pattern: None, + }, ..Default::default() }, None, @@ -1418,6 +1422,10 @@ impl Language { pub fn prettier_parser_name(&self) -> Option<&str> { self.config.prettier_parser_name.as_deref() } + + pub fn config(&self) -> &LanguageConfig { + &self.config + } } impl LanguageScope { diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index d8c2b0d510..e5f7815351 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -130,6 +130,7 @@ pub struct AvailableLanguage { name: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + 'static + Send + Sync>, loaded: bool, } @@ -142,6 +143,9 @@ impl AvailableLanguage { pub fn matcher(&self) -> &LanguageMatcher { &self.matcher } + pub fn hidden(&self) -> bool { + self.hidden + } } enum AvailableGrammar { @@ -288,6 +292,7 @@ impl LanguageRegistry { config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -436,6 +441,7 @@ impl LanguageRegistry { name: LanguageName, grammar_name: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + 'static + Send + Sync>, ) { let state = &mut *self.state.write(); @@ -455,6 +461,7 @@ impl LanguageRegistry { grammar: grammar_name, matcher, load, + hidden, loaded: false, }); state.version += 1; @@ -522,6 +529,7 @@ impl LanguageRegistry { name: language.name(), grammar: language.config.grammar.clone(), matcher: language.config.matcher.clone(), + hidden: language.config.hidden, load: Arc::new(|| Err(anyhow!("already loaded"))), loaded: true, }); @@ -590,15 +598,12 @@ impl LanguageRegistry { async move { rx.await? } } - pub fn available_language_for_name( - self: &Arc, - name: &LanguageName, - ) -> Option { + pub fn available_language_for_name(self: &Arc, name: &str) -> Option { let state = self.state.read(); state .available_languages .iter() - .find(|l| &l.name == name) + .find(|l| l.name.0.as_ref() == name) .cloned() } diff --git a/crates/language_extension/src/language_extension.rs b/crates/language_extension/src/language_extension.rs index d8ffc71d7c..59951c87e4 100644 --- a/crates/language_extension/src/language_extension.rs +++ b/crates/language_extension/src/language_extension.rs @@ -34,10 +34,11 @@ impl ExtensionLanguageProxy for LanguageServerRegistryProxy { language: LanguageName, grammar: Option>, matcher: LanguageMatcher, + hidden: bool, load: Arc Result + Send + Sync + 'static>, ) { self.language_registry - .register_language(language, grammar, matcher, load); + .register_language(language, grammar, matcher, hidden, load); } fn remove_languages( diff --git a/crates/language_selector/Cargo.toml b/crates/language_selector/Cargo.toml index b864ffc31f..276e9b0d42 100644 --- a/crates/language_selector/Cargo.toml +++ b/crates/language_selector/Cargo.toml @@ -15,11 +15,14 @@ doctest = false [dependencies] anyhow.workspace = true editor.workspace = true +file_finder.workspace = true +file_icons.workspace = true fuzzy.workspace = true gpui.workspace = true language.workspace = true picker.workspace = true project.workspace = true +settings.workspace = true ui.workspace = true util.workspace = true workspace.workspace = true diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 489f6fd141..60da837baa 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -3,15 +3,18 @@ mod active_buffer_language; pub use active_buffer_language::ActiveBufferLanguage; use anyhow::anyhow; use editor::Editor; +use file_finder::file_finder_settings::FileFinderSettings; +use file_icons::FileIcons; use fuzzy::{match_strings, StringMatch, StringMatchCandidate}; use gpui::{ actions, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model, ParentElement, Render, Styled, View, ViewContext, VisualContext, WeakView, }; -use language::{Buffer, LanguageRegistry}; +use language::{Buffer, LanguageMatcher, LanguageName, LanguageRegistry}; use picker::{Picker, PickerDelegate}; use project::Project; -use std::sync::Arc; +use settings::Settings; +use std::{ops::Not as _, path::Path, sync::Arc}; use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing}; use util::ResultExt; use workspace::{ModalView, Workspace}; @@ -102,7 +105,13 @@ impl LanguageSelectorDelegate { .language_names() .into_iter() .enumerate() - .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name)) + .filter_map(|(candidate_id, name)| { + language_registry + .available_language_for_name(&name)? + .hidden() + .not() + .then(|| StringMatchCandidate::new(candidate_id, name)) + }) .collect::>(); Self { @@ -115,13 +124,64 @@ impl LanguageSelectorDelegate { selected_index: 0, } } + + fn language_data_for_match( + &self, + mat: &StringMatch, + cx: &AppContext, + ) -> (String, Option) { + let mut label = mat.string.clone(); + let buffer_language = self.buffer.read(cx).language(); + let need_icon = FileFinderSettings::get_global(cx).file_icons; + if let Some(buffer_language) = buffer_language { + let buffer_language_name = buffer_language.name(); + if buffer_language_name.0.as_ref() == mat.string.as_str() { + label.push_str(" (current)"); + let icon = need_icon + .then(|| self.language_icon(&buffer_language.config().matcher, cx)) + .flatten(); + return (label, icon); + } + } + + if need_icon { + let language_name = LanguageName::new(mat.string.as_str()); + match self + .language_registry + .available_language_for_name(&language_name.0) + { + Some(available_language) => { + let icon = self.language_icon(available_language.matcher(), cx); + (label, icon) + } + None => (label, None), + } + } else { + (label, None) + } + } + + fn language_icon(&self, matcher: &LanguageMatcher, cx: &AppContext) -> Option { + matcher + .path_suffixes + .iter() + .find_map(|extension| { + if extension.contains('.') { + None + } else { + FileIcons::get_icon(Path::new(&format!("file.{extension}")), cx) + } + }) + .map(Icon::from_path) + .map(|icon| icon.color(Color::Muted)) + } } impl PickerDelegate for LanguageSelectorDelegate { type ListItem = ListItem; fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { - "Select a language...".into() + "Select a language…".into() } fn match_count(&self) -> usize { @@ -215,17 +275,13 @@ impl PickerDelegate for LanguageSelectorDelegate { 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.map(|n| n.0).as_deref() == Some(mat.string.as_str()) { - label.push_str(" (current)"); - } - + let (label, language_icon) = self.language_data_for_match(mat, cx); Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) .selected(selected) + .start_slot::(language_icon) .child(HighlightedLabel::new(label, mat.positions.clone())), ) } diff --git a/crates/languages/src/jsdoc/config.toml b/crates/languages/src/jsdoc/config.toml index 444e657a38..0aa0d361bd 100644 --- a/crates/languages/src/jsdoc/config.toml +++ b/crates/languages/src/jsdoc/config.toml @@ -5,3 +5,4 @@ brackets = [ { start = "{", end = "}", close = true, newline = false }, { start = "[", end = "]", close = true, newline = false }, ] +hidden = true diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 776d47a5f7..5ba6f5c034 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -62,6 +62,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -83,6 +84,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -104,6 +106,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), @@ -125,6 +128,7 @@ pub fn init(languages: Arc, node_runtime: NodeRuntime, cx: &mu config.name.clone(), config.grammar.clone(), config.matcher.clone(), + config.hidden, Arc::new(move || { Ok(LoadedLanguage { config: config.clone(), diff --git a/crates/languages/src/regex/config.toml b/crates/languages/src/regex/config.toml index d0938024d6..85f2e370d6 100644 --- a/crates/languages/src/regex/config.toml +++ b/crates/languages/src/regex/config.toml @@ -6,3 +6,4 @@ brackets = [ { start = "{", end = "}", close = true, newline = false }, { start = "[", end = "]", close = true, newline = false }, ] +hidden = true From 4e12f0580a37a0ef615dbd74d40a81d60d3f1494 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 2 Dec 2024 10:20:27 -0800 Subject: [PATCH 104/215] Fix dismissing the IME viewer with escape (#21413) Co-Authored-By: Richard Feldman Closes #21392 Release Notes: - Fixed dismissing the macOS IME menu with escape when no marked text was present --------- Co-authored-by: Richard Feldman --- crates/gpui/src/platform/mac/window.rs | 27 ++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index ce9a4c05bf..f430af7495 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -331,6 +331,7 @@ struct MacWindowState { traffic_light_position: Option>, previous_modifiers_changed_event: Option, keystroke_for_do_command: Option, + do_command_handled: Option, external_files_dragged: bool, // Whether the next left-mouse click is also the focusing click. first_mouse: bool, @@ -609,6 +610,7 @@ impl MacWindow { .and_then(|titlebar| titlebar.traffic_light_position), previous_modifiers_changed_event: None, keystroke_for_do_command: None, + do_command_handled: None, external_files_dragged: false, first_mouse: false, fullscreen_restore_bounds: Bounds::default(), @@ -1251,14 +1253,22 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // otherwise we only send to the input handler if we don't have a matching binding. // The input handler may call `do_command_by_selector` if it doesn't know how to handle // a key. If it does so, it will return YES so we won't send the key twice. - if is_composing || event.keystroke.key.is_empty() { - window_state.as_ref().lock().keystroke_for_do_command = Some(event.keystroke.clone()); + if is_composing || event.keystroke.key_char.is_none() { + { + let mut lock = window_state.as_ref().lock(); + lock.keystroke_for_do_command = Some(event.keystroke.clone()); + lock.do_command_handled.take(); + drop(lock); + } + let handled: BOOL = unsafe { let input_context: id = msg_send![this, inputContext]; msg_send![input_context, handleEvent: native_event] }; window_state.as_ref().lock().keystroke_for_do_command.take(); - if handled == YES { + if let Some(handled) = window_state.as_ref().lock().do_command_handled.take() { + return handled as BOOL; + } else if handled == YES { return YES; } @@ -1377,6 +1387,14 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { }; match &event { + PlatformInput::MouseDown(_) => { + drop(lock); + unsafe { + let input_context: id = msg_send![this, inputContext]; + msg_send![input_context, handleEvent: native_event] + } + lock = window_state.as_ref().lock(); + } PlatformInput::MouseMove( event @ MouseMoveEvent { pressed_button: Some(_), @@ -1790,10 +1808,11 @@ extern "C" fn do_command_by_selector(this: &Object, _: Sel, _: Sel) { drop(lock); if let Some((keystroke, mut callback)) = keystroke.zip(event_callback.as_mut()) { - (callback)(PlatformInput::KeyDown(KeyDownEvent { + let handled = (callback)(PlatformInput::KeyDown(KeyDownEvent { keystroke, is_held: false, })); + state.as_ref().lock().do_command_handled = Some(!handled.propagate); } state.as_ref().lock().event_callback = event_callback; From 7c408247835085c6c30d6ef69ef45c1a9e9c6c1f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 2 Dec 2024 10:46:14 -0800 Subject: [PATCH 105/215] Fix macOS IME overlay positioning (#21416) Release Notes: - Improved positioning of macOS IME overlay --------- Co-authored-by: Richard Feldman --- crates/editor/src/editor.rs | 3 ++- crates/gpui/src/platform/mac/window.rs | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d5d96436e8..51a90a9206 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -14611,7 +14611,8 @@ impl ViewInputHandler for Editor { let start = OffsetUtf16(range_utf16.start).to_display_point(&snapshot); let x = snapshot.x_for_display_point(start, &text_layout_details) - scroll_left - + self.gutter_dimensions.width; + + self.gutter_dimensions.width + + self.gutter_dimensions.margin; let y = line_height * (start.row().as_f32() - scroll_position.y); Some(Bounds { diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index f430af7495..12a332e9bc 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1701,7 +1701,10 @@ extern "C" fn first_rect_for_character_range( let lock = state.lock(); let mut frame = NSWindow::frame(lock.native_window); let content_layout_rect: CGRect = msg_send![lock.native_window, contentLayoutRect]; - frame.origin.y -= frame.size.height - content_layout_rect.size.height; + let style_mask: NSWindowStyleMask = msg_send![lock.native_window, styleMask]; + if !style_mask.contains(NSWindowStyleMask::NSFullSizeContentViewWindowMask) { + frame.origin.y -= frame.size.height - content_layout_rect.size.height; + } frame }; with_input_handler(this, |input_handler| { From dbe41823d9f5e720d35b7f40573296bb8cfe455d Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 2 Dec 2024 20:46:28 +0200 Subject: [PATCH 106/215] Use proper terminal item for splitting context (#21415) Closes https://github.com/zed-industries/zed/issues/21411 Release Notes: - N/A --- crates/terminal_view/src/terminal_panel.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 532d5d9040..b3804354c4 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -131,8 +131,8 @@ impl TerminalPanel { terminal_pane.update(cx, |pane, cx| { pane.set_render_tab_bar_buttons(cx, move |pane, cx| { let split_context = pane - .items() - .find_map(|item| item.downcast::()) + .active_item() + .and_then(|item| item.downcast::()) .map(|terminal_view| terminal_view.read(cx).focus_handle.clone()); if !pane.has_focus(cx) && !pane.context_menu_focused(cx) { return (None, None); From 95a047c11b8bddf8edbfc4e932474925c3d9e010 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 2 Dec 2024 19:53:51 +0100 Subject: [PATCH 107/215] tasks/rust: Add support for running examples as binary targets (#21412) Closes #21044 Release Notes: - Added support for running Rust examples as tasks. --- crates/languages/src/rust.rs | 94 ++++++++++++++++++++++++++++++------ 1 file changed, 80 insertions(+), 14 deletions(-) diff --git a/crates/languages/src/rust.rs b/crates/languages/src/rust.rs index 25cddae5a6..274d96f5fa 100644 --- a/crates/languages/src/rust.rs +++ b/crates/languages/src/rust.rs @@ -10,6 +10,7 @@ pub use language::*; use lsp::{LanguageServerBinary, LanguageServerName}; use regex::Regex; use smol::fs::{self}; +use std::fmt::Display; use std::{ any::Any, borrow::Cow, @@ -444,6 +445,10 @@ const RUST_PACKAGE_TASK_VARIABLE: VariableName = const RUST_BIN_NAME_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("RUST_BIN_NAME")); +/// The bin kind (bin/example) corresponding to the current file in Cargo.toml +const RUST_BIN_KIND_TASK_VARIABLE: VariableName = + VariableName::Custom(Cow::Borrowed("RUST_BIN_KIND")); + const RUST_MAIN_FUNCTION_TASK_VARIABLE: VariableName = VariableName::Custom(Cow::Borrowed("_rust_main_function_end")); @@ -469,12 +474,16 @@ impl ContextProvider for RustContextProvider { .is_some(); if is_main_function { - if let Some((package_name, bin_name)) = local_abs_path.and_then(|path| { + if let Some(target) = local_abs_path.and_then(|path| { package_name_and_bin_name_from_abs_path(path, project_env.as_ref()) }) { return Task::ready(Ok(TaskVariables::from_iter([ - (RUST_PACKAGE_TASK_VARIABLE.clone(), package_name), - (RUST_BIN_NAME_TASK_VARIABLE.clone(), bin_name), + (RUST_PACKAGE_TASK_VARIABLE.clone(), target.package_name), + (RUST_BIN_NAME_TASK_VARIABLE.clone(), target.target_name), + ( + RUST_BIN_KIND_TASK_VARIABLE.clone(), + target.target_kind.to_string(), + ), ]))); } } @@ -568,8 +577,9 @@ impl ContextProvider for RustContextProvider { }, TaskTemplate { label: format!( - "cargo run -p {} --bin {}", + "cargo run -p {} --{} {}", RUST_PACKAGE_TASK_VARIABLE.template_value(), + RUST_BIN_KIND_TASK_VARIABLE.template_value(), RUST_BIN_NAME_TASK_VARIABLE.template_value(), ), command: "cargo".into(), @@ -577,7 +587,7 @@ impl ContextProvider for RustContextProvider { "run".into(), "-p".into(), RUST_PACKAGE_TASK_VARIABLE.template_value(), - "--bin".into(), + format!("--{}", RUST_BIN_KIND_TASK_VARIABLE.template_value()), RUST_BIN_NAME_TASK_VARIABLE.template_value(), ], cwd: Some("$ZED_DIRNAME".to_owned()), @@ -635,10 +645,42 @@ struct CargoTarget { src_path: String, } +#[derive(Debug, PartialEq)] +enum TargetKind { + Bin, + Example, +} + +impl Display for TargetKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TargetKind::Bin => write!(f, "bin"), + TargetKind::Example => write!(f, "example"), + } + } +} + +impl TryFrom<&str> for TargetKind { + type Error = (); + fn try_from(value: &str) -> Result { + match value { + "bin" => Ok(Self::Bin), + "example" => Ok(Self::Example), + _ => Err(()), + } + } +} +/// Which package and binary target are we in? +struct TargetInfo { + package_name: String, + target_name: String, + target_kind: TargetKind, +} + fn package_name_and_bin_name_from_abs_path( abs_path: &Path, project_env: Option<&HashMap>, -) -> Option<(String, String)> { +) -> Option { let mut command = util::command::new_std_command("cargo"); if let Some(envs) = project_env { command.envs(envs); @@ -656,10 +698,14 @@ fn package_name_and_bin_name_from_abs_path( let metadata: CargoMetadata = serde_json::from_slice(&output).log_err()?; retrieve_package_id_and_bin_name_from_metadata(metadata, abs_path).and_then( - |(package_id, bin_name)| { + |(package_id, bin_name, target_kind)| { let package_name = package_name_from_pkgid(&package_id); - package_name.map(|package_name| (package_name.to_owned(), bin_name)) + package_name.map(|package_name| TargetInfo { + package_name: package_name.to_owned(), + target_name: bin_name, + target_kind, + }) }, ) } @@ -667,13 +713,19 @@ fn package_name_and_bin_name_from_abs_path( fn retrieve_package_id_and_bin_name_from_metadata( metadata: CargoMetadata, abs_path: &Path, -) -> Option<(String, String)> { +) -> Option<(String, String, TargetKind)> { for package in metadata.packages { for target in package.targets { - let is_bin = target.kind.iter().any(|kind| kind == "bin"); + let Some(bin_kind) = target + .kind + .iter() + .find_map(|kind| TargetKind::try_from(kind.as_ref()).ok()) + else { + continue; + }; let target_path = PathBuf::from(target.src_path); - if target_path == abs_path && is_bin { - return Some((package.id, target.name)); + if target_path == abs_path { + return Some((package.id, target.name, bin_kind)); } } } @@ -1066,7 +1118,11 @@ mod tests { ( r#"{"packages":[{"id":"path+file:///path/to/zed/crates/zed#0.131.0","targets":[{"name":"zed","kind":["bin"],"src_path":"/path/to/zed/src/main.rs"}]}]}"#, "/path/to/zed/src/main.rs", - Some(("path+file:///path/to/zed/crates/zed#0.131.0", "zed")), + Some(( + "path+file:///path/to/zed/crates/zed#0.131.0", + "zed", + TargetKind::Bin, + )), ), ( r#"{"packages":[{"id":"path+file:///path/to/custom-package#my-custom-package@0.1.0","targets":[{"name":"my-custom-bin","kind":["bin"],"src_path":"/path/to/custom-package/src/main.rs"}]}]}"#, @@ -1074,6 +1130,16 @@ mod tests { Some(( "path+file:///path/to/custom-package#my-custom-package@0.1.0", "my-custom-bin", + TargetKind::Bin, + )), + ), + ( + r#"{"packages":[{"id":"path+file:///path/to/custom-package#my-custom-package@0.1.0","targets":[{"name":"my-custom-bin","kind":["example"],"src_path":"/path/to/custom-package/src/main.rs"}]}]}"#, + "/path/to/custom-package/src/main.rs", + Some(( + "path+file:///path/to/custom-package#my-custom-package@0.1.0", + "my-custom-bin", + TargetKind::Example, )), ), ( @@ -1088,7 +1154,7 @@ mod tests { assert_eq!( retrieve_package_id_and_bin_name_from_metadata(metadata, absolute_path), - expected.map(|(pkgid, bin)| (pkgid.to_owned(), bin.to_owned())) + expected.map(|(pkgid, name, kind)| (pkgid.to_owned(), name.to_owned(), kind)) ); } } From f32ffcf5bb005d18bef320226a69edad062e4fec Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 2 Dec 2024 19:56:52 +0100 Subject: [PATCH 108/215] workspace: Sanitize pinned tab count before usage (#21417) Fixes all sorts of panics around usage of incorrect pinned tab count that has been fixed in app itself, yet persists in user db. Closes #ISSUE Release Notes: - N/A --- crates/workspace/src/pane.rs | 2 +- crates/workspace/src/persistence/model.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 83cc911a91..fe6b08fd4a 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1890,7 +1890,7 @@ impl Pane { fn unpin_tab_at(&mut self, ix: usize, cx: &mut ViewContext<'_, Self>) { maybe!({ let pane = cx.view().clone(); - self.pinned_tab_count = self.pinned_tab_count.checked_sub(1).unwrap(); + self.pinned_tab_count = self.pinned_tab_count.checked_sub(1)?; let destination_index = self.pinned_tab_count; let id = self.item_for_index(ix)?.item_id(); diff --git a/crates/workspace/src/persistence/model.rs b/crates/workspace/src/persistence/model.rs index a2510b8bec..7a368ee441 100644 --- a/crates/workspace/src/persistence/model.rs +++ b/crates/workspace/src/persistence/model.rs @@ -473,7 +473,7 @@ impl SerializedPane { })?; } pane.update(cx, |pane, _| { - pane.set_pinned_count(self.pinned_count); + pane.set_pinned_count(self.pinned_count.min(items.len())); })?; anyhow::Ok(items) From b88daae67b4c6af1f80b7c7c091f50d313410d84 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 2 Dec 2024 15:01:18 -0500 Subject: [PATCH 109/215] assistant2: Add support for using tools provided by context servers (#21418) This PR adds support to Assistant 2 for using tools provided by context servers. As part of this I introduced a new `ThreadStore`. Release Notes: - N/A --------- Co-authored-by: Cole --- Cargo.lock | 3 + crates/assistant2/Cargo.toml | 3 + crates/assistant2/src/assistant.rs | 1 + crates/assistant2/src/assistant_panel.rs | 20 +++- crates/assistant2/src/thread_store.rs | 114 +++++++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 crates/assistant2/src/thread_store.rs diff --git a/Cargo.lock b/Cargo.lock index e3bdc89f5f..0594b5c9b5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -458,12 +458,15 @@ dependencies = [ "assistant_tool", "collections", "command_palette_hooks", + "context_server", "editor", "feature_flags", "futures 0.3.31", "gpui", "language_model", "language_model_selector", + "log", + "project", "proto", "serde", "serde_json", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index ca563b05c8..ff49801c46 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -17,12 +17,15 @@ anyhow.workspace = true assistant_tool.workspace = true collections.workspace = true command_palette_hooks.workspace = true +context_server.workspace = true editor.workspace = true feature_flags.workspace = true futures.workspace = true gpui.workspace = true language_model.workspace = true language_model_selector.workspace = true +log.workspace = true +project.workspace = true proto.workspace = true settings.workspace = true serde.workspace = true diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 1b33e27928..8ef4a1d9dc 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,6 +1,7 @@ mod assistant_panel; mod message_editor; mod thread; +mod thread_store; use command_palette_hooks::CommandPaletteFilter; use feature_flags::{Assistant2FeatureFlag, FeatureFlagAppExt}; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index bf457d6c71..7d8405dc78 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -14,6 +14,7 @@ use workspace::Workspace; use crate::message_editor::MessageEditor; use crate::thread::{Message, Thread, ThreadEvent}; +use crate::thread_store::ThreadStore; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { @@ -29,6 +30,8 @@ pub fn init(cx: &mut AppContext) { pub struct AssistantPanel { workspace: WeakView, + #[allow(unused)] + thread_store: Model, thread: Model, message_editor: View, tools: Arc, @@ -42,13 +45,25 @@ impl AssistantPanel { ) -> Task>> { cx.spawn(|mut cx| async move { let tools = Arc::new(ToolWorkingSet::default()); + let thread_store = workspace + .update(&mut cx, |workspace, cx| { + let project = workspace.project().clone(); + ThreadStore::new(project, tools.clone(), cx) + })? + .await?; + workspace.update(&mut cx, |workspace, cx| { - cx.new_view(|cx| Self::new(workspace, tools, cx)) + cx.new_view(|cx| Self::new(workspace, thread_store, tools, cx)) }) }) } - fn new(workspace: &Workspace, tools: Arc, cx: &mut ViewContext) -> Self { + fn new( + workspace: &Workspace, + thread_store: Model, + tools: Arc, + cx: &mut ViewContext, + ) -> Self { let thread = cx.new_model(|cx| Thread::new(tools.clone(), cx)); let subscriptions = vec![ cx.observe(&thread, |_, _, cx| cx.notify()), @@ -57,6 +72,7 @@ impl AssistantPanel { Self { workspace: workspace.weak_handle(), + thread_store, thread: thread.clone(), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), tools, diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs new file mode 100644 index 0000000000..99f90eace8 --- /dev/null +++ b/crates/assistant2/src/thread_store.rs @@ -0,0 +1,114 @@ +use std::sync::Arc; + +use anyhow::Result; +use assistant_tool::{ToolId, ToolWorkingSet}; +use collections::HashMap; +use context_server::manager::ContextServerManager; +use context_server::{ContextServerFactoryRegistry, ContextServerTool}; +use gpui::{prelude::*, AppContext, Model, ModelContext, Task}; +use project::Project; +use util::ResultExt as _; + +pub struct ThreadStore { + #[allow(unused)] + project: Model, + tools: Arc, + context_server_manager: Model, + context_server_tool_ids: HashMap, Vec>, +} + +impl ThreadStore { + pub fn new( + project: Model, + tools: Arc, + cx: &mut AppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let this = cx.new_model(|cx: &mut ModelContext| { + let context_server_factory_registry = + ContextServerFactoryRegistry::default_global(cx); + let context_server_manager = cx.new_model(|cx| { + ContextServerManager::new(context_server_factory_registry, project.clone(), cx) + }); + + let this = Self { + project, + tools, + context_server_manager, + context_server_tool_ids: HashMap::default(), + }; + this.register_context_server_handlers(cx); + + this + })?; + + Ok(this) + }) + } + + fn register_context_server_handlers(&self, cx: &mut ModelContext) { + cx.subscribe( + &self.context_server_manager.clone(), + Self::handle_context_server_event, + ) + .detach(); + } + + fn handle_context_server_event( + &mut self, + context_server_manager: Model, + event: &context_server::manager::Event, + cx: &mut ModelContext, + ) { + let tool_working_set = self.tools.clone(); + match event { + context_server::manager::Event::ServerStarted { server_id } => { + if let Some(server) = context_server_manager.read(cx).get_server(server_id) { + let context_server_manager = context_server_manager.clone(); + cx.spawn({ + let server = server.clone(); + let server_id = server_id.clone(); + |this, mut cx| async move { + let Some(protocol) = server.client() else { + return; + }; + + if protocol.capable(context_server::protocol::ServerCapability::Tools) { + if let Some(tools) = protocol.list_tools().await.log_err() { + let tool_ids = tools + .tools + .into_iter() + .map(|tool| { + log::info!( + "registering context server tool: {:?}", + tool.name + ); + tool_working_set.insert(Arc::new( + ContextServerTool::new( + context_server_manager.clone(), + server.id(), + tool, + ), + )) + }) + .collect::>(); + + this.update(&mut cx, |this, _cx| { + this.context_server_tool_ids.insert(server_id, tool_ids); + }) + .log_err(); + } + } + } + }) + .detach(); + } + } + context_server::manager::Event::ServerStopped { server_id } => { + if let Some(tool_ids) = self.context_server_tool_ids.remove(server_id) { + tool_working_set.remove(&tool_ids); + } + } + } + } +} From 59dc6cf523678f7a2ce0883fd2258c4a1af838c1 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 2 Dec 2024 21:03:31 +0100 Subject: [PATCH 110/215] toolchains: Run listing tasks on background thread (#21414) Potentially fixes #21404 This is a speculative fix, as while I was trying to repro this issue I've noticed that introducing artificial delays in ToolchainLister::list could impact apps responsiveness. These delays were essentially there to stimulate PET taking a while to find venvs. Release Notes: - Improved app responsiveness in environments with multiple Python virtual environments --- crates/language/src/toolchain.rs | 2 +- crates/languages/src/python.rs | 2 +- crates/project/src/toolchain_store.rs | 14 ++++++++------ 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index fe8936db08..13703d81a7 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -24,7 +24,7 @@ pub struct Toolchain { pub as_json: serde_json::Value, } -#[async_trait(?Send)] +#[async_trait] pub trait ToolchainLister: Send + Sync { async fn list( &self, diff --git a/crates/languages/src/python.rs b/crates/languages/src/python.rs index 8736a12942..ec7ddde61d 100644 --- a/crates/languages/src/python.rs +++ b/crates/languages/src/python.rs @@ -536,7 +536,7 @@ fn env_priority(kind: Option) -> usize { } } -#[async_trait(?Send)] +#[async_trait] impl ToolchainLister for PythonToolchainProvider { async fn list( &self, diff --git a/crates/project/src/toolchain_store.rs b/crates/project/src/toolchain_store.rs index 4d4c32d745..71228d96a4 100644 --- a/crates/project/src/toolchain_store.rs +++ b/crates/project/src/toolchain_store.rs @@ -311,12 +311,14 @@ impl LocalToolchainStore { }) .ok()? .await; - let language = registry.language_for_name(&language_name.0).await.ok()?; - let toolchains = language - .toolchain_lister()? - .list(root.to_path_buf(), project_env) - .await; - Some(toolchains) + + cx.background_executor() + .spawn(async move { + let language = registry.language_for_name(&language_name.0).await.ok()?; + let toolchains = language.toolchain_lister()?; + Some(toolchains.list(root.to_path_buf(), project_env).await) + }) + .await }) } pub(crate) fn active_toolchain( From 72afe684b8248a8662bb731694e79d014cca2169 Mon Sep 17 00:00:00 2001 From: yoleuh Date: Mon, 2 Dec 2024 16:48:20 -0500 Subject: [PATCH 111/215] assistant: Use a smaller icon for the "New Chat" button (#21425) Assistant new chat icon is slightly larger than editor pane new icon. Changes: Adds `IconSize::Small` to assistant default size new chat icon, not really noticeable, but matches the new icon in editor pane, and the assistant dropdown menu that have icon size small. |old|new| |---|---| |![image](https://github.com/user-attachments/assets/cbef5054-a465-4957-9409-b4a73e703363)|![image](https://github.com/user-attachments/assets/baee66ea-76d6-43b4-a4b9-ead34991ff85)| Release Notes: - N/A --- crates/assistant/src/assistant_panel.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/assistant/src/assistant_panel.rs b/crates/assistant/src/assistant_panel.rs index 79e026cb51..109c9c3237 100644 --- a/crates/assistant/src/assistant_panel.rs +++ b/crates/assistant/src/assistant_panel.rs @@ -450,6 +450,7 @@ impl AssistantPanel { .gap(DynamicSpacing::Base02.rems(cx)) .child( IconButton::new("new-chat", IconName::Plus) + .icon_size(IconSize::Small) .on_click( cx.listener(|_, _, cx| { cx.dispatch_action(NewContext.boxed_clone()) From f3140f54d8458980417f0208849ce9254f0f54e4 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 2 Dec 2024 16:54:46 -0500 Subject: [PATCH 112/215] assistant2: Wire up error messages (#21426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR wires up the error messages for Assistant 2 so that they are shown to the user: Screenshot 2024-12-02 at 4 28 02 PM Screenshot 2024-12-02 at 4 29 09 PM Screenshot 2024-12-02 at 4 32 49 PM @danilo-leal I kept the existing UX from Assistant 1, as I didn't see any errors in the design prototype, but we can revisit if another approach would work better. Release Notes: - N/A --- Cargo.lock | 2 + crates/assistant2/Cargo.toml | 2 + crates/assistant2/src/assistant_panel.rs | 160 +++++++++++++++++++- crates/assistant2/src/thread.rs | 56 ++++--- crates/language_model/src/language_model.rs | 2 +- 5 files changed, 194 insertions(+), 28 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0594b5c9b5..7504b8491b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assistant_tool", + "client", "collections", "command_palette_hooks", "context_server", @@ -465,6 +466,7 @@ dependencies = [ "gpui", "language_model", "language_model_selector", + "language_models", "log", "project", "proto", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index ff49801c46..20e8dfbc9a 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -15,6 +15,7 @@ doctest = false [dependencies] anyhow.workspace = true assistant_tool.workspace = true +client.workspace = true collections.workspace = true command_palette_hooks.workspace = true context_server.workspace = true @@ -24,6 +25,7 @@ futures.workspace = true gpui.workspace = true language_model.workspace = true language_model_selector.workspace = true +language_models.workspace = true log.workspace = true project.workspace = true proto.workspace = true diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 7d8405dc78..4e6b6ef227 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -2,9 +2,11 @@ use std::sync::Arc; use anyhow::Result; use assistant_tool::ToolWorkingSet; +use client::zed_urls; use gpui::{ - prelude::*, px, Action, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, - FocusableView, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, WindowContext, + prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, + FocusableView, FontWeight, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, + WindowContext, }; use language_model::{LanguageModelRegistry, Role}; use language_model_selector::LanguageModelSelector; @@ -13,7 +15,7 @@ use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; -use crate::thread::{Message, Thread, ThreadEvent}; +use crate::thread::{Message, Thread, ThreadError, ThreadEvent}; use crate::thread_store::ThreadStore; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; @@ -35,6 +37,7 @@ pub struct AssistantPanel { thread: Model, message_editor: View, tools: Arc, + last_error: Option, _subscriptions: Vec, } @@ -76,6 +79,7 @@ impl AssistantPanel { thread: thread.clone(), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), tools, + last_error: None, _subscriptions: subscriptions, } } @@ -102,6 +106,9 @@ impl AssistantPanel { cx: &mut ViewContext, ) { match event { + ThreadEvent::ShowError(error) => { + self.last_error = Some(error.clone()); + } ThreadEvent::StreamedCompletion => {} ThreadEvent::UsePendingTools => { let pending_tool_uses = self @@ -320,6 +327,152 @@ impl AssistantPanel { ) .child(v_flex().p_1p5().child(Label::new(message.text.clone()))) } + + fn render_last_error(&self, cx: &mut ViewContext) -> Option { + let last_error = self.last_error.as_ref()?; + + Some( + div() + .absolute() + .right_3() + .bottom_12() + .max_w_96() + .py_2() + .px_3() + .elevation_2(cx) + .occlude() + .child(match last_error { + ThreadError::PaymentRequired => self.render_payment_required_error(cx), + ThreadError::MaxMonthlySpendReached => { + self.render_max_monthly_spend_reached_error(cx) + } + ThreadError::Message(error_message) => { + self.render_error_message(error_message, cx) + } + }) + .into_any(), + ) + } + + fn render_payment_required_error(&self, cx: &mut ViewContext) -> AnyElement { + const ERROR_MESSAGE: &str = "Free tier exceeded. Subscribe and add payment to continue using Zed LLMs. You'll be billed at cost for tokens used."; + + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Free Usage Exceeded").weight(FontWeight::MEDIUM)), + ) + .child( + div() + .id("error-message") + .max_h_24() + .overflow_y_scroll() + .child(Label::new(ERROR_MESSAGE)), + ) + .child( + h_flex() + .justify_end() + .mt_1() + .child(Button::new("subscribe", "Subscribe").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + }, + ))) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.notify(); + }, + ))), + ) + .into_any() + } + + fn render_max_monthly_spend_reached_error(&self, cx: &mut ViewContext) -> AnyElement { + const ERROR_MESSAGE: &str = "You have reached your maximum monthly spend. Increase your spend limit to continue using Zed LLMs."; + + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new("Max Monthly Spend Reached").weight(FontWeight::MEDIUM)), + ) + .child( + div() + .id("error-message") + .max_h_24() + .overflow_y_scroll() + .child(Label::new(ERROR_MESSAGE)), + ) + .child( + h_flex() + .justify_end() + .mt_1() + .child( + Button::new("subscribe", "Update Monthly Spend Limit").on_click( + cx.listener(|this, _, cx| { + this.last_error = None; + cx.open_url(&zed_urls::account_url(cx)); + cx.notify(); + }), + ), + ) + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.notify(); + }, + ))), + ) + .into_any() + } + + fn render_error_message( + &self, + error_message: &SharedString, + cx: &mut ViewContext, + ) -> AnyElement { + v_flex() + .gap_0p5() + .child( + h_flex() + .gap_1p5() + .items_center() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child( + Label::new("Error interacting with language model") + .weight(FontWeight::MEDIUM), + ), + ) + .child( + div() + .id("error-message") + .max_h_32() + .overflow_y_scroll() + .child(Label::new(error_message.clone())), + ) + .child( + h_flex() + .justify_end() + .mt_1() + .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( + |this, _, cx| { + this.last_error = None; + cx.notify(); + }, + ))), + ) + .into_any() + } } impl Render for AssistantPanel { @@ -354,5 +507,6 @@ impl Render for AssistantPanel { .border_color(cx.theme().colors().border_variant) .child(self.message_editor.clone()), ) + .children(self.render_last_error(cx)) } } diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 0d2aab6905..a5ab415a4d 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -5,12 +5,13 @@ use assistant_tool::ToolWorkingSet; use collections::HashMap; use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _}; -use gpui::{AppContext, EventEmitter, ModelContext, Task}; +use gpui::{AppContext, EventEmitter, ModelContext, SharedString, Task}; use language_model::{ LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, StopReason, }; +use language_models::provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}; use serde::{Deserialize, Serialize}; use util::post_inc; @@ -210,29 +211,28 @@ impl Thread { let result = stream_completion.await; thread - .update(&mut cx, |_thread, cx| { - let error_message = if let Some(error) = result.as_ref().err() { - let error_message = error - .chain() - .map(|err| err.to_string()) - .collect::>() - .join("\n"); - Some(error_message) - } else { - None - }; - - if let Some(error_message) = error_message { - eprintln!("Completion failed: {error_message:?}"); - } - - if let Ok(stop_reason) = result { - match stop_reason { - StopReason::ToolUse => { - cx.emit(ThreadEvent::UsePendingTools); - } - StopReason::EndTurn => {} - StopReason::MaxTokens => {} + .update(&mut cx, |_thread, cx| match result.as_ref() { + Ok(stop_reason) => match stop_reason { + StopReason::ToolUse => { + cx.emit(ThreadEvent::UsePendingTools); + } + StopReason::EndTurn => {} + StopReason::MaxTokens => {} + }, + Err(error) => { + if error.is::() { + cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); + } else if error.is::() { + cx.emit(ThreadEvent::ShowError(ThreadError::MaxMonthlySpendReached)); + } else { + let error_message = error + .chain() + .map(|err| err.to_string()) + .collect::>() + .join("\n"); + cx.emit(ThreadEvent::ShowError(ThreadError::Message( + SharedString::from(error_message.clone()), + ))); } } }) @@ -305,8 +305,16 @@ impl Thread { } } +#[derive(Debug, Clone)] +pub enum ThreadError { + PaymentRequired, + MaxMonthlySpendReached, + Message(SharedString), +} + #[derive(Debug, Clone)] pub enum ThreadEvent { + ShowError(ThreadError), StreamedCompletion, UsePendingTools, ToolFinished { diff --git a/crates/language_model/src/language_model.rs b/crates/language_model/src/language_model.rs index 3c5a00bd85..83f0b50321 100644 --- a/crates/language_model/src/language_model.rs +++ b/crates/language_model/src/language_model.rs @@ -55,7 +55,7 @@ pub enum LanguageModelCompletionEvent { StartMessage { message_id: String }, } -#[derive(Debug, PartialEq, Clone, Serialize, Deserialize)] +#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum StopReason { EndTurn, From 7c994cd4a5434fea92998f676462a9e6d6c46d2d Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 2 Dec 2024 15:00:04 -0800 Subject: [PATCH 113/215] Add AutoIndent action and '=' vim operator (#21427) Release Notes: - vim: Added the `=` operator, for auto-indent Co-authored-by: Conrad --- assets/keymaps/vim.json | 10 +- crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 19 ++ crates/editor/src/editor_tests.rs | 100 +++++- crates/editor/src/element.rs | 1 + crates/editor/src/inlay_hint_cache.rs | 11 +- .../src/test/editor_lsp_test_context.rs | 82 ++--- crates/language/src/buffer.rs | 56 +++- crates/multi_buffer/src/multi_buffer.rs | 292 +++++++++++------- crates/vim/src/indent.rs | 85 ++++- crates/vim/src/normal.rs | 6 + crates/vim/src/state.rs | 3 + crates/vim/src/vim.rs | 1 + 13 files changed, 481 insertions(+), 186 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index a69e97401d..b2ef7f2c18 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -55,10 +55,10 @@ "n": "vim::MoveToNextMatch", "shift-n": "vim::MoveToPrevMatch", "%": "vim::Matching", - "] }": ["vim::UnmatchedForward", { "char": "}" } ], - "[ {": ["vim::UnmatchedBackward", { "char": "{" } ], - "] )": ["vim::UnmatchedForward", { "char": ")" } ], - "[ (": ["vim::UnmatchedBackward", { "char": "(" } ], + "] }": ["vim::UnmatchedForward", { "char": "}" }], + "[ {": ["vim::UnmatchedBackward", { "char": "{" }], + "] )": ["vim::UnmatchedForward", { "char": ")" }], + "[ (": ["vim::UnmatchedBackward", { "char": "(" }], "f": ["vim::PushOperator", { "FindForward": { "before": false } }], "t": ["vim::PushOperator", { "FindForward": { "before": true } }], "shift-f": ["vim::PushOperator", { "FindBackward": { "after": false } }], @@ -209,6 +209,7 @@ "shift-s": "vim::SubstituteLine", ">": ["vim::PushOperator", "Indent"], "<": ["vim::PushOperator", "Outdent"], + "=": ["vim::PushOperator", "AutoIndent"], "g u": ["vim::PushOperator", "Lowercase"], "g shift-u": ["vim::PushOperator", "Uppercase"], "g ~": ["vim::PushOperator", "OppositeCase"], @@ -275,6 +276,7 @@ "ctrl-[": ["vim::SwitchMode", "Normal"], ">": "vim::Indent", "<": "vim::Outdent", + "=": "vim::AutoIndent", "i": ["vim::PushOperator", { "Object": { "around": false } }], "a": ["vim::PushOperator", { "Object": { "around": true } }], "g c": "vim::ToggleComments", diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 5b11b18bc2..a67dd55055 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -303,6 +303,7 @@ gpui::actions!( OpenPermalinkToLine, OpenUrl, Outdent, + AutoIndent, PageDown, PageUp, Paste, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 51a90a9206..82b27d6f22 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6297,6 +6297,25 @@ impl Editor { }); } + pub fn autoindent(&mut self, _: &AutoIndent, cx: &mut ViewContext) { + if self.read_only(cx) { + return; + } + let selections = self + .selections + .all::(cx) + .into_iter() + .map(|s| s.range()); + + self.transact(cx, |this, cx| { + this.buffer.update(cx, |buffer, cx| { + buffer.autoindent_ranges(selections, cx); + }); + let selections = this.selections.all::(cx); + this.change_selections(Some(Autoscroll::fit()), cx, |s| s.select(selections)); + }); + } + pub fn delete_line(&mut self, _: &DeleteLine, cx: &mut ViewContext) { let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); let selections = self.selections.all::(cx); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index b49b3fa33b..5134b512ff 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -34,6 +34,7 @@ use serde_json::{self, json}; use std::sync::atomic::AtomicUsize; use std::sync::atomic::{self, AtomicBool}; use std::{cell::RefCell, future::Future, rc::Rc, time::Instant}; +use test::editor_lsp_test_context::rust_lang; use unindent::Unindent; use util::{ assert_set_eq, @@ -5458,7 +5459,7 @@ async fn test_select_larger_smaller_syntax_node(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { +async fn test_autoindent(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let language = Arc::new( @@ -5520,6 +5521,89 @@ async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_autoindent_selections(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + { + let mut cx = EditorLspTestContext::new_rust(Default::default(), cx).await; + cx.set_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» + } + "}); + + cx.update_editor(|editor, cx| { + editor.autoindent(&Default::default(), cx); + }); + + cx.assert_editor_state(indoc! {" + impl A { + + fn b() {} + + «fn c() { + + }ˇ» + } + "}); + } + + { + let mut cx = EditorTestContext::new_multibuffer( + cx, + [indoc! { " + impl A { + « + // a + fn b(){} + » + « + } + fn c(){} + » + "}], + ); + + let buffer = cx.update_editor(|editor, cx| { + let buffer = editor.buffer().update(cx, |buffer, _| { + buffer.all_buffers().iter().next().unwrap().clone() + }); + buffer.update(cx, |buffer, cx| buffer.set_language(Some(rust_lang()), cx)); + buffer + }); + + cx.run_until_parked(); + cx.update_editor(|editor, cx| { + editor.select_all(&Default::default(), cx); + editor.autoindent(&Default::default(), cx) + }); + cx.run_until_parked(); + + cx.update(|cx| { + pretty_assertions::assert_eq!( + buffer.read(cx).text(), + indoc! { " + impl A { + + // a + fn b(){} + + + } + fn c(){} + + " } + ) + }); + } +} + #[gpui::test] async fn test_autoclose_and_auto_surround_pairs(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); @@ -13933,20 +14017,6 @@ pub(crate) fn init_test(cx: &mut TestAppContext, f: fn(&mut AllLanguageSettingsC update_test_language_settings(cx, f); } -pub(crate) fn rust_lang() -> Arc { - Arc::new(Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - )) -} - #[track_caller] fn assert_hunk_revert( not_reverted_text_with_selections: &str, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 7f4bc3fb77..975f1b8bf0 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -189,6 +189,7 @@ impl EditorElement { register_action(view, cx, Editor::tab_prev); register_action(view, cx, Editor::indent); register_action(view, cx, Editor::outdent); + register_action(view, cx, Editor::autoindent); register_action(view, cx, Editor::delete_line); register_action(view, cx, Editor::join_lines); register_action(view, cx, Editor::sort_lines_case_sensitive); diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 877f02eefe..8b2358c6b4 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -1258,6 +1258,7 @@ pub mod tests { use crate::{ scroll::{scroll_amount::ScrollAmount, Autoscroll}, + test::editor_lsp_test_context::rust_lang, ExcerptRange, }; use futures::StreamExt; @@ -2274,7 +2275,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { @@ -2570,7 +2571,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - let language = crate::editor_tests::rust_lang(); + let language = rust_lang(); language_registry.add(language); let mut fake_servers = language_registry.register_fake_lsp( "Rust", @@ -2922,7 +2923,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { @@ -3153,7 +3154,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { @@ -3396,7 +3397,7 @@ pub mod tests { let project = Project::test(fs, ["/a".as_ref()], cx).await; let language_registry = project.read_with(cx, |project, _| project.languages().clone()); - language_registry.add(crate::editor_tests::rust_lang()); + language_registry.add(rust_lang()); let mut fake_servers = language_registry.register_fake_lsp( "Rust", FakeLspAdapter { diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 0384ed065b..b43d78bc99 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -31,6 +31,47 @@ pub struct EditorLspTestContext { pub buffer_lsp_url: lsp::Url, } +pub(crate) fn rust_lang() -> Arc { + let language = Language::new( + LanguageConfig { + name: "Rust".into(), + matcher: LanguageMatcher { + path_suffixes: vec!["rs".to_string()], + ..Default::default() + }, + line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], + ..Default::default() + }, + Some(tree_sitter_rust::LANGUAGE.into()), + ) + .with_queries(LanguageQueries { + indents: Some(Cow::from(indoc! {r#" + [ + ((where_clause) _ @end) + (field_expression) + (call_expression) + (assignment_expression) + (let_declaration) + (let_chain) + (await_expression) + ] @indent + + (_ "[" "]" @end) @indent + (_ "<" ">" @end) @indent + (_ "{" "}" @end) @indent + (_ "(" ")" @end) @indent"#})), + brackets: Some(Cow::from(indoc! {r#" + ("(" @open ")" @close) + ("[" @open "]" @close) + ("{" @open "}" @close) + ("<" @open ">" @close) + ("\"" @open "\"" @close) + (closure_parameters "|" @open "|" @close)"#})), + ..Default::default() + }) + .expect("Could not parse queries"); + Arc::new(language) +} impl EditorLspTestContext { pub async fn new( language: Language, @@ -119,46 +160,7 @@ impl EditorLspTestContext { capabilities: lsp::ServerCapabilities, cx: &mut gpui::TestAppContext, ) -> EditorLspTestContext { - let language = Language::new( - LanguageConfig { - name: "Rust".into(), - matcher: LanguageMatcher { - path_suffixes: vec!["rs".to_string()], - ..Default::default() - }, - line_comments: vec!["// ".into(), "/// ".into(), "//! ".into()], - ..Default::default() - }, - Some(tree_sitter_rust::LANGUAGE.into()), - ) - .with_queries(LanguageQueries { - indents: Some(Cow::from(indoc! {r#" - [ - ((where_clause) _ @end) - (field_expression) - (call_expression) - (assignment_expression) - (let_declaration) - (let_chain) - (await_expression) - ] @indent - - (_ "[" "]" @end) @indent - (_ "<" ">" @end) @indent - (_ "{" "}" @end) @indent - (_ "(" ")" @end) @indent"#})), - brackets: Some(Cow::from(indoc! {r#" - ("(" @open ")" @close) - ("[" @open "]" @close) - ("{" @open "}" @close) - ("<" @open ">" @close) - ("\"" @open "\"" @close) - (closure_parameters "|" @open "|" @close)"#})), - ..Default::default() - }) - .expect("Could not parse queries"); - - Self::new(language, capabilities, cx).await + Self::new(Arc::into_inner(rust_lang()).unwrap(), capabilities, cx).await } pub async fn new_typescript( diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2479eafd7a..a03357c1d4 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -467,6 +467,7 @@ struct AutoindentRequest { before_edit: BufferSnapshot, entries: Vec, is_block_mode: bool, + ignore_empty_lines: bool, } #[derive(Debug, Clone)] @@ -1381,7 +1382,7 @@ impl Buffer { let autoindent_requests = self.autoindent_requests.clone(); Some(async move { - let mut indent_sizes = BTreeMap::new(); + let mut indent_sizes = BTreeMap::::new(); for request in autoindent_requests { // Resolve each edited range to its row in the current buffer and in the // buffer before this batch of edits. @@ -1475,10 +1476,12 @@ impl Buffer { let suggested_indent = indent_sizes .get(&suggestion.basis_row) .copied() + .map(|e| e.0) .unwrap_or_else(|| { snapshot.indent_size_for_line(suggestion.basis_row) }) .with_delta(suggestion.delta, language_indent_size); + if old_suggestions.get(&new_row).map_or( true, |(old_indentation, was_within_error)| { @@ -1486,7 +1489,10 @@ impl Buffer { && (!suggestion.within_error || *was_within_error) }, ) { - indent_sizes.insert(new_row, suggested_indent); + indent_sizes.insert( + new_row, + (suggested_indent, request.ignore_empty_lines), + ); } } } @@ -1494,10 +1500,12 @@ impl Buffer { if let (true, Some(original_indent_column)) = (request.is_block_mode, original_indent_column) { - let new_indent = indent_sizes - .get(&row_range.start) - .copied() - .unwrap_or_else(|| snapshot.indent_size_for_line(row_range.start)); + let new_indent = + if let Some((indent, _)) = indent_sizes.get(&row_range.start) { + *indent + } else { + snapshot.indent_size_for_line(row_range.start) + }; let delta = new_indent.len as i64 - original_indent_column as i64; if delta != 0 { for row in row_range.skip(1) { @@ -1512,7 +1520,7 @@ impl Buffer { Ordering::Equal => {} } } - size + (size, request.ignore_empty_lines) }); } } @@ -1523,6 +1531,15 @@ impl Buffer { } indent_sizes + .into_iter() + .filter_map(|(row, (indent, ignore_empty_lines))| { + if ignore_empty_lines && snapshot.line_len(row) == 0 { + None + } else { + Some((row, indent)) + } + }) + .collect() }) } @@ -2067,6 +2084,7 @@ impl Buffer { before_edit, entries, is_block_mode: matches!(mode, AutoindentMode::Block { .. }), + ignore_empty_lines: false, })); } @@ -2094,6 +2112,30 @@ impl Buffer { cx.notify(); } + pub fn autoindent_ranges(&mut self, ranges: I, cx: &mut ModelContext) + where + I: IntoIterator>, + T: ToOffset + Copy, + { + let before_edit = self.snapshot(); + let entries = ranges + .into_iter() + .map(|range| AutoindentRequestEntry { + range: before_edit.anchor_before(range.start)..before_edit.anchor_after(range.end), + first_line_is_new: true, + indent_size: before_edit.language_indent_size_at(range.start, cx), + original_indent_column: None, + }) + .collect(); + self.autoindent_requests.push(Arc::new(AutoindentRequest { + before_edit, + entries, + is_block_mode: false, + ignore_empty_lines: true, + })); + self.request_autoindent(cx); + } + // Inserts newlines at the given position to create an empty line, returning the start of the new line. // You can also request the insertion of empty lines above and below the line starting at the returned point. pub fn insert_empty_line( diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index b6ba702b4e..f1434b6d59 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -325,6 +325,13 @@ struct ExcerptBytes<'a> { reversed: bool, } +struct BufferEdit { + range: Range, + new_text: Arc, + is_insertion: bool, + original_indent_column: u32, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExpandExcerptDirection { Up, @@ -525,57 +532,146 @@ impl MultiBuffer { pub fn edit( &self, edits: I, - mut autoindent_mode: Option, + autoindent_mode: Option, cx: &mut ModelContext, ) where I: IntoIterator, T)>, S: ToOffset, T: Into>, { - if self.read_only() { - return; - } - if self.buffers.borrow().is_empty() { - return; - } - let snapshot = self.read(cx); - let edits = edits.into_iter().map(|(range, new_text)| { - let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); - if range.start > range.end { - mem::swap(&mut range.start, &mut range.end); + let edits = edits + .into_iter() + .map(|(range, new_text)| { + let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + if range.start > range.end { + mem::swap(&mut range.start, &mut range.end); + } + (range, new_text.into()) + }) + .collect::>(); + + return edit_internal(self, snapshot, edits, autoindent_mode, cx); + + // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR. + fn edit_internal( + this: &MultiBuffer, + snapshot: Ref, + edits: Vec<(Range, Arc)>, + mut autoindent_mode: Option, + cx: &mut ModelContext, + ) { + if this.read_only() || this.buffers.borrow().is_empty() { + return; + } + + if let Some(buffer) = this.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.edit(edits, autoindent_mode, cx); + }); + cx.emit(Event::ExcerptsEdited { + ids: this.excerpt_ids(), + }); + return; + } + + let original_indent_columns = match &mut autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns, + }) => mem::take(original_indent_columns), + _ => Default::default(), + }; + + let (buffer_edits, edited_excerpt_ids) = + this.convert_edits_to_buffer_edits(edits, &snapshot, &original_indent_columns); + drop(snapshot); + + for (buffer_id, mut edits) in buffer_edits { + edits.sort_unstable_by_key(|edit| edit.range.start); + this.buffers.borrow()[&buffer_id] + .buffer + .update(cx, |buffer, cx| { + let mut edits = edits.into_iter().peekable(); + let mut insertions = Vec::new(); + let mut original_indent_columns = Vec::new(); + let mut deletions = Vec::new(); + let empty_str: Arc = Arc::default(); + while let Some(BufferEdit { + mut range, + new_text, + mut is_insertion, + original_indent_column, + }) = edits.next() + { + while let Some(BufferEdit { + range: next_range, + is_insertion: next_is_insertion, + .. + }) = edits.peek() + { + if range.end >= next_range.start { + range.end = cmp::max(next_range.end, range.end); + is_insertion |= *next_is_insertion; + edits.next(); + } else { + break; + } + } + + if is_insertion { + original_indent_columns.push(original_indent_column); + insertions.push(( + buffer.anchor_before(range.start) + ..buffer.anchor_before(range.end), + new_text.clone(), + )); + } else if !range.is_empty() { + deletions.push(( + buffer.anchor_before(range.start) + ..buffer.anchor_before(range.end), + empty_str.clone(), + )); + } + } + + let deletion_autoindent_mode = + if let Some(AutoindentMode::Block { .. }) = autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns: Default::default(), + }) + } else { + autoindent_mode.clone() + }; + let insertion_autoindent_mode = + if let Some(AutoindentMode::Block { .. }) = autoindent_mode { + Some(AutoindentMode::Block { + original_indent_columns, + }) + } else { + autoindent_mode.clone() + }; + + buffer.edit(deletions, deletion_autoindent_mode, cx); + buffer.edit(insertions, insertion_autoindent_mode, cx); + }) } - (range, new_text) - }); - if let Some(buffer) = self.as_singleton() { - buffer.update(cx, |buffer, cx| { - buffer.edit(edits, autoindent_mode, cx); - }); cx.emit(Event::ExcerptsEdited { - ids: self.excerpt_ids(), + ids: edited_excerpt_ids, }); - return; } + } - let original_indent_columns = match &mut autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns, - }) => mem::take(original_indent_columns), - _ => Default::default(), - }; - - struct BufferEdit { - range: Range, - new_text: Arc, - is_insertion: bool, - original_indent_column: u32, - } + fn convert_edits_to_buffer_edits( + &self, + edits: Vec<(Range, Arc)>, + snapshot: &MultiBufferSnapshot, + original_indent_columns: &[u32], + ) -> (HashMap>, Vec) { let mut buffer_edits: HashMap> = Default::default(); let mut edited_excerpt_ids = Vec::new(); let mut cursor = snapshot.excerpts.cursor::(&()); - for (ix, (range, new_text)) in edits.enumerate() { - let new_text: Arc = new_text.into(); + for (ix, (range, new_text)) in edits.into_iter().enumerate() { let original_indent_column = original_indent_columns.get(ix).copied().unwrap_or(0); cursor.seek(&range.start, Bias::Right, &()); if cursor.item().is_none() && range.start == *cursor.start() { @@ -667,84 +763,71 @@ impl MultiBuffer { } } } + (buffer_edits, edited_excerpt_ids) + } - drop(cursor); - drop(snapshot); - // Non-generic part of edit, hoisted out to avoid blowing up LLVM IR. - fn tail( + pub fn autoindent_ranges(&self, ranges: I, cx: &mut ModelContext) + where + I: IntoIterator>, + S: ToOffset, + { + let snapshot = self.read(cx); + let empty = Arc::::from(""); + let edits = ranges + .into_iter() + .map(|range| { + let mut range = range.start.to_offset(&snapshot)..range.end.to_offset(&snapshot); + if range.start > range.end { + mem::swap(&mut range.start, &mut range.end); + } + (range, empty.clone()) + }) + .collect::>(); + + return autoindent_ranges_internal(self, snapshot, edits, cx); + + fn autoindent_ranges_internal( this: &MultiBuffer, - buffer_edits: HashMap>, - autoindent_mode: Option, - edited_excerpt_ids: Vec, + snapshot: Ref, + edits: Vec<(Range, Arc)>, cx: &mut ModelContext, ) { + if this.read_only() || this.buffers.borrow().is_empty() { + return; + } + + if let Some(buffer) = this.as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.autoindent_ranges(edits.into_iter().map(|e| e.0), cx); + }); + cx.emit(Event::ExcerptsEdited { + ids: this.excerpt_ids(), + }); + return; + } + + let (buffer_edits, edited_excerpt_ids) = + this.convert_edits_to_buffer_edits(edits, &snapshot, &[]); + drop(snapshot); + for (buffer_id, mut edits) in buffer_edits { edits.sort_unstable_by_key(|edit| edit.range.start); + + let mut ranges: Vec> = Vec::new(); + for edit in edits { + if let Some(last_range) = ranges.last_mut() { + if edit.range.start <= last_range.end { + last_range.end = last_range.end.max(edit.range.end); + continue; + } + } + ranges.push(edit.range); + } + this.buffers.borrow()[&buffer_id] .buffer .update(cx, |buffer, cx| { - let mut edits = edits.into_iter().peekable(); - let mut insertions = Vec::new(); - let mut original_indent_columns = Vec::new(); - let mut deletions = Vec::new(); - let empty_str: Arc = Arc::default(); - while let Some(BufferEdit { - mut range, - new_text, - mut is_insertion, - original_indent_column, - }) = edits.next() - { - while let Some(BufferEdit { - range: next_range, - is_insertion: next_is_insertion, - .. - }) = edits.peek() - { - if range.end >= next_range.start { - range.end = cmp::max(next_range.end, range.end); - is_insertion |= *next_is_insertion; - edits.next(); - } else { - break; - } - } - - if is_insertion { - original_indent_columns.push(original_indent_column); - insertions.push(( - buffer.anchor_before(range.start) - ..buffer.anchor_before(range.end), - new_text.clone(), - )); - } else if !range.is_empty() { - deletions.push(( - buffer.anchor_before(range.start) - ..buffer.anchor_before(range.end), - empty_str.clone(), - )); - } - } - - let deletion_autoindent_mode = - if let Some(AutoindentMode::Block { .. }) = autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns: Default::default(), - }) - } else { - autoindent_mode.clone() - }; - let insertion_autoindent_mode = - if let Some(AutoindentMode::Block { .. }) = autoindent_mode { - Some(AutoindentMode::Block { - original_indent_columns, - }) - } else { - autoindent_mode.clone() - }; - - buffer.edit(deletions, deletion_autoindent_mode, cx); - buffer.edit(insertions, insertion_autoindent_mode, cx); + buffer.autoindent_ranges(ranges, cx); }) } @@ -752,7 +835,6 @@ impl MultiBuffer { ids: edited_excerpt_ids, }); } - tail(self, buffer_edits, autoindent_mode, edited_excerpt_ids, cx); } // Inserts newlines at the given position to create an empty line, returning the start of the new line. diff --git a/crates/vim/src/indent.rs b/crates/vim/src/indent.rs index 8e4f27271b..6d5ce78f5c 100644 --- a/crates/vim/src/indent.rs +++ b/crates/vim/src/indent.rs @@ -9,9 +9,10 @@ use ui::ViewContext; pub(crate) enum IndentDirection { In, Out, + Auto, } -actions!(vim, [Indent, Outdent,]); +actions!(vim, [Indent, Outdent, AutoIndent]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Indent, cx| { @@ -49,6 +50,24 @@ pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { vim.switch_mode(Mode::Normal, true, cx) } }); + + Vim::action(editor, cx, |vim, _: &AutoIndent, cx| { + vim.record_current_action(cx); + let count = Vim::take_count(cx).unwrap_or(1); + vim.store_visual_marks(cx); + vim.update_editor(cx, |vim, editor, cx| { + editor.transact(cx, |editor, cx| { + let original_positions = vim.save_selection_starts(editor, cx); + for _ in 0..count { + editor.autoindent(&Default::default(), cx); + } + vim.restore_selection_cursors(editor, cx, original_positions); + }); + }); + if vim.mode.is_visual() { + vim.switch_mode(Mode::Normal, true, cx) + } + }); } impl Vim { @@ -71,10 +90,10 @@ impl Vim { motion.expand_selection(map, selection, times, false, &text_layout_details); }); }); - if dir == IndentDirection::In { - editor.indent(&Default::default(), cx); - } else { - editor.outdent(&Default::default(), cx); + match dir { + IndentDirection::In => editor.indent(&Default::default(), cx), + IndentDirection::Out => editor.outdent(&Default::default(), cx), + IndentDirection::Auto => editor.autoindent(&Default::default(), cx), } editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -104,10 +123,10 @@ impl Vim { object.expand_selection(map, selection, around); }); }); - if dir == IndentDirection::In { - editor.indent(&Default::default(), cx); - } else { - editor.outdent(&Default::default(), cx); + match dir { + IndentDirection::In => editor.indent(&Default::default(), cx), + IndentDirection::Out => editor.outdent(&Default::default(), cx), + IndentDirection::Auto => editor.autoindent(&Default::default(), cx), } editor.change_selections(None, cx, |s| { s.move_with(|map, selection| { @@ -122,7 +141,11 @@ impl Vim { #[cfg(test)] mod test { - use crate::test::NeovimBackedTestContext; + use crate::{ + state::Mode, + test::{NeovimBackedTestContext, VimTestContext}, + }; + use indoc::indoc; #[gpui::test] async fn test_indent_gv(cx: &mut gpui::TestAppContext) { @@ -135,4 +158,46 @@ mod test { .await .assert_eq("« hello\n ˇ» world\n"); } + + #[gpui::test] + async fn test_autoindent_op(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc!( + " + fn a() { + b(); + c(); + + d(); + ˇe(); + f(); + + g(); + } + " + ), + Mode::Normal, + ); + + cx.simulate_keystrokes("= a p"); + cx.assert_state( + indoc!( + " + fn a() { + b(); + c(); + + d(); + ˇe(); + f(); + + g(); + } + " + ), + Mode::Normal, + ); + } } diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index 24e8e7bed4..bde3c12027 100644 --- a/crates/vim/src/normal.rs +++ b/crates/vim/src/normal.rs @@ -170,6 +170,9 @@ impl Vim { Some(Operator::Indent) => self.indent_motion(motion, times, IndentDirection::In, cx), Some(Operator::Rewrap) => self.rewrap_motion(motion, times, cx), Some(Operator::Outdent) => self.indent_motion(motion, times, IndentDirection::Out, cx), + Some(Operator::AutoIndent) => { + self.indent_motion(motion, times, IndentDirection::Auto, cx) + } Some(Operator::Lowercase) => { self.change_case_motion(motion, times, CaseTarget::Lowercase, cx) } @@ -202,6 +205,9 @@ impl Vim { Some(Operator::Outdent) => { self.indent_object(object, around, IndentDirection::Out, cx) } + Some(Operator::AutoIndent) => { + self.indent_object(object, around, IndentDirection::Auto, cx) + } Some(Operator::Rewrap) => self.rewrap_object(object, around, cx), Some(Operator::Lowercase) => { self.change_case_object(object, around, CaseTarget::Lowercase, cx) diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 47742fb0c3..af187381ad 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -72,6 +72,7 @@ pub enum Operator { Jump { line: bool }, Indent, Outdent, + AutoIndent, Rewrap, Lowercase, Uppercase, @@ -465,6 +466,7 @@ impl Operator { Operator::Jump { line: true } => "'", Operator::Jump { line: false } => "`", Operator::Indent => ">", + Operator::AutoIndent => "eq", Operator::Rewrap => "gq", Operator::Outdent => "<", Operator::Uppercase => "gU", @@ -510,6 +512,7 @@ impl Operator { | Operator::Rewrap | Operator::Indent | Operator::Outdent + | Operator::AutoIndent | Operator::Lowercase | Operator::Uppercase | Operator::Object { .. } diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index a1820eafbb..db0a765170 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -470,6 +470,7 @@ impl Vim { | Operator::Replace | Operator::Indent | Operator::Outdent + | Operator::AutoIndent | Operator::Lowercase | Operator::Uppercase | Operator::OppositeCase From 579bc8f01597dadf784f93696de2a2d1d3de2981 Mon Sep 17 00:00:00 2001 From: Kyle Kelley Date: Mon, 2 Dec 2024 15:22:03 -0800 Subject: [PATCH 114/215] Upgrade repl dependencies (#21431) Bump dependencies for jupyter packages. cc @maxdeviant Release Notes: - N/A --- Cargo.lock | 170 +++++------------------ Cargo.toml | 8 +- crates/repl/src/kernels/native_kernel.rs | 9 +- crates/repl/src/outputs.rs | 8 +- 4 files changed, 48 insertions(+), 147 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7504b8491b..d21006ee55 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -930,20 +930,6 @@ version = "4.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" -[[package]] -name = "async-tls" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfeefd0ca297cbbb3bd34fd6b228401c2a5177038257afd751bc29f0a2da4795" -dependencies = [ - "futures-core", - "futures-io", - "rustls 0.20.9", - "rustls-pemfile 1.0.4", - "webpki", - "webpki-roots 0.22.6", -] - [[package]] name = "async-tls" version = "0.13.0" @@ -968,21 +954,6 @@ dependencies = [ "syn 2.0.87", ] -[[package]] -name = "async-tungstenite" -version = "0.22.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce01ac37fdc85f10a43c43bc582cbd566720357011578a935761075f898baf58" -dependencies = [ - "async-std", - "async-tls 0.12.0", - "futures-io", - "futures-util", - "log", - "pin-project-lite", - "tungstenite 0.19.0", -] - [[package]] name = "async-tungstenite" version = "0.28.0" @@ -990,7 +961,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90e661b6cb0a6eb34d02c520b052daa3aa9ac0cc02495c9d066bbce13ead132b" dependencies = [ "async-std", - "async-tls 0.13.0", + "async-tls", "futures-io", "futures-util", "log", @@ -1160,7 +1131,7 @@ dependencies = [ "fastrand 2.2.0", "hex", "http 0.2.12", - "ring 0.17.8", + "ring", "time", "tokio", "tracing", @@ -1350,7 +1321,7 @@ dependencies = [ "once_cell", "p256", "percent-encoding", - "ring 0.17.8", + "ring", "sha2", "subtle", "time", @@ -2507,7 +2478,7 @@ dependencies = [ "anyhow", "async-native-tls", "async-recursion 0.3.2", - "async-tungstenite 0.28.0", + "async-tungstenite", "chrono", "clock", "cocoa 0.26.0", @@ -2639,7 +2610,7 @@ dependencies = [ "assistant_tool", "async-stripe", "async-trait", - "async-tungstenite 0.28.0", + "async-tungstenite", "audio", "aws-config", "aws-sdk-kinesis", @@ -4540,7 +4511,7 @@ dependencies = [ "futures-core", "futures-sink", "nanorand", - "spin 0.9.8", + "spin", ] [[package]] @@ -6453,7 +6424,7 @@ dependencies = [ "base64 0.21.7", "js-sys", "pem", - "ring 0.17.8", + "ring", "serde", "serde_json", "simple_asn1", @@ -6461,47 +6432,31 @@ dependencies = [ [[package]] name = "jupyter-protocol" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d4d496ac890e14efc12c5289818b3c39e3026a7bb02d5576b011e1a062d4bcc" +checksum = "503458f8125fd9047ed0a9d95d7a93adc5eaf8bce48757c6d401e09f71ad3407" dependencies = [ "anyhow", "async-trait", "bytes 1.8.0", "chrono", "futures 0.3.31", - "jupyter-serde", - "rand 0.8.5", "serde", "serde_json", "uuid", ] -[[package]] -name = "jupyter-serde" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32aa595c3912167b7eafcaa822b767ad1fa9605a18127fc9ac741241b796410e" -dependencies = [ - "anyhow", - "serde", - "serde_json", - "thiserror 1.0.69", - "uuid", -] - [[package]] name = "jupyter-websocket-client" -version = "0.5.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5850894210a3f033ff730d6f956b0335db38573ce7bb61c6abbf69dcbe284ba7" +checksum = "58d9afa5bc6eeafb78f710a2efc585f69099f8b6a99dc7eb826581e3773a6e31" dependencies = [ "anyhow", "async-trait", - "async-tungstenite 0.22.2", + "async-tungstenite", "futures 0.3.31", "jupyter-protocol", - "jupyter-serde", "serde", "serde_json", "url", @@ -6817,7 +6772,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin", ] [[package]] @@ -7539,13 +7494,13 @@ dependencies = [ [[package]] name = "nbformat" -version = "0.7.0" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa6827a3881aa100bb2241cd2633b3c79474dbc93704f1f2cf5cc85064cda4be" +checksum = "19835ad46507d80d9671e10a1c7c335655f4f3033aeb066fe025f14e070c2e66" dependencies = [ "anyhow", "chrono", - "jupyter-serde", + "jupyter-protocol", "serde", "serde_json", "thiserror 1.0.69", @@ -9571,7 +9526,7 @@ dependencies = [ "bytes 1.8.0", "getrandom 0.2.15", "rand 0.8.5", - "ring 0.17.8", + "ring", "rustc-hash 2.0.0", "rustls 0.23.16", "rustls-pki-types", @@ -10214,21 +10169,6 @@ dependencies = [ "util", ] -[[package]] -name = "ring" -version = "0.16.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" -dependencies = [ - "cc", - "libc", - "once_cell", - "spin 0.5.2", - "untrusted 0.7.1", - "web-sys", - "winapi", -] - [[package]] name = "ring" version = "0.17.8" @@ -10239,8 +10179,8 @@ dependencies = [ "cfg-if", "getrandom 0.2.15", "libc", - "spin 0.9.8", - "untrusted 0.9.0", + "spin", + "untrusted", "windows-sys 0.52.0", ] @@ -10333,7 +10273,7 @@ name = "rpc" version = "0.1.0" dependencies = [ "anyhow", - "async-tungstenite 0.28.0", + "async-tungstenite", "base64 0.22.1", "chrono", "collections", @@ -10375,9 +10315,9 @@ dependencies = [ [[package]] name = "runtimelib" -version = "0.22.0" +version = "0.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a8ab675beb5cf25c28f9c6ddb8f47bcf73b43872797e6ab6157865f44d1e19" +checksum = "445ff0ee3d5c832cdd27efadd004a741423db1f91bd1de593a14b21211ea084c" dependencies = [ "anyhow", "async-dispatcher", @@ -10390,8 +10330,7 @@ dependencies = [ "futures 0.3.31", "glob", "jupyter-protocol", - "jupyter-serde", - "ring 0.17.8", + "ring", "serde", "serde_json", "shellexpand 3.1.0", @@ -10518,18 +10457,6 @@ dependencies = [ "rustix 0.38.40", ] -[[package]] -name = "rustls" -version = "0.20.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b80e3dec595989ea8510028f30c408a4630db12c9cbb8de34203b89d6577e99" -dependencies = [ - "log", - "ring 0.16.20", - "sct", - "webpki", -] - [[package]] name = "rustls" version = "0.21.12" @@ -10537,7 +10464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" dependencies = [ "log", - "ring 0.17.8", + "ring", "rustls-webpki 0.101.7", "sct", ] @@ -10549,7 +10476,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" dependencies = [ "once_cell", - "ring 0.17.8", + "ring", "rustls-pki-types", "rustls-webpki 0.102.8", "subtle", @@ -10614,8 +10541,8 @@ version = "0.101.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -10624,9 +10551,9 @@ version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ - "ring 0.17.8", + "ring", "rustls-pki-types", - "untrusted 0.9.0", + "untrusted", ] [[package]] @@ -10740,8 +10667,8 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] @@ -11503,12 +11430,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "spin" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" - [[package]] name = "spin" version = "0.9.8" @@ -13389,25 +13310,6 @@ version = "0.21.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c591d83f69777866b9126b24c6dd9a18351f177e49d625920d19f989fd31cf8" -[[package]] -name = "tungstenite" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15fba1a6d6bb030745759a9a2a588bfe8490fc8b4751a277db3a0be1c9ebbf67" -dependencies = [ - "byteorder", - "bytes 1.8.0", - "data-encoding", - "http 0.2.12", - "httparse", - "log", - "rand 0.8.5", - "sha1", - "thiserror 1.0.69", - "url", - "utf-8", -] - [[package]] name = "tungstenite" version = "0.20.1" @@ -13619,12 +13521,6 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1766d682d402817b5ac4490b3c3002d91dfa0d22812f341609f97b08757359c" -[[package]] -name = "untrusted" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - [[package]] name = "untrusted" version = "0.9.0" @@ -14535,8 +14431,8 @@ version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed63aea5ce73d0ff405984102c42de94fc55a6b75765d621c65262469b3c9b53" dependencies = [ - "ring 0.17.8", - "untrusted 0.9.0", + "ring", + "untrusted", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b50b6d9f9d..0465545990 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -388,14 +388,14 @@ indexmap = { version = "1.6.2", features = ["serde"] } indoc = "2" itertools = "0.13.0" jsonwebtoken = "9.3" -jupyter-protocol = { version = "0.3.0" } -jupyter-websocket-client = { version = "0.5.0" } +jupyter-protocol = { version = "0.5.0" } +jupyter-websocket-client = { version = "0.8.0" } libc = "0.2" linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } markup5ever_rcdom = "0.3.0" nanoid = "0.4" -nbformat = { version = "0.7.0" } +nbformat = { version = "0.9.0" } nix = "0.29" num-format = "0.4.4" once_cell = "1.19.0" @@ -429,7 +429,7 @@ reqwest = { git = "https://github.com/zed-industries/reqwest.git", rev = "fd110f "stream", ] } rsa = "0.9.6" -runtimelib = { version = "0.22.0", default-features = false, features = [ +runtimelib = { version = "0.24.0", default-features = false, features = [ "async-dispatcher-runtime", ] } rustc-demangle = "0.1.23" diff --git a/crates/repl/src/kernels/native_kernel.rs b/crates/repl/src/kernels/native_kernel.rs index 974a721ac5..2d796e12c6 100644 --- a/crates/repl/src/kernels/native_kernel.rs +++ b/crates/repl/src/kernels/native_kernel.rs @@ -6,9 +6,12 @@ use futures::{ AsyncBufReadExt as _, SinkExt as _, }; use gpui::{EntityId, Task, View, WindowContext}; -use jupyter_protocol::{JupyterKernelspec, JupyterMessage, JupyterMessageContent, KernelInfoReply}; +use jupyter_protocol::{ + connection_info::{ConnectionInfo, Transport}, + ExecutionState, JupyterKernelspec, JupyterMessage, JupyterMessageContent, KernelInfoReply, +}; use project::Fs; -use runtimelib::{dirs, ConnectionInfo, ExecutionState}; +use runtimelib::dirs; use smol::{net::TcpListener, process::Command}; use std::{ env, @@ -119,7 +122,7 @@ impl NativeRunningKernel { let ports = peek_ports(ip).await?; let connection_info = ConnectionInfo { - transport: "tcp".to_string(), + transport: Transport::TCP, ip: ip.to_string(), stdin_port: ports[0], control_port: ports[1], diff --git a/crates/repl/src/outputs.rs b/crates/repl/src/outputs.rs index b705a15568..a1335f2a0d 100644 --- a/crates/repl/src/outputs.rs +++ b/crates/repl/src/outputs.rs @@ -334,9 +334,11 @@ impl ExecutionView { result.transient.as_ref().and_then(|t| t.display_id.clone()), cx, ), - JupyterMessageContent::DisplayData(result) => { - Output::new(&result.data, result.transient.display_id.clone(), cx) - } + JupyterMessageContent::DisplayData(result) => Output::new( + &result.data, + result.transient.as_ref().and_then(|t| t.display_id.clone()), + cx, + ), JupyterMessageContent::StreamContent(result) => { // Previous stream data will combine together, handling colors, carriage returns, etc if let Some(new_terminal) = self.apply_terminal_text(&result.text, cx) { From f4dbcb67143a12d55735cd5811ab8601a022b1e1 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 2 Dec 2024 16:27:29 -0700 Subject: [PATCH 115/215] Use explicit sort order instead of comparison impls for gpui prims (#21430) Found this while looking into adding support for the Surface primitive on Linux, for rendering video shares. In that case it would be expensive to compare images for equality. `Eq` and `PartialEq` were being required but not used here due to use of `Ord` and `PartialOrd`. Release Notes: - N/A --- crates/gpui/src/scene.rs | 129 +++++---------------------------------- 1 file changed, 16 insertions(+), 113 deletions(-) diff --git a/crates/gpui/src/scene.rs b/crates/gpui/src/scene.rs index 9787ec5d87..418be6af22 100644 --- a/crates/gpui/src/scene.rs +++ b/crates/gpui/src/scene.rs @@ -128,13 +128,15 @@ impl Scene { } pub fn finish(&mut self) { - self.shadows.sort(); - self.quads.sort(); - self.paths.sort(); - self.underlines.sort(); - self.monochrome_sprites.sort(); - self.polychrome_sprites.sort(); - self.surfaces.sort(); + self.shadows.sort_by_key(|shadow| shadow.order); + self.quads.sort_by_key(|quad| quad.order); + self.paths.sort_by_key(|path| path.order); + self.underlines.sort_by_key(|underline| underline.order); + self.monochrome_sprites + .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); + self.polychrome_sprites + .sort_by_key(|sprite| (sprite.order, sprite.tile.tile_id)); + self.surfaces.sort_by_key(|surface| surface.order); } #[cfg_attr( @@ -196,7 +198,7 @@ pub(crate) enum PaintOperation { EndLayer, } -#[derive(Clone, Ord, PartialOrd, Eq, PartialEq)] +#[derive(Clone)] pub(crate) enum Primitive { Shadow(Shadow), Quad(Quad), @@ -449,7 +451,7 @@ pub(crate) enum PrimitiveBatch<'a> { Surfaces(&'a [PaintSurface]), } -#[derive(Default, Debug, Clone, Eq, PartialEq)] +#[derive(Default, Debug, Clone)] #[repr(C)] pub(crate) struct Quad { pub order: DrawOrder, @@ -462,25 +464,13 @@ pub(crate) struct Quad { pub border_widths: Edges, } -impl Ord for Quad { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Quad { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(quad: Quad) -> Self { Primitive::Quad(quad) } } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone)] #[repr(C)] pub(crate) struct Underline { pub order: DrawOrder, @@ -492,25 +482,13 @@ pub(crate) struct Underline { pub wavy: bool, } -impl Ord for Underline { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Underline { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(underline: Underline) -> Self { Primitive::Underline(underline) } } -#[derive(Debug, Clone, Eq, PartialEq)] +#[derive(Debug, Clone)] #[repr(C)] pub(crate) struct Shadow { pub order: DrawOrder, @@ -521,18 +499,6 @@ pub(crate) struct Shadow { pub color: Hsla, } -impl Ord for Shadow { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Shadow { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(shadow: Shadow) -> Self { Primitive::Shadow(shadow) @@ -642,7 +608,7 @@ impl Default for TransformationMatrix { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] #[repr(C)] pub(crate) struct MonochromeSprite { pub order: DrawOrder, @@ -654,28 +620,13 @@ pub(crate) struct MonochromeSprite { pub transformation: TransformationMatrix, } -impl Ord for MonochromeSprite { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - match self.order.cmp(&other.order) { - std::cmp::Ordering::Equal => self.tile.tile_id.cmp(&other.tile.tile_id), - order => order, - } - } -} - -impl PartialOrd for MonochromeSprite { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(sprite: MonochromeSprite) -> Self { Primitive::MonochromeSprite(sprite) } } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] #[repr(C)] pub(crate) struct PolychromeSprite { pub order: DrawOrder, @@ -687,22 +638,6 @@ pub(crate) struct PolychromeSprite { pub corner_radii: Corners, pub tile: AtlasTile, } -impl Eq for PolychromeSprite {} - -impl Ord for PolychromeSprite { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - match self.order.cmp(&other.order) { - std::cmp::Ordering::Equal => self.tile.tile_id.cmp(&other.tile.tile_id), - order => order, - } - } -} - -impl PartialOrd for PolychromeSprite { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} impl From for Primitive { fn from(sprite: PolychromeSprite) -> Self { @@ -710,7 +645,7 @@ impl From for Primitive { } } -#[derive(Clone, Debug, Eq, PartialEq)] +#[derive(Clone, Debug)] pub(crate) struct PaintSurface { pub order: DrawOrder, pub bounds: Bounds, @@ -719,18 +654,6 @@ pub(crate) struct PaintSurface { pub image_buffer: media::core_video::CVImageBuffer, } -impl Ord for PaintSurface { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for PaintSurface { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From for Primitive { fn from(surface: PaintSurface) -> Self { Primitive::Surface(surface) @@ -859,26 +782,6 @@ impl Path { } } -impl Eq for Path {} - -impl PartialEq for Path { - fn eq(&self, other: &Self) -> bool { - self.order == other.order - } -} - -impl Ord for Path { - fn cmp(&self, other: &Self) -> std::cmp::Ordering { - self.order.cmp(&other.order) - } -} - -impl PartialOrd for Path { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } -} - impl From> for Primitive { fn from(path: Path) -> Self { Primitive::Path(path) From e1c509e0de487d5ed6f0ad66e62be2063654f888 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 2 Dec 2024 18:48:03 -0500 Subject: [PATCH 116/215] Check for vulnerable dependencies in CI (#21424) This PR adds GitHub's dependency review action to CI, to flag PRs that introduce new Cargo.lock entries for vulnerable crates according to the GHSA database. An alternative would be to run `cargo audit`, which checks against the RustSec database. The state of synchronization between these two databases seems a bit messy, but as far as I can tell GHSA has most recent RustSec advisories on file, while RustSec is missing a larger number of recent GHSA advisories. The dependency review action should be smart enough not to flag PRs because an untouched entry in Cargo.lock has a new advisory. I've turned off the "license check" functionality since we have a separate CI step for that. Release Notes: - N/A --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49881e2e7c..33c85f74b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -113,6 +113,11 @@ jobs: script/check-licenses script/generate-licenses /tmp/zed_licenses_output + - name: Check for new vulnerable dependencies + uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4 + with: + license-check: false + - name: Run tests uses: ./.github/actions/run_tests From b53b2c03761d65647100400706670a0fe2d813ab Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 2 Dec 2024 19:39:18 -0500 Subject: [PATCH 117/215] Run dependency review for pull requests only (#21432) This was an oversight in the original PR, dependency-review-action won't work properly for `push` events ([example](https://github.com/zed-industries/zed/actions/runs/12130053580/job/33819624076)). Release Notes: - N/A --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33c85f74b9..602808f1b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -114,6 +114,7 @@ jobs: script/generate-licenses /tmp/zed_licenses_output - name: Check for new vulnerable dependencies + if: github.event_name == 'pull_request' uses: actions/dependency-review-action@3b139cfc5fae8b618d3eae3675e383bb1769c019 # v4 with: license-check: false From 2b143784da1adfb82462076b54ba327159996a79 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 3 Dec 2024 00:40:46 -0300 Subject: [PATCH 118/215] Improve audio files icon (#21441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It took me a couple of minutes of staring at this speaker icon to figure out it was a speaker! I even researched whether the `.wav` file type had a specific icon, given I thought it was a specific triangle of sorts 😅 I'm sensing audio waves, at this size, will be easier to parse. Release Notes: - N/A --- assets/icons/file_icons/audio.svg | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/assets/icons/file_icons/audio.svg b/assets/icons/file_icons/audio.svg index 5152efb874..672f736c95 100644 --- a/assets/icons/file_icons/audio.svg +++ b/assets/icons/file_icons/audio.svg @@ -1,4 +1,8 @@ - - + + + + + + From a8c7e610211de13d730a99f648ba6517a9f0a0f5 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Tue, 3 Dec 2024 12:45:15 +0800 Subject: [PATCH 119/215] Fix AI Context menu text wrapping causing overlap (#21438) Closes https://github.com/zed-industries/zed/issues/20678 | Before | After | | --- | --- | | SCR-20241203-jreb | SCR-20241203-jwhe | Release Notes: - Fixed AI Context menu text wrapping causing overlap. Also cc #21409 @WeetHet @osiewicz to use `Label`, this PR has been fixed `Label` to ensure `whitespace_nowrap` when use `single_line`. --------- Co-authored-by: Danilo Leal --- crates/assistant/src/slash_command_picker.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/assistant/src/slash_command_picker.rs b/crates/assistant/src/slash_command_picker.rs index 8e797d6184..215888540a 100644 --- a/crates/assistant/src/slash_command_picker.rs +++ b/crates/assistant/src/slash_command_picker.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use gpui::{AnyElement, DismissEvent, SharedString, Task, WeakView}; use picker::{Picker, PickerDelegate, PickerEditorPosition}; -use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger}; +use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip}; use crate::assistant_panel::ContextEditor; use crate::SlashCommandWorkingSet; @@ -177,11 +177,17 @@ impl PickerDelegate for SlashCommandDelegate { .inset(true) .spacing(ListItemSpacing::Dense) .selected(selected) + .tooltip({ + let description = info.description.clone(); + move |cx| cx.new_view(|_| Tooltip::new(description.clone())).into() + }) .child( v_flex() .group(format!("command-entry-label-{ix}")) .w_full() + .py_0p5() .min_w(px(250.)) + .max_w(px(400.)) .child( h_flex() .gap_1p5() @@ -192,7 +198,7 @@ impl PickerDelegate for SlashCommandDelegate { { label.push_str(&args); } - Label::new(label).size(LabelSize::Small) + Label::new(label).single_line().size(LabelSize::Small) })) .children(info.args.clone().filter(|_| !selected).map( |args| { @@ -200,6 +206,7 @@ impl PickerDelegate for SlashCommandDelegate { .font_buffer(cx) .child( Label::new(args) + .single_line() .size(LabelSize::Small) .color(Color::Muted), ) @@ -210,9 +217,11 @@ impl PickerDelegate for SlashCommandDelegate { )), ) .child( - Label::new(info.description.clone()) - .size(LabelSize::Small) - .color(Color::Muted), + div().overflow_hidden().text_ellipsis().child( + Label::new(info.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), ), ), ), From a76cd778c4eaf6af69f68f31190563836e25fb89 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 3 Dec 2024 10:07:59 -0300 Subject: [PATCH 120/215] Disable hunk diff arrow buttons when there's only one hunk (#21437) Closes https://github.com/zed-industries/zed/issues/20817 | One hunk | Multiple hunks | |--------|--------| | Screenshot 2024-12-03 at 09 42 49 | Screenshot 2024-12-02 at 23 36 38 | Release Notes: - Fixed showing prev/next hunk navigation buttons when there is only one hunk --- crates/editor/src/hunk_diff.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index 27bb8ac557..3da005cd2c 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -399,6 +399,12 @@ impl Editor { } } + fn has_multiple_hunks(&self, cx: &AppContext) -> bool { + let snapshot = self.buffer.read(cx).snapshot(cx); + let mut hunks = snapshot.git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX); + hunks.nth(1).is_some() + } + fn hunk_header_block( &self, hunk: &HoveredHunk, @@ -428,6 +434,7 @@ impl Editor { render: Arc::new({ let editor = cx.view().clone(); let hunk = hunk.clone(); + let has_multiple_hunks = self.has_multiple_hunks(cx); move |cx| { let hunk_controls_menu_handle = @@ -471,6 +478,7 @@ impl Editor { IconButton::new("next-hunk", IconName::ArrowDown) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) + .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); move |cx| { @@ -499,6 +507,7 @@ impl Editor { IconButton::new("prev-hunk", IconName::ArrowUp) .shape(IconButtonShape::Square) .icon_size(IconSize::Small) + .disabled(!has_multiple_hunks) .tooltip({ let focus_handle = editor.focus_handle(cx); move |cx| { From 1270ef3ea543064d87ea4556ebf1ef46553b79dc Mon Sep 17 00:00:00 2001 From: Sebastian Nickels Date: Tue, 3 Dec 2024 16:24:30 +0100 Subject: [PATCH 121/215] Enable toolchain venv in new terminals (#21388) Fixes part of issue #7808 > This venv should be the one we automatically activate when opening new terminals, if the detect_venv setting is on. Release Notes: - Selected Python toolchains (virtual environments) are now automatically activated in new terminals. --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/project/src/terminals.rs | 344 ++++++++++++--------- crates/terminal/src/terminal_settings.rs | 2 +- crates/terminal_view/src/persistence.rs | 56 ++-- crates/terminal_view/src/terminal_panel.rs | 281 ++++++++++------- crates/terminal_view/src/terminal_view.rs | 65 ++-- 5 files changed, 441 insertions(+), 307 deletions(-) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index 111516c82d..34ef4d8a82 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -1,8 +1,9 @@ use crate::Project; -use anyhow::Context as _; +use anyhow::{Context as _, Result}; use collections::HashMap; -use gpui::{AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, WeakModel}; +use gpui::{AnyWindowHandle, AppContext, Context, Entity, Model, ModelContext, Task, WeakModel}; use itertools::Itertools; +use language::LanguageName; use settings::{Settings, SettingsLocation}; use smol::channel::bounded; use std::{ @@ -10,10 +11,11 @@ use std::{ env::{self}, iter, path::{Path, PathBuf}, + sync::Arc, }; use task::{Shell, SpawnInTerminal}; use terminal::{ - terminal_settings::{self, TerminalSettings}, + terminal_settings::{self, TerminalSettings, VenvSettings}, TaskState, TaskStatus, Terminal, TerminalBuilder, }; use util::ResultExt; @@ -42,7 +44,7 @@ pub struct SshCommand { } impl Project { - pub fn active_project_directory(&self, cx: &AppContext) -> Option { + pub fn active_project_directory(&self, cx: &AppContext) -> Option> { let worktree = self .active_entry() .and_then(|entry_id| self.worktree_for_entry(entry_id, cx)) @@ -53,7 +55,7 @@ impl Project { worktree .root_entry() .filter(|entry| entry.is_dir()) - .map(|_| worktree.abs_path().to_path_buf()) + .map(|_| worktree.abs_path().clone()) }); worktree } @@ -87,12 +89,12 @@ impl Project { kind: TerminalKind, window: AnyWindowHandle, cx: &mut ModelContext, - ) -> anyhow::Result> { - let path = match &kind { - TerminalKind::Shell(path) => path.as_ref().map(|path| path.to_path_buf()), + ) -> Task>> { + let path: Option> = match &kind { + TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())), TerminalKind::Task(spawn_task) => { if let Some(cwd) = &spawn_task.cwd { - Some(cwd.clone()) + Some(Arc::from(cwd.as_ref())) } else { self.active_project_directory(cx) } @@ -109,7 +111,7 @@ impl Project { }); } } - let settings = TerminalSettings::get(settings_location, cx); + let settings = TerminalSettings::get(settings_location, cx).clone(); let (completion_tx, completion_rx) = bounded(1); @@ -128,160 +130,206 @@ impl Project { } else { None }; - let python_venv_directory = path - .as_ref() - .and_then(|path| self.python_venv_directory(path, settings, cx)); - let mut python_venv_activate_command = None; - let (spawn_task, shell) = match kind { - TerminalKind::Shell(_) => { - if let Some(python_venv_directory) = python_venv_directory { - python_venv_activate_command = - self.python_activate_command(&python_venv_directory, settings); - } + cx.spawn(move |this, mut cx| async move { + let python_venv_directory = if let Some(path) = path.clone() { + this.update(&mut cx, |this, cx| { + this.python_venv_directory(path, settings.detect_venv.clone(), cx) + })? + .await + } else { + None + }; + let mut python_venv_activate_command = None; - match &ssh_details { - Some((host, ssh_command)) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - - // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed - // to properly display colors. - // We do not have the luxury of assuming the host has it installed, - // so we set it to a default that does not break the highlighting via ssh. - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - - let (program, args) = - wrap_for_ssh(ssh_command, None, path.as_deref(), env, None); - env = HashMap::default(); - ( - None, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) + let (spawn_task, shell) = match kind { + TerminalKind::Shell(_) => { + if let Some(python_venv_directory) = python_venv_directory { + python_venv_activate_command = this + .update(&mut cx, |this, _| { + this.python_activate_command( + &python_venv_directory, + &settings.detect_venv, + ) + }) + .ok() + .flatten(); } - None => (None, settings.shell.clone()), - } - } - TerminalKind::Task(spawn_task) => { - let task_state = Some(TaskState { - id: spawn_task.id, - full_label: spawn_task.full_label, - label: spawn_task.label, - command_label: spawn_task.command_label, - hide: spawn_task.hide, - status: TaskStatus::Running, - show_summary: spawn_task.show_summary, - show_command: spawn_task.show_command, - completion_rx, - }); - env.extend(spawn_task.env); + match &ssh_details { + Some((host, ssh_command)) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); - if let Some(venv_path) = &python_venv_directory { - env.insert( - "VIRTUAL_ENV".to_string(), - venv_path.to_string_lossy().to_string(), - ); - } + // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed + // to properly display colors. + // We do not have the luxury of assuming the host has it installed, + // so we set it to a default that does not break the highlighting via ssh. + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); - match &ssh_details { - Some((host, ssh_command)) => { - log::debug!("Connecting to a remote server: {ssh_command:?}"); - env.entry("TERM".to_string()) - .or_insert_with(|| "xterm-256color".to_string()); - let (program, args) = wrap_for_ssh( - ssh_command, - Some((&spawn_task.command, &spawn_task.args)), - path.as_deref(), - env, - python_venv_directory, - ); - env = HashMap::default(); - ( - task_state, - Shell::WithArguments { - program, - args, - title_override: Some(format!("{} — Terminal", host).into()), - }, - ) - } - None => { - if let Some(venv_path) = &python_venv_directory { - add_environment_path(&mut env, &venv_path.join("bin")).log_err(); + let (program, args) = + wrap_for_ssh(ssh_command, None, path.as_deref(), env, None); + env = HashMap::default(); + ( + Option::::None, + Shell::WithArguments { + program, + args, + title_override: Some(format!("{} — Terminal", host).into()), + }, + ) } - - ( - task_state, - Shell::WithArguments { - program: spawn_task.command, - args: spawn_task.args, - title_override: None, - }, - ) + None => (None, settings.shell.clone()), } } - } - }; + TerminalKind::Task(spawn_task) => { + let task_state = Some(TaskState { + id: spawn_task.id, + full_label: spawn_task.full_label, + label: spawn_task.label, + command_label: spawn_task.command_label, + hide: spawn_task.hide, + status: TaskStatus::Running, + show_summary: spawn_task.show_summary, + show_command: spawn_task.show_command, + completion_rx, + }); - let terminal = TerminalBuilder::new( - local_path, - spawn_task, - shell, - env, - settings.cursor_shape.unwrap_or_default(), - settings.alternate_scroll, - settings.max_scroll_history_lines, - ssh_details.is_some(), - window, - completion_tx, - cx, - ) - .map(|builder| { - let terminal_handle = cx.new_model(|cx| builder.subscribe(cx)); + env.extend(spawn_task.env); - self.terminals - .local_handles - .push(terminal_handle.downgrade()); + if let Some(venv_path) = &python_venv_directory { + env.insert( + "VIRTUAL_ENV".to_string(), + venv_path.to_string_lossy().to_string(), + ); + } - let id = terminal_handle.entity_id(); - cx.observe_release(&terminal_handle, move |project, _terminal, cx| { - let handles = &mut project.terminals.local_handles; + match &ssh_details { + Some((host, ssh_command)) => { + log::debug!("Connecting to a remote server: {ssh_command:?}"); + env.entry("TERM".to_string()) + .or_insert_with(|| "xterm-256color".to_string()); + let (program, args) = wrap_for_ssh( + ssh_command, + Some((&spawn_task.command, &spawn_task.args)), + path.as_deref(), + env, + python_venv_directory, + ); + env = HashMap::default(); + ( + task_state, + Shell::WithArguments { + program, + args, + title_override: Some(format!("{} — Terminal", host).into()), + }, + ) + } + None => { + if let Some(venv_path) = &python_venv_directory { + add_environment_path(&mut env, &venv_path.join("bin")).log_err(); + } - if let Some(index) = handles - .iter() - .position(|terminal| terminal.entity_id() == id) - { - handles.remove(index); - cx.notify(); + ( + task_state, + Shell::WithArguments { + program: spawn_task.command, + args: spawn_task.args, + title_override: None, + }, + ) + } + } } - }) - .detach(); + }; + let terminal = this.update(&mut cx, |this, cx| { + TerminalBuilder::new( + local_path.map(|path| path.to_path_buf()), + spawn_task, + shell, + env, + settings.cursor_shape.unwrap_or_default(), + settings.alternate_scroll, + settings.max_scroll_history_lines, + ssh_details.is_some(), + window, + completion_tx, + cx, + ) + .map(|builder| { + let terminal_handle = cx.new_model(|cx| builder.subscribe(cx)); - if let Some(activate_command) = python_venv_activate_command { - self.activate_python_virtual_environment(activate_command, &terminal_handle, cx); - } - terminal_handle - }); + this.terminals + .local_handles + .push(terminal_handle.downgrade()); - terminal + let id = terminal_handle.entity_id(); + cx.observe_release(&terminal_handle, move |project, _terminal, cx| { + let handles = &mut project.terminals.local_handles; + + if let Some(index) = handles + .iter() + .position(|terminal| terminal.entity_id() == id) + { + handles.remove(index); + cx.notify(); + } + }) + .detach(); + + if let Some(activate_command) = python_venv_activate_command { + this.activate_python_virtual_environment( + activate_command, + &terminal_handle, + cx, + ); + } + terminal_handle + }) + })?; + + terminal + }) } - pub fn python_venv_directory( + fn python_venv_directory( &self, - abs_path: &Path, - settings: &TerminalSettings, - cx: &AppContext, - ) -> Option { - let venv_settings = settings.detect_venv.as_option()?; - if let Some(path) = self.find_venv_in_worktree(abs_path, &venv_settings, cx) { - return Some(path); - } - self.find_venv_on_filesystem(abs_path, &venv_settings, cx) + abs_path: Arc, + venv_settings: VenvSettings, + cx: &ModelContext, + ) -> Task> { + cx.spawn(move |this, mut cx| async move { + if let Some((worktree, _)) = this + .update(&mut cx, |this, cx| this.find_worktree(&abs_path, cx)) + .ok()? + { + let toolchain = this + .update(&mut cx, |this, cx| { + this.active_toolchain( + worktree.read(cx).id(), + LanguageName::new("Python"), + cx, + ) + }) + .ok()? + .await; + + if let Some(toolchain) = toolchain { + let toolchain_path = Path::new(toolchain.path.as_ref()); + return Some(toolchain_path.parent()?.parent()?.to_path_buf()); + } + } + let venv_settings = venv_settings.as_option()?; + this.update(&mut cx, move |this, cx| { + if let Some(path) = this.find_venv_in_worktree(&abs_path, &venv_settings, cx) { + return Some(path); + } + this.find_venv_on_filesystem(&abs_path, &venv_settings, cx) + }) + .ok() + .flatten() + }) } fn find_venv_in_worktree( @@ -337,9 +385,9 @@ impl Project { fn python_activate_command( &self, venv_base_directory: &Path, - settings: &TerminalSettings, + venv_settings: &VenvSettings, ) -> Option { - let venv_settings = settings.detect_venv.as_option()?; + let venv_settings = venv_settings.as_option()?; let activate_keyword = match venv_settings.activate_script { terminal_settings::ActivateScript::Default => match std::env::consts::OS { "windows" => ".", @@ -441,7 +489,7 @@ pub fn wrap_for_ssh( (program, args) } -fn add_environment_path(env: &mut HashMap, new_path: &Path) -> anyhow::Result<()> { +fn add_environment_path(env: &mut HashMap, new_path: &Path) -> Result<()> { let mut env_paths = vec![new_path.to_path_buf()]; if let Some(path) = env.get("PATH").or(env::var("PATH").ok().as_ref()) { let mut paths = std::env::split_paths(&path).collect::>(); diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index 842f00ad9f..760eb14b21 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -24,7 +24,7 @@ pub struct Toolbar { pub breadcrumbs: bool, } -#[derive(Debug, Deserialize)] +#[derive(Clone, Debug, Deserialize)] pub struct TerminalSettings { pub shell: Shell, pub working_directory: WorkingDirectory, diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index dd430963d2..d410ef6d72 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -5,7 +5,7 @@ use futures::{stream::FuturesUnordered, StreamExt as _}; use gpui::{AsyncWindowContext, Axis, Model, Task, View, WeakView}; use project::{terminals::TerminalKind, Project}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use ui::{Pixels, ViewContext, VisualContext as _, WindowContext}; use util::ResultExt as _; @@ -219,33 +219,39 @@ async fn deserialize_pane_group( }) .log_err()?; let active_item = serialized_pane.active_item; - pane.update(cx, |pane, cx| { - populate_pane_items(pane, new_items, active_item, cx); - // Avoid blank panes in splits - if pane.items_len() == 0 { - let working_directory = workspace - .update(cx, |workspace, cx| default_working_directory(workspace, cx)) - .ok() - .flatten(); - let kind = TerminalKind::Shell(working_directory); - let window = cx.window_handle(); - let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, window, cx)) - .log_err()?; + + let terminal = pane + .update(cx, |pane, cx| { + populate_pane_items(pane, new_items, active_item, cx); + // Avoid blank panes in splits + if pane.items_len() == 0 { + let working_directory = workspace + .update(cx, |workspace, cx| default_working_directory(workspace, cx)) + .ok() + .flatten(); + let kind = TerminalKind::Shell( + working_directory.as_deref().map(Path::to_path_buf), + ); + let window = cx.window_handle(); + let terminal = project + .update(cx, |project, cx| project.create_terminal(kind, window, cx)); + Some(Some(terminal)) + } else { + Some(None) + } + }) + .ok() + .flatten()?; + if let Some(terminal) = terminal { + let terminal = terminal.await.ok()?; + pane.update(cx, |pane, cx| { let terminal_view = Box::new(cx.new_view(|cx| { - TerminalView::new( - terminal.clone(), - workspace.clone(), - Some(workspace_id), - cx, - ) + TerminalView::new(terminal, workspace.clone(), Some(workspace_id), cx) })); pane.add_item(terminal_view, true, false, None, cx); - } - Some(()) - }) - .ok() - .flatten()?; + }) + .ok()?; + } Some((Member::Pane(pane.clone()), active.then_some(pane))) } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index b3804354c4..bbe25b8a92 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -318,10 +318,19 @@ impl TerminalPanel { } } pane::Event::Split(direction) => { - let Some(new_pane) = self.new_pane_with_cloned_active_terminal(cx) else { - return; - }; - self.center.split(&pane, &new_pane, *direction).log_err(); + let new_pane = self.new_pane_with_cloned_active_terminal(cx); + let pane = pane.clone(); + let direction = *direction; + cx.spawn(move |this, mut cx| async move { + let Some(new_pane) = new_pane.await else { + return; + }; + this.update(&mut cx, |this, _| { + this.center.split(&pane, &new_pane, direction).log_err(); + }) + .ok(); + }) + .detach(); } pane::Event::Focus => { self.active_pane = pane.clone(); @@ -334,8 +343,12 @@ impl TerminalPanel { fn new_pane_with_cloned_active_terminal( &mut self, cx: &mut ViewContext, - ) -> Option> { - let workspace = self.workspace.clone().upgrade()?; + ) -> Task>> { + let Some(workspace) = self.workspace.clone().upgrade() else { + return Task::ready(None); + }; + let database_id = workspace.read(cx).database_id(); + let weak_workspace = self.workspace.clone(); let project = workspace.read(cx).project().clone(); let working_directory = self .active_pane @@ -352,21 +365,37 @@ impl TerminalPanel { .or_else(|| default_working_directory(workspace.read(cx), cx)); let kind = TerminalKind::Shell(working_directory); let window = cx.window_handle(); - let terminal = project - .update(cx, |project, cx| project.create_terminal(kind, window, cx)) - .log_err()?; - let database_id = workspace.read(cx).database_id(); - let terminal_view = Box::new(cx.new_view(|cx| { - TerminalView::new(terminal.clone(), self.workspace.clone(), database_id, cx) - })); - let pane = new_terminal_pane(self.workspace.clone(), project, cx); - self.apply_tab_bar_buttons(&pane, cx); - pane.update(cx, |pane, cx| { - pane.add_item(terminal_view, true, true, None, cx); - }); - cx.focus_view(&pane); + cx.spawn(move |this, mut cx| async move { + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(kind, window, cx) + }) + .log_err()? + .await + .log_err()?; - Some(pane) + let terminal_view = Box::new( + cx.new_view(|cx| { + TerminalView::new(terminal.clone(), weak_workspace.clone(), database_id, cx) + }) + .ok()?, + ); + let pane = this + .update(&mut cx, |this, cx| { + let pane = new_terminal_pane(weak_workspace, project, cx); + this.apply_tab_bar_buttons(&pane, cx); + pane + }) + .ok()?; + + pane.update(&mut cx, |pane, cx| { + pane.add_item(terminal_view, true, true, None, cx); + }) + .ok()?; + cx.focus_view(&pane).ok()?; + + Some(pane) + }) } pub fn open_terminal( @@ -489,43 +518,58 @@ impl TerminalPanel { .last() .expect("covered no terminals case above") .clone(); - if allow_concurrent_runs { - debug_assert!( - !use_new_terminal, - "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" - ); - self.replace_terminal( - spawn_task, - task_pane, - existing_item_index, - existing_terminal, - cx, - ); - } else { - self.deferred_tasks.insert( - spawn_in_terminal.id.clone(), - cx.spawn(|terminal_panel, mut cx| async move { - wait_for_terminals_tasks(terminals_for_task, &mut cx).await; - terminal_panel - .update(&mut cx, |terminal_panel, cx| { - if use_new_terminal { - terminal_panel - .spawn_in_new_terminal(spawn_task, cx) - .detach_and_log_err(cx); - } else { - terminal_panel.replace_terminal( - spawn_task, - task_pane, - existing_item_index, - existing_terminal, - cx, - ); - } - }) - .ok(); - }), - ); - } + let id = spawn_in_terminal.id.clone(); + cx.spawn(move |this, mut cx| async move { + if allow_concurrent_runs { + debug_assert!( + !use_new_terminal, + "Should have handled 'allow_concurrent_runs && use_new_terminal' case above" + ); + this.update(&mut cx, |this, cx| { + this.replace_terminal( + spawn_task, + task_pane, + existing_item_index, + existing_terminal, + cx, + ) + })? + .await; + } else { + this.update(&mut cx, |this, cx| { + this.deferred_tasks.insert( + id, + cx.spawn(|terminal_panel, mut cx| async move { + wait_for_terminals_tasks(terminals_for_task, &mut cx).await; + let Ok(Some(new_terminal_task)) = + terminal_panel.update(&mut cx, |terminal_panel, cx| { + if use_new_terminal { + terminal_panel + .spawn_in_new_terminal(spawn_task, cx) + .detach_and_log_err(cx); + None + } else { + Some(terminal_panel.replace_terminal( + spawn_task, + task_pane, + existing_item_index, + existing_terminal, + cx, + )) + } + }) + else { + return; + }; + new_terminal_task.await; + }), + ); + }) + .ok(); + } + anyhow::Result::<_, anyhow::Error>::Ok(()) + }) + .detach() } pub fn spawn_in_new_terminal( @@ -611,11 +655,14 @@ impl TerminalPanel { cx.spawn(|terminal_panel, mut cx| async move { let pane = terminal_panel.update(&mut cx, |this, _| this.active_pane.clone())?; + let project = workspace.update(&mut cx, |workspace, _| workspace.project().clone())?; + let window = cx.window_handle(); + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(kind, window, cx) + })? + .await?; let result = workspace.update(&mut cx, |workspace, cx| { - let window = cx.window_handle(); - let terminal = workspace - .project() - .update(cx, |project, cx| project.create_terminal(kind, window, cx))?; let terminal_view = Box::new(cx.new_view(|cx| { TerminalView::new( terminal.clone(), @@ -695,48 +742,64 @@ impl TerminalPanel { terminal_item_index: usize, terminal_to_replace: View, cx: &mut ViewContext<'_, Self>, - ) -> Option<()> { - let project = self - .workspace - .update(cx, |workspace, _| workspace.project().clone()) - .ok()?; - + ) -> Task> { let reveal = spawn_task.reveal; let window = cx.window_handle(); - let new_terminal = project.update(cx, |project, cx| { - project - .create_terminal(TerminalKind::Task(spawn_task), window, cx) - .log_err() - })?; - terminal_to_replace.update(cx, |terminal_to_replace, cx| { - terminal_to_replace.set_terminal(new_terminal, cx); - }); - - match reveal { - RevealStrategy::Always => { - self.activate_terminal_view(&task_pane, terminal_item_index, true, cx); - let task_workspace = self.workspace.clone(); - cx.spawn(|_, mut cx| async move { - task_workspace - .update(&mut cx, |workspace, cx| workspace.focus_panel::(cx)) + let task_workspace = self.workspace.clone(); + cx.spawn(move |this, mut cx| async move { + let project = this + .update(&mut cx, |this, cx| { + this.workspace + .update(cx, |workspace, _| workspace.project().clone()) .ok() }) - .detach(); - } - RevealStrategy::NoFocus => { - self.activate_terminal_view(&task_pane, terminal_item_index, false, cx); - let task_workspace = self.workspace.clone(); - cx.spawn(|_, mut cx| async move { - task_workspace - .update(&mut cx, |workspace, cx| workspace.open_panel::(cx)) - .ok() + .ok() + .flatten()?; + let new_terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(TerminalKind::Task(spawn_task), window, cx) }) - .detach(); - } - RevealStrategy::Never => {} - } + .ok()? + .await + .log_err()?; + terminal_to_replace + .update(&mut cx, |terminal_to_replace, cx| { + terminal_to_replace.set_terminal(new_terminal, cx); + }) + .ok()?; - Some(()) + match reveal { + RevealStrategy::Always => { + this.update(&mut cx, |this, cx| { + this.activate_terminal_view(&task_pane, terminal_item_index, true, cx) + }) + .ok()?; + + cx.spawn(|mut cx| async move { + task_workspace + .update(&mut cx, |workspace, cx| workspace.focus_panel::(cx)) + .ok() + }) + .detach(); + } + RevealStrategy::NoFocus => { + this.update(&mut cx, |this, cx| { + this.activate_terminal_view(&task_pane, terminal_item_index, false, cx) + }) + .ok()?; + + cx.spawn(|mut cx| async move { + task_workspace + .update(&mut cx, |workspace, cx| workspace.open_panel::(cx)) + .ok() + }) + .detach(); + } + RevealStrategy::Never => {} + } + + Some(()) + }) } fn has_no_terminals(&self, cx: &WindowContext) -> bool { @@ -998,18 +1061,18 @@ impl Render for TerminalPanel { if let Some(pane) = panes.get(action.0).map(|p| (*p).clone()) { cx.focus_view(&pane); } else { - if let Some(new_pane) = - terminal_panel.new_pane_with_cloned_active_terminal(cx) - { - terminal_panel - .center - .split( - &terminal_panel.active_pane, - &new_pane, - SplitDirection::Right, - ) - .log_err(); - } + let new_pane = terminal_panel.new_pane_with_cloned_active_terminal(cx); + cx.spawn(|this, mut cx| async move { + if let Some(new_pane) = new_pane.await { + this.update(&mut cx, |this, _| { + this.center + .split(&this.active_pane, &new_pane, SplitDirection::Right) + .log_err(); + }) + .ok(); + } + }) + .detach(); } })) .on_action(cx.listener( diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 44e97122b8..7a83e530fe 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -136,24 +136,36 @@ impl TerminalView { let working_directory = default_working_directory(workspace, cx); let window = cx.window_handle(); - let terminal = workspace - .project() - .update(cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(working_directory), window, cx) - }) - .notify_err(workspace, cx); + let project = workspace.project().downgrade(); + cx.spawn(move |workspace, mut cx| async move { + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(TerminalKind::Shell(working_directory), window, cx) + }) + .ok()? + .await; + let terminal = workspace + .update(&mut cx, |workspace, cx| terminal.notify_err(workspace, cx)) + .ok() + .flatten()?; - if let Some(terminal) = terminal { - let view = cx.new_view(|cx| { - TerminalView::new( - terminal, - workspace.weak_handle(), - workspace.database_id(), - cx, - ) - }); - workspace.add_item_to_active_pane(Box::new(view), None, true, cx); - } + workspace + .update(&mut cx, |workspace, cx| { + let view = cx.new_view(|cx| { + TerminalView::new( + terminal, + workspace.weak_handle(), + workspace.database_id(), + cx, + ) + }); + workspace.add_item_to_active_pane(Box::new(view), None, true, cx); + }) + .ok(); + + Some(()) + }) + .detach() } pub fn new( @@ -1231,9 +1243,11 @@ impl SerializableItem for TerminalView { .ok() .flatten(); - let terminal = project.update(&mut cx, |project, cx| { - project.create_terminal(TerminalKind::Shell(cwd), window, cx) - })??; + let terminal = project + .update(&mut cx, |project, cx| { + project.create_terminal(TerminalKind::Shell(cwd), window, cx) + })? + .await?; cx.update(|cx| { cx.new_view(|cx| TerminalView::new(terminal, workspace, Some(workspace_id), cx)) }) @@ -1362,11 +1376,14 @@ impl SearchableItem for TerminalView { ///Gets the working directory for the given workspace, respecting the user's settings. /// None implies "~" on whichever machine we end up on. -pub fn default_working_directory(workspace: &Workspace, cx: &AppContext) -> Option { +pub(crate) fn default_working_directory(workspace: &Workspace, cx: &AppContext) -> Option { match &TerminalSettings::get_global(cx).working_directory { - WorkingDirectory::CurrentProjectDirectory => { - workspace.project().read(cx).active_project_directory(cx) - } + WorkingDirectory::CurrentProjectDirectory => workspace + .project() + .read(cx) + .active_project_directory(cx) + .as_deref() + .map(Path::to_path_buf), WorkingDirectory::FirstProjectDirectory => first_project_directory(workspace, cx), WorkingDirectory::AlwaysHome => None, WorkingDirectory::Always { directory } => { From a0f2c0799ebdfdac2c45e0b288016ff29d14fa0e Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 3 Dec 2024 17:27:59 +0200 Subject: [PATCH 122/215] Debounce diagnostics status bar updates (#21463) Closes https://github.com/zed-industries/zed/pull/20797 Release Notes: - Fixed diagnostics status bar flashing when typing --- crates/diagnostics/src/items.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 495987c516..f102be37fd 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -1,7 +1,9 @@ +use std::time::Duration; + use editor::Editor; use gpui::{ - EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, View, ViewContext, - WeakView, + EventEmitter, IntoElement, ParentElement, Render, Styled, Subscription, Task, View, + ViewContext, WeakView, }; use language::Diagnostic; use ui::{h_flex, prelude::*, Button, ButtonLike, Color, Icon, IconName, Label, Tooltip}; @@ -15,6 +17,7 @@ pub struct DiagnosticIndicator { workspace: WeakView, current_diagnostic: Option, _observe_active_editor: Option, + diagnostics_update: Task<()>, } impl Render for DiagnosticIndicator { @@ -126,6 +129,7 @@ impl DiagnosticIndicator { workspace: workspace.weak_handle(), current_diagnostic: None, _observe_active_editor: None, + diagnostics_update: Task::ready(()), } } @@ -149,8 +153,17 @@ impl DiagnosticIndicator { .min_by_key(|entry| (entry.diagnostic.severity, entry.range.len())) .map(|entry| entry.diagnostic); if new_diagnostic != self.current_diagnostic { - self.current_diagnostic = new_diagnostic; - cx.notify(); + self.diagnostics_update = cx.spawn(|diagnostics_indicator, mut cx| async move { + cx.background_executor() + .timer(Duration::from_millis(50)) + .await; + diagnostics_indicator + .update(&mut cx, |diagnostics_indicator, cx| { + diagnostics_indicator.current_diagnostic = new_diagnostic; + cx.notify(); + }) + .ok(); + }); } } } From a464474df017dd42f554b401d5775c1b1b1c26a2 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 3 Dec 2024 18:41:36 +0200 Subject: [PATCH 123/215] Properly handle opening of file-less excerpts (#21465) Follow-up of https://github.com/zed-industries/zed/pull/20491 and https://github.com/zed-industries/zed/pull/20469 Closes https://github.com/zed-industries/zed/issues/21369 Release Notes: - Fixed file-less excerpts always opening instead of activating --- crates/editor/src/editor.rs | 37 +++++++++++++++++++++++++++++-- crates/editor/src/editor_tests.rs | 2 +- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 82b27d6f22..1e47eb46a8 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12833,8 +12833,41 @@ impl Editor { }; for (buffer, (ranges, scroll_offset)) in new_selections_by_buffer { - let editor = - workspace.open_project_item::(pane.clone(), buffer, true, true, cx); + let editor = buffer + .read(cx) + .file() + .is_none() + .then(|| { + // Handle file-less buffers separately: those are not really the project items, so won't have a paroject path or entity id, + // so `workspace.open_project_item` will never find them, always opening a new editor. + // Instead, we try to activate the existing editor in the pane first. + let (editor, pane_item_index) = + pane.read(cx).items().enumerate().find_map(|(i, item)| { + let editor = item.downcast::()?; + let singleton_buffer = + editor.read(cx).buffer().read(cx).as_singleton()?; + if singleton_buffer == buffer { + Some((editor, i)) + } else { + None + } + })?; + pane.update(cx, |pane, cx| { + pane.activate_item(pane_item_index, true, true, cx) + }); + Some(editor) + }) + .flatten() + .unwrap_or_else(|| { + workspace.open_project_item::( + pane.clone(), + buffer, + true, + true, + cx, + ) + }); + editor.update(cx, |editor, cx| { let autoscroll = match scroll_offset { Some(scroll_offset) => Autoscroll::top_relative(scroll_offset as usize), diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 5134b512ff..044e2765ed 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11805,7 +11805,7 @@ async fn test_mutlibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { multi_buffer_editor.update(cx, |editor, cx| { editor.change_selections(Some(Autoscroll::Next), cx, |s| { - s.select_ranges(Some(60..70)) + s.select_ranges(Some(70..70)) }); editor.open_excerpts(&OpenExcerpts, cx); }); From 2dd5138988ada1b57983b5948c7e082150df50c6 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 3 Dec 2024 16:54:06 +0000 Subject: [PATCH 124/215] docs: Add anchor links for language-specific settings (#21469) --- docs/src/configuring-zed.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index e71266a01f..d4f8c40dbd 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -1335,19 +1335,19 @@ To override settings for a language, add an entry for that languages name to the The following settings can be overridden for each specific language: -- `enable_language_server` -- `ensure_final_newline_on_save` -- `format_on_save` -- `formatter` -- `hard_tabs` -- `preferred_line_length` -- `remove_trailing_whitespace_on_save` -- `show_inline_completions` -- `show_whitespaces` -- `soft_wrap` -- `tab_size` -- `use_autoclose` -- `always_treat_brackets_as_autoclosed` +- [`enable_language_server`](#enable-language-server) +- [`ensure_final_newline_on_save`](#ensure-final-newline-on-save) +- [`format_on_save`](#format-on-save) +- [`formatter`](#formatter) +- [`hard_tabs`](#hard-tabs) +- [`preferred_line_length`](#preferred-line-length) +- [`remove_trailing_whitespace_on_save`](#remove-trailing-whitespace-on-save) +- [`show_inline_completions`](#show-inline-completions) +- [`show_whitespaces`](#show-whitespaces) +- [`soft_wrap`](#soft-wrap) +- [`tab_size`](#tab-size) +- [`use_autoclose`](#use-autoclose) +- [`always_treat_brackets_as_autoclosed`](#always-treat-brackets-as-autoclosed) These values take in the same options as the root-level settings with the same name. From c443307c19f71fef32b05721b03e32db91b1dd34 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 09:26:19 -0800 Subject: [PATCH 125/215] Fix ctrl-alt-X shortcuts (#21473) The macOS input handler assumes that you want to insert control sequences when you type ctrl-alt-X (you probably don't...). Release Notes: - (nightly only) fix ctrl-alt-X shortcuts --- crates/gpui/src/platform/mac/window.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 12a332e9bc..9266f81f74 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1253,7 +1253,10 @@ extern "C" fn handle_key_event(this: &Object, native_event: id, key_equivalent: // otherwise we only send to the input handler if we don't have a matching binding. // The input handler may call `do_command_by_selector` if it doesn't know how to handle // a key. If it does so, it will return YES so we won't send the key twice. - if is_composing || event.keystroke.key_char.is_none() { + // We also do this for non-printing keys (like arrow keys and escape) as the IME menu + // may need them even if there is no marked text; + // however we skip keys with control or the input handler adds control-characters to the buffer. + if is_composing || (event.keystroke.key_char.is_none() && !event.keystroke.modifiers.control) { { let mut lock = window_state.as_ref().lock(); lock.keystroke_for_do_command = Some(event.keystroke.clone()); From 75c9dc179bb3db89915666baf56e5362761cd97c Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 09:37:01 -0800 Subject: [PATCH 126/215] Add textobjects queries (#20924) Co-Authored-By: Max Release Notes: - vim: Added motions `[[`, `[]`, `]]`, `][` for navigating by section, `[m`, `]m`, `[M`, `]M` for navigating by method, and `[*`, `]*`, `[/`, `]/` for comments. These currently only work for languages built in to Zed, as they are powered by new tree-sitter queries. - vim: Added new text objects: `ic`, `ac` for inside/around classes, `if`,`af` for functions/methods, and `g c` for comments. These currently only work for languages built in to Zed, as they are powered by new tree-sitter queries. --------- Co-authored-by: Max --- Cargo.lock | 12 +- assets/keymaps/vim.json | 19 +- crates/language/src/buffer.rs | 69 +++- crates/language/src/buffer_tests.rs | 48 +++ crates/language/src/language.rs | 69 +++- crates/language/src/language_registry.rs | 2 + crates/language/src/syntax_map.rs | 31 ++ crates/languages/src/bash/textobjects.scm | 7 + crates/languages/src/c/textobjects.scm | 25 ++ crates/languages/src/cpp/textobjects.scm | 31 ++ crates/languages/src/css/textobjects.scm | 30 ++ crates/languages/src/go/textobjects.scm | 25 ++ .../languages/src/javascript/textobjects.scm | 51 +++ crates/languages/src/json/textobjects.scm | 1 + crates/languages/src/jsonc/textobjects.scm | 1 + crates/languages/src/markdown/textobjects.scm | 3 + crates/languages/src/python/textobjects.scm | 7 + crates/languages/src/rust/outline.scm | 6 +- crates/languages/src/rust/textobjects.scm | 51 +++ crates/languages/src/tsx/textobjects.scm | 79 ++++ .../languages/src/typescript/textobjects.scm | 79 ++++ crates/languages/src/yaml/textobjects.scm | 1 + crates/multi_buffer/src/multi_buffer.rs | 44 +++ crates/vim/src/motion.rs | 348 ++++++++++++++++++ crates/vim/src/object.rs | 112 +++++- crates/vim/src/visual.rs | 2 +- docs/src/extensions/languages.md | 39 ++ docs/src/vim.md | 39 +- 28 files changed, 1205 insertions(+), 26 deletions(-) create mode 100644 crates/languages/src/bash/textobjects.scm create mode 100644 crates/languages/src/c/textobjects.scm create mode 100644 crates/languages/src/cpp/textobjects.scm create mode 100644 crates/languages/src/css/textobjects.scm create mode 100644 crates/languages/src/go/textobjects.scm create mode 100644 crates/languages/src/javascript/textobjects.scm create mode 100644 crates/languages/src/json/textobjects.scm create mode 100644 crates/languages/src/jsonc/textobjects.scm create mode 100644 crates/languages/src/markdown/textobjects.scm create mode 100644 crates/languages/src/python/textobjects.scm create mode 100644 crates/languages/src/rust/textobjects.scm create mode 100644 crates/languages/src/tsx/textobjects.scm create mode 100644 crates/languages/src/typescript/textobjects.scm create mode 100644 crates/languages/src/yaml/textobjects.scm diff --git a/Cargo.lock b/Cargo.lock index d21006ee55..1bd064ca4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3416,9 +3416,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.9" +version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" dependencies = [ "quote", "syn 2.0.87", @@ -6789,9 +6789,9 @@ checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" [[package]] name = "libc" -version = "0.2.164" +version = "0.2.162" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "433bfe06b8c75da9b2e3fbea6e5329ff87748f0b144ef75306e674c3f6f7c13f" +checksum = "18d287de67fe55fd7e1581fe933d965a5a9477b38e949cfa9f8574ef01506398" [[package]] name = "libdbus-sys" @@ -10956,9 +10956,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.133" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7fceb2473b9166b2294ef05efcb65a3db80803f0b03ef86a5fc88a2b85ee377" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "indexmap 2.6.0", "itoa", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index b2ef7f2c18..5f5933ef63 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -33,6 +33,18 @@ "(": "vim::SentenceBackward", ")": "vim::SentenceForward", "|": "vim::GoToColumn", + "] ]": "vim::NextSectionStart", + "] [": "vim::NextSectionEnd", + "[ [": "vim::PreviousSectionStart", + "[ ]": "vim::PreviousSectionEnd", + "] m": "vim::NextMethodStart", + "] M": "vim::NextMethodEnd", + "[ m": "vim::PreviousMethodStart", + "[ M": "vim::PreviousMethodEnd", + "[ *": "vim::PreviousComment", + "[ /": "vim::PreviousComment", + "] *": "vim::NextComment", + "] /": "vim::NextComment", // Word motions "w": "vim::NextWordStart", "e": "vim::NextWordEnd", @@ -360,7 +372,8 @@ "bindings": { "escape": "vim::ClearOperators", "ctrl-c": "vim::ClearOperators", - "ctrl-[": "vim::ClearOperators" + "ctrl-[": "vim::ClearOperators", + "g c": "vim::Comment" } }, { @@ -389,7 +402,9 @@ ">": "vim::AngleBrackets", "a": "vim::Argument", "i": "vim::IndentObj", - "shift-i": ["vim::IndentObj", { "includeBelow": true }] + "shift-i": ["vim::IndentObj", { "includeBelow": true }], + "f": "vim::Method", + "c": "vim::Class" } }, { diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index a03357c1d4..f3b6cb51ad 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -14,7 +14,8 @@ use crate::{ SyntaxMapMatches, SyntaxSnapshot, ToTreeSitterPoint, }, task_context::RunnableRange, - LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, + LanguageScope, Outline, OutlineConfig, RunnableCapture, RunnableTag, TextObject, + TreeSitterOptions, }; use anyhow::{anyhow, Context, Result}; use async_watch as watch; @@ -3412,6 +3413,72 @@ impl BufferSnapshot { }) } + pub fn text_object_ranges( + &self, + range: Range, + options: TreeSitterOptions, + ) -> impl Iterator, TextObject)> + '_ { + let range = range.start.to_offset(self).saturating_sub(1) + ..self.len().min(range.end.to_offset(self) + 1); + + let mut matches = + self.syntax + .matches_with_options(range.clone(), &self.text, options, |grammar| { + grammar.text_object_config.as_ref().map(|c| &c.query) + }); + + let configs = matches + .grammars() + .iter() + .map(|grammar| grammar.text_object_config.as_ref()) + .collect::>(); + + let mut captures = Vec::<(Range, TextObject)>::new(); + + iter::from_fn(move || loop { + while let Some(capture) = captures.pop() { + if capture.0.overlaps(&range) { + return Some(capture); + } + } + + let mat = matches.peek()?; + + let Some(config) = configs[mat.grammar_index].as_ref() else { + matches.advance(); + continue; + }; + + for capture in mat.captures { + let Some(ix) = config + .text_objects_by_capture_ix + .binary_search_by_key(&capture.index, |e| e.0) + .ok() + else { + continue; + }; + let text_object = config.text_objects_by_capture_ix[ix].1; + let byte_range = capture.node.byte_range(); + + let mut found = false; + for (range, existing) in captures.iter_mut() { + if existing == &text_object { + range.start = range.start.min(byte_range.start); + range.end = range.end.max(byte_range.end); + found = true; + break; + } + } + + if !found { + captures.push((byte_range, text_object)); + } + } + + matches.advance(); + }) + } + /// Returns enclosing bracket ranges containing the given range pub fn enclosing_bracket_ranges( &self, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index a33a21cb0f..3eab3aaed7 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -20,6 +20,7 @@ use std::{ sync::LazyLock, time::{Duration, Instant}, }; +use syntax_map::TreeSitterOptions; use text::network::Network; use text::{BufferId, LineEnding, LineIndent}; use text::{Point, ToPoint}; @@ -915,6 +916,39 @@ async fn test_symbols_containing(cx: &mut gpui::TestAppContext) { } } +#[gpui::test] +fn test_text_objects(cx: &mut AppContext) { + let (text, ranges) = marked_text_ranges( + indoc! {r#" + impl Hello { + fn say() -> u8 { return /* ˇhi */ 1 } + }"# + }, + false, + ); + + let buffer = + cx.new_model(|cx| Buffer::local(text.clone(), cx).with_language(Arc::new(rust_lang()), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + + let matches = snapshot + .text_object_ranges(ranges[0].clone(), TreeSitterOptions::default()) + .map(|(range, text_object)| (&text[range], text_object)) + .collect::>(); + + assert_eq!( + matches, + &[ + ("/* hi */", TextObject::AroundComment), + ("return /* hi */ 1", TextObject::InsideFunction), + ( + "fn say() -> u8 { return /* hi */ 1 }", + TextObject::AroundFunction + ), + ], + ) +} + #[gpui::test] fn test_enclosing_bracket_ranges(cx: &mut AppContext) { let mut assert = |selection_text, range_markers| { @@ -3182,6 +3216,20 @@ fn rust_lang() -> Language { "#, ) .unwrap() + .with_text_object_query( + r#" + (function_item + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + + (line_comment)+ @comment.around + + (block_comment) @comment.around + "#, + ) + .unwrap() .with_outline_query( r#" (line_comment) @annotation diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index e9590448f8..e0cd392131 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -78,7 +78,7 @@ pub use language_registry::{ }; pub use lsp::LanguageServerId; pub use outline::*; -pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer}; +pub use syntax_map::{OwnedSyntaxLayer, SyntaxLayer, TreeSitterOptions}; pub use text::{AnchorRangeExt, LineEnding}; pub use tree_sitter::{Node, Parser, Tree, TreeCursor}; @@ -848,6 +848,7 @@ pub struct Grammar { pub(crate) runnable_config: Option, pub(crate) indents_config: Option, pub outline_config: Option, + pub text_object_config: Option, pub embedding_config: Option, pub(crate) injection_config: Option, pub(crate) override_config: Option, @@ -873,6 +874,44 @@ pub struct OutlineConfig { pub annotation_capture_ix: Option, } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum TextObject { + InsideFunction, + AroundFunction, + InsideClass, + AroundClass, + InsideComment, + AroundComment, +} + +impl TextObject { + pub fn from_capture_name(name: &str) -> Option { + match name { + "function.inside" => Some(TextObject::InsideFunction), + "function.around" => Some(TextObject::AroundFunction), + "class.inside" => Some(TextObject::InsideClass), + "class.around" => Some(TextObject::AroundClass), + "comment.inside" => Some(TextObject::InsideComment), + "comment.around" => Some(TextObject::AroundComment), + _ => None, + } + } + + pub fn around(&self) -> Option { + match self { + TextObject::InsideFunction => Some(TextObject::AroundFunction), + TextObject::InsideClass => Some(TextObject::AroundClass), + TextObject::InsideComment => Some(TextObject::AroundComment), + _ => None, + } + } +} + +pub struct TextObjectConfig { + pub query: Query, + pub text_objects_by_capture_ix: Vec<(u32, TextObject)>, +} + #[derive(Debug)] pub struct EmbeddingConfig { pub query: Query, @@ -950,6 +989,7 @@ impl Language { highlights_query: None, brackets_config: None, outline_config: None, + text_object_config: None, embedding_config: None, indents_config: None, injection_config: None, @@ -1020,7 +1060,12 @@ impl Language { if let Some(query) = queries.runnables { self = self .with_runnable_query(query.as_ref()) - .context("Error loading tests query")?; + .context("Error loading runnables query")?; + } + if let Some(query) = queries.text_objects { + self = self + .with_text_object_query(query.as_ref()) + .context("Error loading textobject query")?; } Ok(self) } @@ -1097,6 +1142,26 @@ impl Language { Ok(self) } + pub fn with_text_object_query(mut self, source: &str) -> Result { + let grammar = self + .grammar_mut() + .ok_or_else(|| anyhow!("cannot mutate grammar"))?; + let query = Query::new(&grammar.ts_language, source)?; + + let mut text_objects_by_capture_ix = Vec::new(); + for (ix, name) in query.capture_names().iter().enumerate() { + if let Some(text_object) = TextObject::from_capture_name(name) { + text_objects_by_capture_ix.push((ix as u32, text_object)); + } + } + + grammar.text_object_config = Some(TextObjectConfig { + query, + text_objects_by_capture_ix, + }); + Ok(self) + } + pub fn with_embedding_query(mut self, source: &str) -> Result { let grammar = self .grammar_mut() diff --git a/crates/language/src/language_registry.rs b/crates/language/src/language_registry.rs index e5f7815351..794ab0784e 100644 --- a/crates/language/src/language_registry.rs +++ b/crates/language/src/language_registry.rs @@ -181,6 +181,7 @@ pub const QUERY_FILENAME_PREFIXES: &[( ("overrides", |q| &mut q.overrides), ("redactions", |q| &mut q.redactions), ("runnables", |q| &mut q.runnables), + ("textobjects", |q| &mut q.text_objects), ]; /// Tree-sitter language queries for a given language. @@ -195,6 +196,7 @@ pub struct LanguageQueries { pub overrides: Option>, pub redactions: Option>, pub runnables: Option>, + pub text_objects: Option>, } #[derive(Clone, Default)] diff --git a/crates/language/src/syntax_map.rs b/crates/language/src/syntax_map.rs index 1208925542..76c6dc75e3 100644 --- a/crates/language/src/syntax_map.rs +++ b/crates/language/src/syntax_map.rs @@ -814,6 +814,23 @@ impl SyntaxSnapshot { buffer.as_rope(), self.layers_for_range(range, buffer, true), query, + TreeSitterOptions::default(), + ) + } + + pub fn matches_with_options<'a>( + &'a self, + range: Range, + buffer: &'a BufferSnapshot, + options: TreeSitterOptions, + query: fn(&Grammar) -> Option<&Query>, + ) -> SyntaxMapMatches<'a> { + SyntaxMapMatches::new( + range.clone(), + buffer.as_rope(), + self.layers_for_range(range, buffer, true), + query, + options, ) } @@ -1001,12 +1018,25 @@ impl<'a> SyntaxMapCaptures<'a> { } } +#[derive(Default)] +pub struct TreeSitterOptions { + max_start_depth: Option, +} +impl TreeSitterOptions { + pub fn max_start_depth(max_start_depth: u32) -> Self { + Self { + max_start_depth: Some(max_start_depth), + } + } +} + impl<'a> SyntaxMapMatches<'a> { fn new( range: Range, text: &'a Rope, layers: impl Iterator>, query: fn(&Grammar) -> Option<&Query>, + options: TreeSitterOptions, ) -> Self { let mut result = Self::default(); for layer in layers { @@ -1027,6 +1057,7 @@ impl<'a> SyntaxMapMatches<'a> { query_cursor.deref_mut(), ) }; + cursor.set_max_start_depth(options.max_start_depth); cursor.set_byte_range(range.clone()); let matches = cursor.matches(query, layer.node(), TextProvider(text)); diff --git a/crates/languages/src/bash/textobjects.scm b/crates/languages/src/bash/textobjects.scm new file mode 100644 index 0000000000..cca2f7d9e9 --- /dev/null +++ b/crates/languages/src/bash/textobjects.scm @@ -0,0 +1,7 @@ +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(comment) @comment.around diff --git a/crates/languages/src/c/textobjects.scm b/crates/languages/src/c/textobjects.scm new file mode 100644 index 0000000000..832dd62288 --- /dev/null +++ b/crates/languages/src/c/textobjects.scm @@ -0,0 +1,25 @@ +(declaration + declarator: (function_declarator)) @function.around + +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(preproc_function_def + value: (_) @function.inside) @function.around + +(comment) @comment.around + +(struct_specifier + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_specifier + body: (_ + "{" + [(_) ","?]* @class.inside + "}")) @class.around diff --git a/crates/languages/src/cpp/textobjects.scm b/crates/languages/src/cpp/textobjects.scm new file mode 100644 index 0000000000..11a27b8d58 --- /dev/null +++ b/crates/languages/src/cpp/textobjects.scm @@ -0,0 +1,31 @@ +(declaration + declarator: (function_declarator)) @function.around + +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(preproc_function_def + value: (_) @function.inside) @function.around + +(comment) @comment.around + +(struct_specifier + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_specifier + body: (_ + "{" + [(_) ","?]* @class.inside + "}")) @class.around + +(class_specifier + body: (_ + "{" + [(_) ":"? ";"?]* @class.inside + "}"?)) @class.around diff --git a/crates/languages/src/css/textobjects.scm b/crates/languages/src/css/textobjects.scm new file mode 100644 index 0000000000..c9c6207b85 --- /dev/null +++ b/crates/languages/src/css/textobjects.scm @@ -0,0 +1,30 @@ +(comment) @comment.around + +(rule_set + (block ( + "{" + (_)* @function.inside + "}" ))) @function.around +(keyframe_block + (block ( + "{" + (_)* @function.inside + "}" ))) @function.around + +(media_statement + (block ( + "{" + (_)* @class.inside + "}" ))) @class.around + +(supports_statement + (block ( + "{" + (_)* @class.inside + "}" ))) @class.around + +(keyframes_statement + (keyframe_block_list ( + "{" + (_)* @class.inside + "}" ))) @class.around diff --git a/crates/languages/src/go/textobjects.scm b/crates/languages/src/go/textobjects.scm new file mode 100644 index 0000000000..eb4f3a0050 --- /dev/null +++ b/crates/languages/src/go/textobjects.scm @@ -0,0 +1,25 @@ +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(type_declaration + (type_spec (struct_type (field_declaration_list ( + "{" + (_)* @class.inside + "}")?)))) @class.around + +(type_declaration + (type_spec (interface_type + (_)* @class.inside))) @class.around + +(type_declaration) @class.around + +(comment)+ @comment.around diff --git a/crates/languages/src/javascript/textobjects.scm b/crates/languages/src/javascript/textobjects.scm new file mode 100644 index 0000000000..1a273ddb50 --- /dev/null +++ b/crates/languages/src/javascript/textobjects.scm @@ -0,0 +1,51 @@ +(comment)+ @comment.around + +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(function_expression + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function) @function.around + +(generator_function + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(generator_function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(class_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(class + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around diff --git a/crates/languages/src/json/textobjects.scm b/crates/languages/src/json/textobjects.scm new file mode 100644 index 0000000000..81fd20245b --- /dev/null +++ b/crates/languages/src/json/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment.around diff --git a/crates/languages/src/jsonc/textobjects.scm b/crates/languages/src/jsonc/textobjects.scm new file mode 100644 index 0000000000..81fd20245b --- /dev/null +++ b/crates/languages/src/jsonc/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment.around diff --git a/crates/languages/src/markdown/textobjects.scm b/crates/languages/src/markdown/textobjects.scm new file mode 100644 index 0000000000..e0f76c5365 --- /dev/null +++ b/crates/languages/src/markdown/textobjects.scm @@ -0,0 +1,3 @@ +(section + (atx_heading) + (_)* @class.inside) @class.around diff --git a/crates/languages/src/python/textobjects.scm b/crates/languages/src/python/textobjects.scm new file mode 100644 index 0000000000..abd28ab75a --- /dev/null +++ b/crates/languages/src/python/textobjects.scm @@ -0,0 +1,7 @@ +(comment)+ @comment.around + +(function_definition + body: (_) @function.inside) @function.around + +(class_definition + body: (_) @class.inside) @class.around diff --git a/crates/languages/src/rust/outline.scm b/crates/languages/src/rust/outline.scm index 3012995e2a..4299a01f19 100644 --- a/crates/languages/src/rust/outline.scm +++ b/crates/languages/src/rust/outline.scm @@ -15,11 +15,7 @@ (visibility_modifier)? @context name: (_) @name) @item -(impl_item - "impl" @context - trait: (_)? @name - "for"? @context - type: (_) @name +(function_item body: (_ "{" @open (_)* "}" @close)) @item (trait_item diff --git a/crates/languages/src/rust/textobjects.scm b/crates/languages/src/rust/textobjects.scm new file mode 100644 index 0000000000..4e7e7fa0cd --- /dev/null +++ b/crates/languages/src/rust/textobjects.scm @@ -0,0 +1,51 @@ +; functions +(function_signature_item) @function.around + +(function_item + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +; classes +(struct_item + body: (_ + ["{" "("]? + [(_) ","?]* @class.inside + ["}" ")"]? )) @class.around + +(enum_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(union_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(trait_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(impl_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(mod_item + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +; comments + +(line_comment)+ @comment.around + +(block_comment) @comment.around diff --git a/crates/languages/src/tsx/textobjects.scm b/crates/languages/src/tsx/textobjects.scm new file mode 100644 index 0000000000..836fed35ba --- /dev/null +++ b/crates/languages/src/tsx/textobjects.scm @@ -0,0 +1,79 @@ +(comment)+ @comment.around + +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(function_expression + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function) @function.around +(function_signature) @function.around + +(generator_function + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(generator_function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(class_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(class + body: (_ + "{" + (_)* @class.inside + "}" )) @class.around + +(interface_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(enum_declaration + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(ambient_declaration + (module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" ))) @class.around + +(internal_module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(type_alias_declaration) @class.around diff --git a/crates/languages/src/typescript/textobjects.scm b/crates/languages/src/typescript/textobjects.scm new file mode 100644 index 0000000000..836fed35ba --- /dev/null +++ b/crates/languages/src/typescript/textobjects.scm @@ -0,0 +1,79 @@ +(comment)+ @comment.around + +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(function_expression + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function + body: (statement_block + "{" + (_)* @function.inside + "}")) @function.around + +(arrow_function) @function.around +(function_signature) @function.around + +(generator_function + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(generator_function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(class_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(class + body: (_ + "{" + (_)* @class.inside + "}" )) @class.around + +(interface_declaration + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(enum_declaration + body: (_ + "{" + [(_) ","?]* @class.inside + "}" )) @class.around + +(ambient_declaration + (module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" ))) @class.around + +(internal_module + body: (_ + "{" + [(_) ";"?]* @class.inside + "}" )) @class.around + +(type_alias_declaration) @class.around diff --git a/crates/languages/src/yaml/textobjects.scm b/crates/languages/src/yaml/textobjects.scm new file mode 100644 index 0000000000..5262b7e232 --- /dev/null +++ b/crates/languages/src/yaml/textobjects.scm @@ -0,0 +1 @@ +(comment)+ @comment diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index f1434b6d59..461498d00d 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -3441,6 +3441,30 @@ impl MultiBufferSnapshot { }) } + pub fn excerpt_before(&self, id: ExcerptId) -> Option> { + let start_locator = self.excerpt_locator_for_id(id); + let mut cursor = self.excerpts.cursor::>(&()); + cursor.seek(&Some(start_locator), Bias::Left, &()); + cursor.prev(&()); + let excerpt = cursor.item()?; + Some(MultiBufferExcerpt { + excerpt, + excerpt_offset: 0, + }) + } + + pub fn excerpt_after(&self, id: ExcerptId) -> Option> { + let start_locator = self.excerpt_locator_for_id(id); + let mut cursor = self.excerpts.cursor::>(&()); + cursor.seek(&Some(start_locator), Bias::Left, &()); + cursor.next(&()); + let excerpt = cursor.item()?; + Some(MultiBufferExcerpt { + excerpt, + excerpt_offset: 0, + }) + } + pub fn excerpt_boundaries_in_range( &self, range: R, @@ -4689,6 +4713,26 @@ impl<'a> MultiBufferExcerpt<'a> { } } + pub fn id(&self) -> ExcerptId { + self.excerpt.id + } + + pub fn start_anchor(&self) -> Anchor { + Anchor { + buffer_id: Some(self.excerpt.buffer_id), + excerpt_id: self.excerpt.id, + text_anchor: self.excerpt.range.context.start, + } + } + + pub fn end_anchor(&self) -> Anchor { + Anchor { + buffer_id: Some(self.excerpt.buffer_id), + excerpt_id: self.excerpt.id, + text_anchor: self.excerpt.range.context.end, + } + } + pub fn buffer(&self) -> &'a BufferSnapshot { &self.excerpt.buffer } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 9c770fb63f..eb6e8464a3 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -11,6 +11,7 @@ use language::{CharKind, Point, Selection, SelectionGoal}; use multi_buffer::MultiBufferRow; use serde::Deserialize; use std::ops::Range; +use workspace::searchable::Direction; use crate::{ normal::mark, @@ -104,6 +105,16 @@ pub enum Motion { WindowTop, WindowMiddle, WindowBottom, + NextSectionStart, + NextSectionEnd, + PreviousSectionStart, + PreviousSectionEnd, + NextMethodStart, + NextMethodEnd, + PreviousMethodStart, + PreviousMethodEnd, + NextComment, + PreviousComment, // we don't have a good way to run a search synchronously, so // we handle search motions by running the search async and then @@ -269,6 +280,16 @@ actions!( WindowTop, WindowMiddle, WindowBottom, + NextSectionStart, + NextSectionEnd, + PreviousSectionStart, + PreviousSectionEnd, + NextMethodStart, + NextMethodEnd, + PreviousMethodStart, + PreviousMethodEnd, + NextComment, + PreviousComment, ] ); @@ -454,6 +475,37 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, &WindowBottom, cx| { vim.motion(Motion::WindowBottom, cx) }); + + Vim::action(editor, cx, |vim, &PreviousSectionStart, cx| { + vim.motion(Motion::PreviousSectionStart, cx) + }); + Vim::action(editor, cx, |vim, &NextSectionStart, cx| { + vim.motion(Motion::NextSectionStart, cx) + }); + Vim::action(editor, cx, |vim, &PreviousSectionEnd, cx| { + vim.motion(Motion::PreviousSectionEnd, cx) + }); + Vim::action(editor, cx, |vim, &NextSectionEnd, cx| { + vim.motion(Motion::NextSectionEnd, cx) + }); + Vim::action(editor, cx, |vim, &PreviousMethodStart, cx| { + vim.motion(Motion::PreviousMethodStart, cx) + }); + Vim::action(editor, cx, |vim, &NextMethodStart, cx| { + vim.motion(Motion::NextMethodStart, cx) + }); + Vim::action(editor, cx, |vim, &PreviousMethodEnd, cx| { + vim.motion(Motion::PreviousMethodEnd, cx) + }); + Vim::action(editor, cx, |vim, &NextMethodEnd, cx| { + vim.motion(Motion::NextMethodEnd, cx) + }); + Vim::action(editor, cx, |vim, &NextComment, cx| { + vim.motion(Motion::NextComment, cx) + }); + Vim::action(editor, cx, |vim, &PreviousComment, cx| { + vim.motion(Motion::PreviousComment, cx) + }); } impl Vim { @@ -536,6 +588,16 @@ impl Motion { | WindowTop | WindowMiddle | WindowBottom + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | Jump { line: true, .. } => true, EndOfLine { .. } | Matching @@ -607,6 +669,16 @@ impl Motion { | NextLineStart | PreviousLineStart | ZedSearchResult { .. } + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | Jump { .. } => false, } } @@ -652,6 +724,16 @@ impl Motion { | FirstNonWhitespace { .. } | FindBackward { .. } | Jump { .. } + | NextSectionStart + | NextSectionEnd + | PreviousSectionStart + | PreviousSectionEnd + | NextMethodStart + | NextMethodEnd + | PreviousMethodStart + | PreviousMethodEnd + | NextComment + | PreviousComment | ZedSearchResult { .. } => false, RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => { motion.inclusive() @@ -867,6 +949,47 @@ impl Motion { return None; } } + NextSectionStart => ( + section_motion(map, point, times, Direction::Next, true), + SelectionGoal::None, + ), + NextSectionEnd => ( + section_motion(map, point, times, Direction::Next, false), + SelectionGoal::None, + ), + PreviousSectionStart => ( + section_motion(map, point, times, Direction::Prev, true), + SelectionGoal::None, + ), + PreviousSectionEnd => ( + section_motion(map, point, times, Direction::Prev, false), + SelectionGoal::None, + ), + + NextMethodStart => ( + method_motion(map, point, times, Direction::Next, true), + SelectionGoal::None, + ), + NextMethodEnd => ( + method_motion(map, point, times, Direction::Next, false), + SelectionGoal::None, + ), + PreviousMethodStart => ( + method_motion(map, point, times, Direction::Prev, true), + SelectionGoal::None, + ), + PreviousMethodEnd => ( + method_motion(map, point, times, Direction::Prev, false), + SelectionGoal::None, + ), + NextComment => ( + comment_motion(map, point, times, Direction::Next), + SelectionGoal::None, + ), + PreviousComment => ( + comment_motion(map, point, times, Direction::Prev), + SelectionGoal::None, + ), }; (new_point != point || infallible).then_some((new_point, goal)) @@ -2129,6 +2252,231 @@ fn window_bottom( } } +fn method_motion( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + times: usize, + direction: Direction, + is_start: bool, +) -> DisplayPoint { + let Some((_, _, buffer)) = map.buffer_snapshot.as_singleton() else { + return display_point; + }; + + for _ in 0..times { + let point = map.display_point_to_point(display_point, Bias::Left); + let offset = point.to_offset(&map.buffer_snapshot); + let range = if direction == Direction::Prev { + 0..offset + } else { + offset..buffer.len() + }; + + let possibilities = buffer + .text_object_ranges(range, language::TreeSitterOptions::max_start_depth(4)) + .filter_map(|(range, object)| { + if !matches!(object, language::TextObject::AroundFunction) { + return None; + } + + let relevant = if is_start { range.start } else { range.end }; + if direction == Direction::Prev && relevant < offset { + Some(relevant) + } else if direction == Direction::Next && relevant > offset + 1 { + Some(relevant) + } else { + None + } + }); + + let dest = if direction == Direction::Prev { + possibilities.max().unwrap_or(offset) + } else { + possibilities.min().unwrap_or(offset) + }; + let new_point = map.clip_point(dest.to_display_point(&map), Bias::Left); + if new_point == display_point { + break; + } + display_point = new_point; + } + display_point +} + +fn comment_motion( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + times: usize, + direction: Direction, +) -> DisplayPoint { + let Some((_, _, buffer)) = map.buffer_snapshot.as_singleton() else { + return display_point; + }; + + for _ in 0..times { + let point = map.display_point_to_point(display_point, Bias::Left); + let offset = point.to_offset(&map.buffer_snapshot); + let range = if direction == Direction::Prev { + 0..offset + } else { + offset..buffer.len() + }; + + let possibilities = buffer + .text_object_ranges(range, language::TreeSitterOptions::max_start_depth(6)) + .filter_map(|(range, object)| { + if !matches!(object, language::TextObject::AroundComment) { + return None; + } + + let relevant = if direction == Direction::Prev { + range.start + } else { + range.end + }; + if direction == Direction::Prev && relevant < offset { + Some(relevant) + } else if direction == Direction::Next && relevant > offset + 1 { + Some(relevant) + } else { + None + } + }); + + let dest = if direction == Direction::Prev { + possibilities.max().unwrap_or(offset) + } else { + possibilities.min().unwrap_or(offset) + }; + let new_point = map.clip_point(dest.to_display_point(&map), Bias::Left); + if new_point == display_point { + break; + } + display_point = new_point; + } + + display_point +} + +fn section_motion( + map: &DisplaySnapshot, + mut display_point: DisplayPoint, + times: usize, + direction: Direction, + is_start: bool, +) -> DisplayPoint { + if let Some((_, _, buffer)) = map.buffer_snapshot.as_singleton() { + for _ in 0..times { + let offset = map + .display_point_to_point(display_point, Bias::Left) + .to_offset(&map.buffer_snapshot); + let range = if direction == Direction::Prev { + 0..offset + } else { + offset..buffer.len() + }; + + // we set a max start depth here because we want a section to only be "top level" + // similar to vim's default of '{' in the first column. + // (and without it, ]] at the start of editor.rs is -very- slow) + let mut possibilities = buffer + .text_object_ranges(range, language::TreeSitterOptions::max_start_depth(3)) + .filter(|(_, object)| { + matches!( + object, + language::TextObject::AroundClass | language::TextObject::AroundFunction + ) + }) + .collect::>(); + possibilities.sort_by_key(|(range_a, _)| range_a.start); + let mut prev_end = None; + let possibilities = possibilities.into_iter().filter_map(|(range, t)| { + if t == language::TextObject::AroundFunction + && prev_end.is_some_and(|prev_end| prev_end > range.start) + { + return None; + } + prev_end = Some(range.end); + + let relevant = if is_start { range.start } else { range.end }; + if direction == Direction::Prev && relevant < offset { + Some(relevant) + } else if direction == Direction::Next && relevant > offset + 1 { + Some(relevant) + } else { + None + } + }); + + let offset = if direction == Direction::Prev { + possibilities.max().unwrap_or(0) + } else { + possibilities.min().unwrap_or(buffer.len()) + }; + + let new_point = map.clip_point(offset.to_display_point(&map), Bias::Left); + if new_point == display_point { + break; + } + display_point = new_point; + } + return display_point; + }; + + for _ in 0..times { + let point = map.display_point_to_point(display_point, Bias::Left); + let Some(excerpt) = map.buffer_snapshot.excerpt_containing(point..point) else { + return display_point; + }; + let next_point = match (direction, is_start) { + (Direction::Prev, true) => { + let mut start = excerpt.start_anchor().to_display_point(&map); + if start >= display_point && start.row() > DisplayRow(0) { + let Some(excerpt) = map.buffer_snapshot.excerpt_before(excerpt.id()) else { + return display_point; + }; + start = excerpt.start_anchor().to_display_point(&map); + } + start + } + (Direction::Prev, false) => { + let mut start = excerpt.start_anchor().to_display_point(&map); + if start.row() > DisplayRow(0) { + *start.row_mut() -= 1; + } + map.clip_point(start, Bias::Left) + } + (Direction::Next, true) => { + let mut end = excerpt.end_anchor().to_display_point(&map); + *end.row_mut() += 1; + map.clip_point(end, Bias::Right) + } + (Direction::Next, false) => { + let mut end = excerpt.end_anchor().to_display_point(&map); + *end.column_mut() = 0; + if end <= display_point { + *end.row_mut() += 1; + let point_end = map.display_point_to_point(end, Bias::Right); + let Some(excerpt) = + map.buffer_snapshot.excerpt_containing(point_end..point_end) + else { + return display_point; + }; + end = excerpt.end_anchor().to_display_point(&map); + *end.column_mut() = 0; + } + end + } + }; + if next_point == display_point { + break; + } + display_point = next_point; + } + + display_point +} + #[cfg(test)] mod test { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 7ed97358ff..380acc896a 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -1,6 +1,10 @@ use std::ops::Range; -use crate::{motion::right, state::Mode, Vim}; +use crate::{ + motion::right, + state::{Mode, Operator}, + Vim, +}; use editor::{ display_map::{DisplaySnapshot, ToDisplayPoint}, movement::{self, FindRange}, @@ -10,7 +14,7 @@ use editor::{ use itertools::Itertools; use gpui::{actions, impl_actions, ViewContext}; -use language::{BufferSnapshot, CharKind, Point, Selection}; +use language::{BufferSnapshot, CharKind, Point, Selection, TextObject, TreeSitterOptions}; use multi_buffer::MultiBufferRow; use serde::Deserialize; @@ -30,6 +34,9 @@ pub enum Object { Argument, IndentObj { include_below: bool }, Tag, + Method, + Class, + Comment, } #[derive(Clone, Deserialize, PartialEq)] @@ -61,7 +68,10 @@ actions!( CurlyBrackets, AngleBrackets, Argument, - Tag + Tag, + Method, + Class, + Comment ] ); @@ -107,6 +117,18 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, |vim, _: &Argument, cx| { vim.object(Object::Argument, cx) }); + Vim::action(editor, cx, |vim, _: &Method, cx| { + vim.object(Object::Method, cx) + }); + Vim::action(editor, cx, |vim, _: &Class, cx| { + vim.object(Object::Class, cx) + }); + Vim::action(editor, cx, |vim, _: &Comment, cx| { + if !matches!(vim.active_operator(), Some(Operator::Object { .. })) { + vim.push_operator(Operator::Object { around: true }, cx); + } + vim.object(Object::Comment, cx) + }); Vim::action( editor, cx, @@ -144,6 +166,9 @@ impl Object { | Object::CurlyBrackets | Object::SquareBrackets | Object::Argument + | Object::Method + | Object::Class + | Object::Comment | Object::IndentObj { .. } => true, } } @@ -162,12 +187,15 @@ impl Object { | Object::Parentheses | Object::SquareBrackets | Object::Tag + | Object::Method + | Object::Class + | Object::Comment | Object::CurlyBrackets | Object::AngleBrackets => true, } } - pub fn target_visual_mode(self, current_mode: Mode) -> Mode { + pub fn target_visual_mode(self, current_mode: Mode, around: bool) -> Mode { match self { Object::Word { .. } | Object::Sentence @@ -186,8 +214,16 @@ impl Object { | Object::AngleBrackets | Object::VerticalBars | Object::Tag + | Object::Comment | Object::Argument | Object::IndentObj { .. } => Mode::Visual, + Object::Method | Object::Class => { + if around { + Mode::VisualLine + } else { + Mode::Visual + } + } Object::Paragraph => Mode::VisualLine, } } @@ -238,6 +274,33 @@ impl Object { Object::AngleBrackets => { surrounding_markers(map, relative_to, around, self.is_multiline(), '<', '>') } + Object::Method => text_object( + map, + relative_to, + if around { + TextObject::AroundFunction + } else { + TextObject::InsideFunction + }, + ), + Object::Comment => text_object( + map, + relative_to, + if around { + TextObject::AroundComment + } else { + TextObject::InsideComment + }, + ), + Object::Class => text_object( + map, + relative_to, + if around { + TextObject::AroundClass + } else { + TextObject::InsideClass + }, + ), Object::Argument => argument(map, relative_to, around), Object::IndentObj { include_below } => indent(map, relative_to, around, include_below), } @@ -441,6 +504,47 @@ fn around_next_word( Some(start..end) } +fn text_object( + map: &DisplaySnapshot, + relative_to: DisplayPoint, + target: TextObject, +) -> Option> { + let snapshot = &map.buffer_snapshot; + let offset = relative_to.to_offset(map, Bias::Left); + + let excerpt = snapshot.excerpt_containing(offset..offset)?; + let buffer = excerpt.buffer(); + + let mut matches: Vec> = buffer + .text_object_ranges(offset..offset, TreeSitterOptions::default()) + .filter_map(|(r, m)| if m == target { Some(r) } else { None }) + .collect(); + matches.sort_by_key(|r| (r.end - r.start)); + if let Some(range) = matches.first() { + return Some(range.start.to_display_point(map)..range.end.to_display_point(map)); + } + + let around = target.around()?; + let mut matches: Vec> = buffer + .text_object_ranges(offset..offset, TreeSitterOptions::default()) + .filter_map(|(r, m)| if m == around { Some(r) } else { None }) + .collect(); + matches.sort_by_key(|r| (r.end - r.start)); + let around_range = matches.first()?; + + let mut matches: Vec> = buffer + .text_object_ranges(around_range.clone(), TreeSitterOptions::default()) + .filter_map(|(r, m)| if m == target { Some(r) } else { None }) + .collect(); + matches.sort_by_key(|r| r.start); + if let Some(range) = matches.first() { + if !range.is_empty() { + return Some(range.start.to_display_point(map)..range.end.to_display_point(map)); + } + } + return Some(around_range.start.to_display_point(map)..around_range.end.to_display_point(map)); +} + fn argument( map: &DisplaySnapshot, relative_to: DisplayPoint, diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index 813be6dda1..8d2b31a1de 100644 --- a/crates/vim/src/visual.rs +++ b/crates/vim/src/visual.rs @@ -308,7 +308,7 @@ impl Vim { if let Some(Operator::Object { around }) = self.active_operator() { self.pop_operator(cx); let current_mode = self.mode; - let target_mode = object.target_visual_mode(current_mode); + let target_mode = object.target_visual_mode(current_mode, around); if target_mode != current_mode { self.switch_mode(target_mode, true, cx); } diff --git a/docs/src/extensions/languages.md b/docs/src/extensions/languages.md index 0995ed97fd..fc2c42c74a 100644 --- a/docs/src/extensions/languages.md +++ b/docs/src/extensions/languages.md @@ -69,6 +69,7 @@ several features: - Syntax overrides - Text redactions - Runnable code detection +- Selecting classes, functions, etc. The following sections elaborate on how [Tree-sitter queries](https://tree-sitter.github.io/tree-sitter/using-parsers#query-syntax) enable these features in Zed, using [JSON syntax](https://www.json.org/json-en.html) as a guiding example. @@ -259,6 +260,44 @@ For example, in JavaScript, we also disable auto-closing of single quotes within (comment) @comment.inclusive ``` +### Text objects + +The `textobjects.scm` file defines rules for navigating by text objects. This was added in Zed v0.165 and is currently used only in Vim mode. + +Vim provides two levels of granularity for navigating around files. Section-by-section with `[]` etc., and method-by-method with `]m` etc. Even languages that don't support functions and classes can work well by defining similar concepts. For example CSS defines a rule-set as a method, and a media-query as a class. + +For languages with closures, these typically should not count as functions in Zed. This is best-effort however, as languages like Javascript do not syntactically differentiate syntactically between closures and top-level function declarations. + +For languages with declarations like C, provide queries that match `@class.around` or `@function.around`. The `if` and `ic` text objects will default to these if there is no inside. + +If you are not sure what to put in textobjects.scm, both [nvim-treesitter-textobjects](https://github.com/nvim-treesitter/nvim-treesitter-textobjects), and the [Helix editor](https://github.com/helix-editor/helix) have queries for many languages. You can refer to the Zed [built-in languages](https://github.com/zed-industries/zed/tree/main/crates/languages/src) to see how to adapt these. + +| Capture | Description | Vim mode | +| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------ | +| @function.around | An entire function definition or equivalent small section of a file. | `[m`, `]m`, `[M`,`]M` motions. `af` text object | +| @function.inside | The function body (the stuff within the braces). | `if` text object | +| @class.around | An entire class definition or equivalent large section of a file. | `[[`, `]]`, `[]`, `][` motions. `ac` text object | +| @class.inside | The contents of a class definition. | `ic` text object | +| @comment.around | An entire comment (e.g. all adjacent line comments, or a block comment) | `gc` text object | +| @comment.inside | The contents of a comment | `igc` text object (rarely supported) | + +For example: + +```scheme +; include only the content of the method in the function +(method_definition + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +; match function.around for declarations with no body +(function_signature_item) @function.around + +; join all adjacent comments into one +(comment)+ @comment.around +``` + ### Text redactions The `redactions.scm` file defines text redaction rules. When collaborating and sharing your screen, it makes sure that certain syntax nodes are rendered in a redacted mode to avoid them from leaking. diff --git a/docs/src/vim.md b/docs/src/vim.md index 254c5a0934..c0a7fed2e2 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -79,12 +79,41 @@ The following commands use the language server to help you navigate and refactor ### Treesitter -Treesitter is a powerful tool that Zed uses to understand the structure of your code. These commands help you navigate your code semantically. +Treesitter is a powerful tool that Zed uses to understand the structure of your code. Zed provides motions that change the current cursor position, and text objects that can be used as the target of actions. -| Command | Default Shortcut | -| ---------------------------- | ---------------- | -| Select a smaller syntax node | `] x` | -| Select a larger syntax node | `[ x` | +| Command | Default Shortcut | +| ------------------------------- | --------------------------- | +| Go to next/previous method | `] m` / `[ m` | +| Go to next/previous method end | `] M` / `[ M` | +| Go to next/previous section | `] ]` / `[ [` | +| Go to next/previous section end | `] [` / `[ ]` | +| Go to next/previous comment | `] /`, `] *` / `[ /`, `[ *` | +| Select a larger syntax node | `[ x` | +| Select a larger syntax node | `[ x` | + +| Text Objects | Default Shortcut | +| ---------------------------------------------------------- | ---------------- | +| Around a class, definition, etc. | `a c` | +| Inside a class, definition, etc. | `i c` | +| Around a function, method etc. | `a f` | +| Inside a function, method, etc. | `i f` | +| A comment | `g c` | +| An argument, or list item, etc. | `i a` | +| An argument, or list item, etc. (including trailing comma) | `a a` | +| Around an HTML-like tag | `i a` | +| Inside an HTML-like tag | `i a` | +| The current indent level, and one line before and after | `a I` | +| The current indent level, and one line before | `a i` | +| The current indent level | `i i` | + +Note that the definitions for the targets of the `[m` family of motions are the same as the +boundaries defined by `af`. The targets of the `[[` are the same as those defined by `ac`, though +if there are no classes, then functions are also used. Similarly `gc` is used to find `[ /`. `g c` + +The definition of functions, classes and comments is language dependent, and support can be added +to extensions by adding a [`textobjects.scm`]. The definition of arguments and tags operates at +the tree-sitter level, but looks for certain patterns in the parse tree and is not currently configurable +per language. ### Multi cursor From 41a973b13f4db40627f18cfe2b496ce2fe6cc7f5 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 3 Dec 2024 17:57:39 +0000 Subject: [PATCH 127/215] Publish theme json schema v0.2.0 (#21428) Fix theme json schema so `./script/import-themes print-schema` works again Update schema to reflect current structs ([diff](https://gist.github.com/notpeter/26e6d0939985f542e8492458442ac62a/revisions?diff=unified&w=)) https://zed.dev/schema/themes/v0.2.0.json --- assets/themes/andromeda/andromeda.json | 2 +- assets/themes/atelier/atelier.json | 2 +- assets/themes/ayu/ayu.json | 2 +- assets/themes/gruvbox/gruvbox.json | 2 +- assets/themes/one/one.json | 2 +- assets/themes/rose_pine/rose_pine.json | 2 +- assets/themes/sandcastle/sandcastle.json | 2 +- assets/themes/solarized/solarized.json | 2 +- assets/themes/summercamp/summercamp.json | 2 +- crates/theme_importer/src/main.rs | 78 +++++++++++++----------- docs/src/extensions/themes.md | 4 +- script/import-themes | 2 +- 12 files changed, 55 insertions(+), 47 deletions(-) diff --git a/assets/themes/andromeda/andromeda.json b/assets/themes/andromeda/andromeda.json index 532d013b36..633b5c308f 100644 --- a/assets/themes/andromeda/andromeda.json +++ b/assets/themes/andromeda/andromeda.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Andromeda", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/atelier/atelier.json b/assets/themes/atelier/atelier.json index 1bf4878b5a..f72e8e84ee 100644 --- a/assets/themes/atelier/atelier.json +++ b/assets/themes/atelier/atelier.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Atelier", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index 00fb6deb91..d511ebf84a 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Ayu", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index a56ea7d046..908ce3a28a 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Gruvbox", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/one/one.json b/assets/themes/one/one.json index 0519ead392..daa09f8995 100644 --- a/assets/themes/one/one.json +++ b/assets/themes/one/one.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "One", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/rose_pine/rose_pine.json b/assets/themes/rose_pine/rose_pine.json index 5b66c5ed34..2ff97da117 100644 --- a/assets/themes/rose_pine/rose_pine.json +++ b/assets/themes/rose_pine/rose_pine.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Rosé Pine", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/sandcastle/sandcastle.json b/assets/themes/sandcastle/sandcastle.json index b5239b0a55..ba9e6f50fd 100644 --- a/assets/themes/sandcastle/sandcastle.json +++ b/assets/themes/sandcastle/sandcastle.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Sandcastle", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/solarized/solarized.json b/assets/themes/solarized/solarized.json index 7bd0c53f52..fe86793cdc 100644 --- a/assets/themes/solarized/solarized.json +++ b/assets/themes/solarized/solarized.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Solarized", "author": "Zed Industries", "themes": [ diff --git a/assets/themes/summercamp/summercamp.json b/assets/themes/summercamp/summercamp.json index 84423a8600..c2206f9aab 100644 --- a/assets/themes/summercamp/summercamp.json +++ b/assets/themes/summercamp/summercamp.json @@ -1,5 +1,5 @@ { - "$schema": "https://zed.dev/schema/themes/v0.1.0.json", + "$schema": "https://zed.dev/schema/themes/v0.2.0.json", "name": "Summercamp", "author": "Zed Industries", "themes": [ diff --git a/crates/theme_importer/src/main.rs b/crates/theme_importer/src/main.rs index d92966ae24..db287956c5 100644 --- a/crates/theme_importer/src/main.rs +++ b/crates/theme_importer/src/main.rs @@ -19,6 +19,8 @@ use theme::{Appearance, AppearanceContent, ThemeFamilyContent}; use crate::vscode::VsCodeTheme; use crate::vscode::VsCodeThemeConverter; +const ZED_THEME_SCHEMA_URL: &str = "https://zed.dev/public/schema/themes/v0.2.0.json"; + #[derive(Debug, Deserialize)] struct FamilyMetadata { pub name: String, @@ -69,34 +71,53 @@ pub struct ThemeMetadata { #[derive(Parser)] #[command(author, version, about, long_about = None)] struct Args { - /// The path to the theme to import. - theme_path: PathBuf, - - /// Whether to warn when values are missing from the theme. - #[arg(long)] - warn_on_missing: bool, - - /// The path to write the output to. - #[arg(long, short)] - output: Option, - #[command(subcommand)] - command: Option, + command: Command, } -#[derive(Subcommand)] +#[derive(PartialEq, Subcommand)] enum Command { /// Prints the JSON schema for a theme. PrintSchema, + /// Converts a VSCode theme to Zed format [default] + Convert { + /// The path to the theme to import. + theme_path: PathBuf, + + /// Whether to warn when values are missing from the theme. + #[arg(long)] + warn_on_missing: bool, + + /// The path to write the output to. + #[arg(long, short)] + output: Option, + }, } fn main() -> Result<()> { let args = Args::parse(); + match args.command { + Command::PrintSchema => { + let theme_family_schema = schema_for!(ThemeFamilyContent); + println!( + "{}", + serde_json::to_string_pretty(&theme_family_schema).unwrap() + ); + Ok(()) + } + Command::Convert { + theme_path, + warn_on_missing, + output, + } => convert(theme_path, output, warn_on_missing), + } +} + +fn convert(theme_file_path: PathBuf, output: Option, warn_on_missing: bool) -> Result<()> { let log_config = { let mut config = simplelog::ConfigBuilder::new(); - - if !args.warn_on_missing { + if !warn_on_missing { config.add_filter_ignore_str("theme_printer"); } @@ -111,28 +132,11 @@ fn main() -> Result<()> { ) .expect("could not initialize logger"); - if let Some(command) = args.command { - match command { - Command::PrintSchema => { - let theme_family_schema = schema_for!(ThemeFamilyContent); - - println!( - "{}", - serde_json::to_string_pretty(&theme_family_schema).unwrap() - ); - - return Ok(()); - } - } - } - - let theme_file_path = args.theme_path; - let theme_file = match File::open(&theme_file_path) { Ok(file) => file, Err(err) => { log::info!("Failed to open file at path: {:?}", theme_file_path); - return Err(err)?; + return Err(err.into()); } }; @@ -148,10 +152,14 @@ fn main() -> Result<()> { let converter = VsCodeThemeConverter::new(vscode_theme, theme_metadata, IndexMap::new()); let theme = converter.convert()?; - + let mut theme = serde_json::to_value(theme).unwrap(); + theme.as_object_mut().unwrap().insert( + "$schema".to_string(), + serde_json::Value::String(ZED_THEME_SCHEMA_URL.to_string()), + ); let theme_json = serde_json::to_string_pretty(&theme).unwrap(); - if let Some(output) = args.output { + if let Some(output) = output { let mut file = File::create(output)?; file.write_all(theme_json.as_bytes())?; } else { diff --git a/docs/src/extensions/themes.md b/docs/src/extensions/themes.md index 4737a99a3e..ecdbdace59 100644 --- a/docs/src/extensions/themes.md +++ b/docs/src/extensions/themes.md @@ -2,13 +2,13 @@ The `themes` directory in an extension should contain one or more theme files. -Each theme file should adhere to the JSON schema specified at [`https://zed.dev/schema/themes/v0.1.0.json`](https://zed.dev/schema/themes/v0.1.0.json). +Each theme file should adhere to the JSON schema specified at [`https://zed.dev/schema/themes/v0.2.0.json`](https://zed.dev/schema/themes/v0.2.0.json). See [this blog post](https://zed.dev/blog/user-themes-now-in-preview) for more details about creating themes. ## Theme JSON Structure -The structure of a Zed theme is defined in the [Zed Theme JSON Schema](https://zed.dev/schema/themes/v0.1.0.json). +The structure of a Zed theme is defined in the [Zed Theme JSON Schema](https://zed.dev/schema/themes/v0.2.0.json). A Zed theme consists of a Theme Family object including: diff --git a/script/import-themes b/script/import-themes index ce9ce9ef12..8f07df2ef3 100755 --- a/script/import-themes +++ b/script/import-themes @@ -1,3 +1,3 @@ #!/bin/bash -cargo run -p theme_importer +cargo run -p theme_importer -- "$@" From afb253b406c40d1bb0a7ec2961be93523130e460 Mon Sep 17 00:00:00 2001 From: Jason Lee Date: Wed, 4 Dec 2024 02:03:53 +0800 Subject: [PATCH 128/215] ui: Ensure `Label` with `single_line` set does not wrap (#21444) Release Notes: - N/A --- Split from #21438, this change for make sure the `single_line` mode Label will not be wrap. --------- Co-authored-by: Marshall Bowers --- .../src/components/label/highlighted_label.rs | 5 +++++ crates/ui/src/components/label/label.rs | 20 ++++++------------- crates/ui/src/components/label/label_like.rs | 11 ++++++++++ 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/crates/ui/src/components/label/highlighted_label.rs b/crates/ui/src/components/label/highlighted_label.rs index f961713956..0e6cc26b18 100644 --- a/crates/ui/src/components/label/highlighted_label.rs +++ b/crates/ui/src/components/label/highlighted_label.rs @@ -65,6 +65,11 @@ impl LabelCommon for HighlightedLabel { self.base = self.base.underline(underline); self } + + fn single_line(mut self) -> Self { + self.base = self.base.single_line(); + self + } } pub fn highlight_ranges( diff --git a/crates/ui/src/components/label/label.rs b/crates/ui/src/components/label/label.rs index f655961841..1df33d2740 100644 --- a/crates/ui/src/components/label/label.rs +++ b/crates/ui/src/components/label/label.rs @@ -56,20 +56,6 @@ impl Label { single_line: false, } } - - /// Make the label display in a single line mode - /// - /// # Examples - /// - /// ``` - /// use ui::prelude::*; - /// - /// let my_label = Label::new("Hello, World!").single_line(); - /// ``` - pub fn single_line(mut self) -> Self { - self.single_line = true; - self - } } // Style methods. @@ -177,6 +163,12 @@ impl LabelCommon for Label { self.base = self.base.underline(underline); self } + + fn single_line(mut self) -> Self { + self.single_line = true; + self.base = self.base.single_line(); + self + } } impl RenderOnce for Label { diff --git a/crates/ui/src/components/label/label_like.rs b/crates/ui/src/components/label/label_like.rs index fd7303082a..b1c3240f5a 100644 --- a/crates/ui/src/components/label/label_like.rs +++ b/crates/ui/src/components/label/label_like.rs @@ -49,6 +49,9 @@ pub trait LabelCommon { /// Sets the alpha property of the label, overwriting the alpha value of the color. fn alpha(self, alpha: f32) -> Self; + + /// Sets the label to render as a single line. + fn single_line(self) -> Self; } #[derive(IntoElement)] @@ -63,6 +66,7 @@ pub struct LabelLike { children: SmallVec<[AnyElement; 2]>, alpha: Option, underline: bool, + single_line: bool, } impl Default for LabelLike { @@ -84,6 +88,7 @@ impl LabelLike { children: SmallVec::new(), alpha: None, underline: false, + single_line: false, } } } @@ -139,6 +144,11 @@ impl LabelCommon for LabelLike { self.alpha = Some(alpha); self } + + fn single_line(mut self) -> Self { + self.single_line = true; + self + } } impl ParentElement for LabelLike { @@ -178,6 +188,7 @@ impl RenderOnce for LabelLike { this }) .when(self.strikethrough, |this| this.line_through()) + .when(self.single_line, |this| this.whitespace_nowrap()) .text_color(color) .font_weight(self.weight.unwrap_or(settings.ui_font.weight)) .children(self.children) From 492ca219d34e56b4d4145545a6ab3d1a818f3a0e Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 12:09:44 -0800 Subject: [PATCH 129/215] Fix panic in autoclosing (#21482) Closes #14961 Release Notes: - Fixed a panic when backspacing at the start of a buffer with `always_treat_brackets_as_autoclosed` enabled. --- crates/editor/src/editor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 1e47eb46a8..88919f9295 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -4098,8 +4098,10 @@ impl Editor { if buffer.contains_str_at(selection.start, &pair.end) { let pair_start_len = pair.start.len(); - if buffer.contains_str_at(selection.start - pair_start_len, &pair.start) - { + if buffer.contains_str_at( + selection.start.saturating_sub(pair_start_len), + &pair.start, + ) { selection.start -= pair_start_len; selection.end += pair.end.len(); From b28287ce9137602957620b72aa6988a56b081de5 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 12:09:53 -0800 Subject: [PATCH 130/215] Fix panic in remove_item (#21480) In #20742 we added a call to remove_item that retain an item index over an await point. This led to a race condition that could panic if another tab was removed during that time. (cc @mgsloan) This changes the API to make it harder to misuse. Release Notes: - Fixed a panic when closing tabs containing new unsaved files --- crates/workspace/src/pane.rs | 30 ++++++++++++++---------------- crates/workspace/src/workspace.rs | 8 ++++---- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index fe6b08fd4a..a2c63addd8 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -828,9 +828,10 @@ impl Pane { pub fn close_current_preview_item(&mut self, cx: &mut ViewContext) -> Option { let item_idx = self.preview_item_idx()?; + let id = self.preview_item_id()?; let prev_active_item_index = self.active_item_index; - self.remove_item(item_idx, false, false, cx); + self.remove_item(id, false, false, cx); self.active_item_index = prev_active_item_index; if item_idx < self.items.len() { @@ -1403,13 +1404,7 @@ impl Pane { // Remove the item from the pane. pane.update(&mut cx, |pane, cx| { - if let Some(item_ix) = pane - .items - .iter() - .position(|i| i.item_id() == item_to_close.item_id()) - { - pane.remove_item(item_ix, false, true, cx); - } + pane.remove_item(item_to_close.item_id(), false, true, cx); }) .ok(); } @@ -1421,11 +1416,14 @@ impl Pane { pub fn remove_item( &mut self, - item_index: usize, + item_id: EntityId, activate_pane: bool, close_pane_if_empty: bool, cx: &mut ViewContext, ) { + let Some(item_index) = self.index_for_item_id(item_id) else { + return; + }; self._remove_item(item_index, activate_pane, close_pane_if_empty, None, cx) } @@ -1615,7 +1613,9 @@ impl Pane { .await? } Ok(1) => { - pane.update(cx, |pane, cx| pane.remove_item(item_ix, false, false, cx))?; + pane.update(cx, |pane, cx| { + pane.remove_item(item.item_id(), false, false, cx) + })?; } _ => return Ok(false), } @@ -1709,9 +1709,7 @@ impl Pane { if let Some(abs_path) = abs_path.await.ok().flatten() { pane.update(cx, |pane, cx| { if let Some(item) = pane.item_for_path(abs_path.clone(), cx) { - if let Some(idx) = pane.index_for_item(&*item) { - pane.remove_item(idx, false, false, cx); - } + pane.remove_item(item.item_id(), false, false, cx); } item.save_as(project, abs_path, cx) @@ -1777,15 +1775,15 @@ impl Pane { entry_id: ProjectEntryId, cx: &mut ViewContext, ) -> Option<()> { - let (item_index_to_delete, item_id) = self.items().enumerate().find_map(|(i, item)| { + let item_id = self.items().find_map(|item| { if item.is_singleton(cx) && item.project_entry_ids(cx).as_slice() == [entry_id] { - Some((i, item.item_id())) + Some(item.item_id()) } else { None } })?; - self.remove_item(item_index_to_delete, false, true, cx); + self.remove_item(item_id, false, true, cx); self.nav_history.remove_item(item_id); Some(()) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index a8681f22c5..c5de8822dc 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3723,7 +3723,7 @@ impl Workspace { let mut new_item = task.await?; pane.update(cx, |pane, cx| { - let mut item_ix_to_remove = None; + let mut item_to_remove = None; for (ix, item) in pane.items().enumerate() { if let Some(item) = item.to_followable_item_handle(cx) { match new_item.dedup(item.as_ref(), cx) { @@ -3733,7 +3733,7 @@ impl Workspace { break; } Some(item::Dedup::ReplaceExisting) => { - item_ix_to_remove = Some(ix); + item_to_remove = Some((ix, item.item_id())); break; } None => {} @@ -3741,8 +3741,8 @@ impl Workspace { } } - if let Some(ix) = item_ix_to_remove { - pane.remove_item(ix, false, false, cx); + if let Some((ix, id)) = item_to_remove { + pane.remove_item(id, false, false, cx); pane.add_item(new_item.boxed_clone(), false, false, Some(ix), cx); } })?; From 731e6d31f6015827d1fcdebf59f298a4c16ff547 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 12:10:02 -0800 Subject: [PATCH 131/215] Revert "macos: Add default keybind for ctrl-home / ctrl-end (#21007)" (#21476) This reverts commit 614b3b979b7373aaa6dee84dfbc824fce1a86ea8. This conflicts with the macOS `ctrl-fn-left/right` bindings for moving windows around (new in Sequoia). If you want these use: ``` { "context": "Editor", "bindings": { "ctrl-home": "editor::MoveToBeginning", "ctrl-end": "editor::MoveToEnd" } }, ``` Release Notes: - N/A --- assets/keymaps/default-macos.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index f3990cecee..71d997d2b1 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -93,8 +93,6 @@ "ctrl-e": "editor::MoveToEndOfLine", "cmd-up": "editor::MoveToBeginning", "cmd-down": "editor::MoveToEnd", - "ctrl-home": "editor::MoveToBeginning", - "ctrl-end": "editor::MoveToEnd", "shift-up": "editor::SelectUp", "ctrl-shift-p": "editor::SelectUp", "shift-down": "editor::SelectDown", From 165d50ff5b1dbf02d60ba20d53f4a0a5ee7ff26e Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 3 Dec 2024 20:27:12 +0000 Subject: [PATCH 132/215] Add openbsd netcat to script/linux (#21478) - Follow-up to: https://github.com/zed-industries/zed/pull/20751 openbsd-netcat is required for interactive SSH Remoting prompts (password, passphrase, 2fa, etc). --- script/linux | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/script/linux b/script/linux index f1fe751154..7457b8de76 100755 --- a/script/linux +++ b/script/linux @@ -37,6 +37,7 @@ if [[ -n $apt ]]; then cmake clang jq + netcat-openbsd git curl gettext-base @@ -84,12 +85,14 @@ if [[ -n $dnf ]] || [[ -n $yum ]]; then tar ) # perl used for building openssl-sys crate. See: https://docs.rs/openssl/latest/openssl/ + # openbsd-netcat is unavailable in RHEL8/9 (and nmap-ncat doesn't support sockets) if grep -qP '^ID="(fedora)' /etc/os-release; then deps+=( perl-FindBin perl-IPC-Cmd perl-File-Compare perl-File-Copy + netcat mold ) elif grep -qP '^ID="(rhel|rocky|alma|centos|ol)' /etc/os-release; then @@ -120,7 +123,7 @@ if [[ -n $dnf ]] || [[ -n $yum ]]; then fi fi - $maysudo $pkg_cmd install -y "${deps[@]}" + $maysudo "$pkg_cmd" install -y "${deps[@]}" finalize exit 0 fi @@ -145,6 +148,7 @@ if [[ -n $zyp ]]; then libzstd-devel make mold + netcat-openbsd openssl-devel sqlite3-devel tar @@ -169,6 +173,7 @@ if [[ -n $pacman ]]; then wayland libgit2 libxkbcommon-x11 + openbsd-netcat openssl zstd pkgconf @@ -198,6 +203,7 @@ if [[ -n $xbps ]]; then libxcb-devel libxkbcommon-devel libzstd-devel + openbsd-netcat openssl-devel wayland-devel vulkan-loader @@ -222,6 +228,7 @@ if [[ -n $emerge ]]; then media-libs/alsa-lib media-libs/fontconfig media-libs/vulkan-loader + net-analyzer/openbsd-netcat x11-libs/libxcb x11-libs/libxkbcommon sys-devel/mold From 88b0d3c78eb2cc4f826e508fe458588a67de1f7e Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 3 Dec 2024 15:27:58 -0500 Subject: [PATCH 133/215] markdown: Make `cx` the last parameter to the constructor (#21487) I noticed that `Markdown::new` didn't have the `cx` as the final parameter, as is conventional. This PR fixes that. Release Notes: - N/A --- crates/editor/src/hover_popover.rs | 2 +- crates/markdown/examples/markdown.rs | 2 +- crates/markdown/examples/markdown_as_child.rs | 2 +- crates/markdown/src/markdown.rs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index 006a42700b..c402132bf3 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -593,8 +593,8 @@ async fn parse_blocks( combined_text, markdown_style.clone(), Some(language_registry.clone()), - cx, fallback_language_name, + cx, ) }) .ok(); diff --git a/crates/markdown/examples/markdown.rs b/crates/markdown/examples/markdown.rs index 0514ebcf4e..26b4f83374 100644 --- a/crates/markdown/examples/markdown.rs +++ b/crates/markdown/examples/markdown.rs @@ -178,7 +178,7 @@ impl MarkdownExample { cx: &mut WindowContext, ) -> Self { let markdown = - cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), cx, None)); + cx.new_view(|cx| Markdown::new(text, style, Some(language_registry), None, cx)); Self { markdown } } } diff --git a/crates/markdown/examples/markdown_as_child.rs b/crates/markdown/examples/markdown_as_child.rs index 3700e64364..a7be4d2891 100644 --- a/crates/markdown/examples/markdown_as_child.rs +++ b/crates/markdown/examples/markdown_as_child.rs @@ -87,7 +87,7 @@ pub fn main() { heading: Default::default(), }; let markdown = cx.new_view(|cx| { - Markdown::new(MARKDOWN_EXAMPLE.into(), markdown_style, None, cx, None) + Markdown::new(MARKDOWN_EXAMPLE.into(), markdown_style, None, None, cx) }); HelloWorld { markdown } diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index ff67c01a0e..39217b6930 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -71,8 +71,8 @@ impl Markdown { source: String, style: MarkdownStyle, language_registry: Option>, - cx: &ViewContext, fallback_code_block_language: Option, + cx: &ViewContext, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { From 463c99b503d0b679cd1859789e5a378fe2c0c783 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 12:56:01 -0800 Subject: [PATCH 134/215] Fix script/get-released-version (#21489) Release Notes: - N/A --- script/get-released-version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/get-released-version b/script/get-released-version index e1f4783f8a..357de7c240 100755 --- a/script/get-released-version +++ b/script/get-released-version @@ -18,4 +18,4 @@ case $channel in ;; esac -curl -s https://zed.dev/api/releases/latest?asset=Zed.dmg$query | jq -r .version +curl -s "https://zed.dev/api/releases/latest?asset=zed&os=macos&arch=aarch64$query" | jq -r .version From 1fccda7b8d1fc6d82befce6921a35538625f9e7a Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 12:56:25 -0800 Subject: [PATCH 135/215] Add text objects to extensions (#21488) Release Notes: - Adds textobject support to erlang, haskell, lua, php, prisma, proto, toml, and zig --- Cargo.lock | 16 +++---- extensions/erlang/Cargo.toml | 2 +- extensions/erlang/extension.toml | 2 +- .../erlang/languages/erlang/textobjects.scm | 6 +++ extensions/haskell/Cargo.toml | 2 +- extensions/haskell/extension.toml | 2 +- .../haskell/languages/haskell/textobjects.scm | 12 +++++ extensions/lua/Cargo.toml | 2 +- extensions/lua/extension.toml | 2 +- extensions/lua/languages/lua/textobjects.scm | 7 +++ extensions/php/Cargo.toml | 2 +- extensions/php/extension.toml | 2 +- extensions/php/languages/php/textobjects.scm | 45 +++++++++++++++++++ extensions/prisma/Cargo.toml | 2 +- extensions/prisma/extension.toml | 2 +- .../prisma/languages/prisma/textobjects.scm | 25 +++++++++++ extensions/proto/Cargo.toml | 2 +- extensions/proto/extension.toml | 2 +- .../proto/languages/proto/textobjects.scm | 18 ++++++++ extensions/toml/Cargo.toml | 2 +- extensions/toml/extension.toml | 2 +- .../toml/languages/toml/textobjects.scm | 6 +++ extensions/zig/Cargo.toml | 2 +- extensions/zig/extension.toml | 2 +- extensions/zig/languages/zig/textobjects.scm | 27 +++++++++++ script/language-extension-version | 1 - 26 files changed, 170 insertions(+), 25 deletions(-) create mode 100644 extensions/erlang/languages/erlang/textobjects.scm create mode 100644 extensions/haskell/languages/haskell/textobjects.scm create mode 100644 extensions/lua/languages/lua/textobjects.scm create mode 100644 extensions/php/languages/php/textobjects.scm create mode 100644 extensions/prisma/languages/prisma/textobjects.scm create mode 100644 extensions/proto/languages/proto/textobjects.scm create mode 100644 extensions/toml/languages/toml/textobjects.scm create mode 100644 extensions/zig/languages/zig/textobjects.scm diff --git a/Cargo.lock b/Cargo.lock index 1bd064ca4c..6266df7d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15768,7 +15768,7 @@ dependencies = [ [[package]] name = "zed_erlang" -version = "0.1.0" +version = "0.1.1" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15802,7 +15802,7 @@ dependencies = [ [[package]] name = "zed_haskell" -version = "0.1.1" +version = "0.1.2" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15816,28 +15816,28 @@ dependencies = [ [[package]] name = "zed_lua" -version = "0.1.0" +version = "0.1.1" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_php" -version = "0.2.2" +version = "0.2.3" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_prisma" -version = "0.0.3" +version = "0.0.4" dependencies = [ "zed_extension_api 0.1.0", ] [[package]] name = "zed_proto" -version = "0.2.0" +version = "0.2.1" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15880,7 +15880,7 @@ dependencies = [ [[package]] name = "zed_toml" -version = "0.1.1" +version = "0.1.2" dependencies = [ "zed_extension_api 0.1.0", ] @@ -15894,7 +15894,7 @@ dependencies = [ [[package]] name = "zed_zig" -version = "0.3.1" +version = "0.3.2" dependencies = [ "zed_extension_api 0.1.0", ] diff --git a/extensions/erlang/Cargo.toml b/extensions/erlang/Cargo.toml index 5067344896..ca354e0cbc 100644 --- a/extensions/erlang/Cargo.toml +++ b/extensions/erlang/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_erlang" -version = "0.1.0" +version = "0.1.1" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/erlang/extension.toml b/extensions/erlang/extension.toml index 8dd2628fd2..f6e903ccf9 100644 --- a/extensions/erlang/extension.toml +++ b/extensions/erlang/extension.toml @@ -1,7 +1,7 @@ id = "erlang" name = "Erlang" description = "Erlang support." -version = "0.1.0" +version = "0.1.1" schema_version = 1 authors = ["Dairon M ", "Fabian Bergström "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/erlang/languages/erlang/textobjects.scm b/extensions/erlang/languages/erlang/textobjects.scm new file mode 100644 index 0000000000..e802a2f362 --- /dev/null +++ b/extensions/erlang/languages/erlang/textobjects.scm @@ -0,0 +1,6 @@ +(function_clause + body: (_ "->" (_)* @function.inside)) @function.around + +(type_alias ty: (_) @class.inside) @class.around + +(comment)+ @comment.around diff --git a/extensions/haskell/Cargo.toml b/extensions/haskell/Cargo.toml index 0b69075a20..c106a0dd1b 100644 --- a/extensions/haskell/Cargo.toml +++ b/extensions/haskell/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_haskell" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/haskell/extension.toml b/extensions/haskell/extension.toml index 2ef30cb3d5..003687136e 100644 --- a/extensions/haskell/extension.toml +++ b/extensions/haskell/extension.toml @@ -1,7 +1,7 @@ id = "haskell" name = "Haskell" description = "Haskell support." -version = "0.1.1" +version = "0.1.2" schema_version = 1 authors = [ "Pocæus ", diff --git a/extensions/haskell/languages/haskell/textobjects.scm b/extensions/haskell/languages/haskell/textobjects.scm new file mode 100644 index 0000000000..4302397467 --- /dev/null +++ b/extensions/haskell/languages/haskell/textobjects.scm @@ -0,0 +1,12 @@ +(comment)+ @comment.around + +[ + (adt) + (type_alias) + (newtype) +] @class.around + +(record_fields "{" (_)* @class.inside "}") + +((signature)? (function)+) @function.around +(function rhs:(_) @function.inside) diff --git a/extensions/lua/Cargo.toml b/extensions/lua/Cargo.toml index f577ce1871..8eec6ed62f 100644 --- a/extensions/lua/Cargo.toml +++ b/extensions/lua/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_lua" -version = "0.1.0" +version = "0.1.1" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/lua/extension.toml b/extensions/lua/extension.toml index 82026f48ba..52120cdfa2 100644 --- a/extensions/lua/extension.toml +++ b/extensions/lua/extension.toml @@ -1,7 +1,7 @@ id = "lua" name = "Lua" description = "Lua support." -version = "0.1.0" +version = "0.1.1" schema_version = 1 authors = ["Max Brunsfeld "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/lua/languages/lua/textobjects.scm b/extensions/lua/languages/lua/textobjects.scm new file mode 100644 index 0000000000..1f8bf66059 --- /dev/null +++ b/extensions/lua/languages/lua/textobjects.scm @@ -0,0 +1,7 @@ +(function_definition + body: (_) @function.inside) @function.around + +(function_declaration + body: (_) @function.inside) @function.around + +(comment)+ @comment.around diff --git a/extensions/php/Cargo.toml b/extensions/php/Cargo.toml index a78c133e8e..8bf6a523f4 100644 --- a/extensions/php/Cargo.toml +++ b/extensions/php/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_php" -version = "0.2.2" +version = "0.2.3" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/php/extension.toml b/extensions/php/extension.toml index eec2fe5d39..a2bc1d921e 100644 --- a/extensions/php/extension.toml +++ b/extensions/php/extension.toml @@ -1,7 +1,7 @@ id = "php" name = "PHP" description = "PHP support." -version = "0.2.2" +version = "0.2.3" schema_version = 1 authors = ["Piotr Osiewicz "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/php/languages/php/textobjects.scm b/extensions/php/languages/php/textobjects.scm new file mode 100644 index 0000000000..d86a0c1252 --- /dev/null +++ b/extensions/php/languages/php/textobjects.scm @@ -0,0 +1,45 @@ +(function_definition + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(method_declaration + body: (_ + "{" + (_)* @function.inside + "}" )) @function.around + +(method_declaration) @function.around + +(class_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(interface_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(trait_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(enum_declaration + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(namespace_definition + body: (_ + "{" + (_)* @class.inside + "}")) @class.around + +(comment)+ @comment.around diff --git a/extensions/prisma/Cargo.toml b/extensions/prisma/Cargo.toml index e5a261266a..68256bd1cc 100644 --- a/extensions/prisma/Cargo.toml +++ b/extensions/prisma/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_prisma" -version = "0.0.3" +version = "0.0.4" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/prisma/extension.toml b/extensions/prisma/extension.toml index 449f990d2f..22b2bd9f2b 100644 --- a/extensions/prisma/extension.toml +++ b/extensions/prisma/extension.toml @@ -1,7 +1,7 @@ id = "prisma" name = "Prisma" description = "Prisma support." -version = "0.0.3" +version = "0.0.4" schema_version = 1 authors = ["Matthew Gramigna "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/prisma/languages/prisma/textobjects.scm b/extensions/prisma/languages/prisma/textobjects.scm new file mode 100644 index 0000000000..0158c90786 --- /dev/null +++ b/extensions/prisma/languages/prisma/textobjects.scm @@ -0,0 +1,25 @@ +(model_declaration + (statement_block + "{" + (_)* @class.inside + "}")) @class.around + +(datasource_declaration + (statement_block + "{" + (_)* @class.inside + "}")) @class.around + +(generator_declaration + (statement_block + "{" + (_)* @class.inside + "}")) @class.around + +(enum_declaration + (enum_block + "{" + (_)* @class.inside + "}")) @class.around + +(developer_comment)+ @comment.around diff --git a/extensions/proto/Cargo.toml b/extensions/proto/Cargo.toml index 215a09f896..03c9bc5626 100644 --- a/extensions/proto/Cargo.toml +++ b/extensions/proto/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_proto" -version = "0.2.0" +version = "0.2.1" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/proto/extension.toml b/extensions/proto/extension.toml index f26aee7dde..232602faf7 100644 --- a/extensions/proto/extension.toml +++ b/extensions/proto/extension.toml @@ -1,7 +1,7 @@ id = "proto" name = "Proto" description = "Protocol Buffers support." -version = "0.2.0" +version = "0.2.1" schema_version = 1 authors = ["Zed Industries "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/proto/languages/proto/textobjects.scm b/extensions/proto/languages/proto/textobjects.scm new file mode 100644 index 0000000000..90ea84282d --- /dev/null +++ b/extensions/proto/languages/proto/textobjects.scm @@ -0,0 +1,18 @@ +(message (message_body + "{" + (_)* @class.inside + "}")) @class.around +(enum (enum_body + "{" + (_)* @class.inside + "}")) @class.around +(service + "service" + (_) + "{" + (_)* @class.inside + "}") @class.around + +(rpc) @function.around + +(comment)+ @comment.around diff --git a/extensions/toml/Cargo.toml b/extensions/toml/Cargo.toml index 3aa7b69224..85d933e236 100644 --- a/extensions/toml/Cargo.toml +++ b/extensions/toml/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_toml" -version = "0.1.1" +version = "0.1.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/toml/extension.toml b/extensions/toml/extension.toml index 15db5c464d..a8b9250226 100644 --- a/extensions/toml/extension.toml +++ b/extensions/toml/extension.toml @@ -1,7 +1,7 @@ id = "toml" name = "TOML" description = "TOML support." -version = "0.1.1" +version = "0.1.2" schema_version = 1 authors = [ "Max Brunsfeld ", diff --git a/extensions/toml/languages/toml/textobjects.scm b/extensions/toml/languages/toml/textobjects.scm new file mode 100644 index 0000000000..f5b4856e27 --- /dev/null +++ b/extensions/toml/languages/toml/textobjects.scm @@ -0,0 +1,6 @@ +(comment)+ @comment +(table "[" (_) "]" + (_)* @class.inside) @class.around + +(table_array_element "[[" (_) "]]" + (_)* @class.inside) @class.around diff --git a/extensions/zig/Cargo.toml b/extensions/zig/Cargo.toml index 63f3c5c007..e29542d27e 100644 --- a/extensions/zig/Cargo.toml +++ b/extensions/zig/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "zed_zig" -version = "0.3.1" +version = "0.3.2" edition = "2021" publish = false license = "Apache-2.0" diff --git a/extensions/zig/extension.toml b/extensions/zig/extension.toml index bcd4f58555..380300683b 100644 --- a/extensions/zig/extension.toml +++ b/extensions/zig/extension.toml @@ -1,7 +1,7 @@ id = "zig" name = "Zig" description = "Zig support." -version = "0.3.1" +version = "0.3.2" schema_version = 1 authors = ["Allan Calix "] repository = "https://github.com/zed-industries/zed" diff --git a/extensions/zig/languages/zig/textobjects.scm b/extensions/zig/languages/zig/textobjects.scm new file mode 100644 index 0000000000..b08df97ea9 --- /dev/null +++ b/extensions/zig/languages/zig/textobjects.scm @@ -0,0 +1,27 @@ +(function_declaration + body: (_ + "{" + (_)* @function.inside + "}")) @function.around + +(test_declaration + (block + "{" + (_)* @function.inside + "}")) @function.around + +(variable_declaration + (struct_declaration + "struct" + "{" + [(_) ","]* @class.inside + "}")) @class.around + +(variable_declaration + (enum_declaration + "enum" + "{" + (_)* @class.inside + "}")) @class.around + +(comment)+ @comment.around diff --git a/script/language-extension-version b/script/language-extension-version index fc5c448736..d547886087 100755 --- a/script/language-extension-version +++ b/script/language-extension-version @@ -26,4 +26,3 @@ fi sed -i '' -e "s/^version = \".*\"/version = \"$VERSION\"/" "$EXTENSION_TOML" sed -i '' -e "s/^version = \".*\"/version = \"$VERSION\"/" "$CARGO_TOML" -cargo check From db34f293006d46ddaf798fa095cb8d4dd6afafd2 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 13:18:19 -0800 Subject: [PATCH 136/215] vim: Add == and fix = in the status bar (#21490) cc @maxbrunsfeld Release Notes: - vim: Add == --- assets/keymaps/vim.json | 7 +++++++ crates/vim/src/state.rs | 1 + 2 files changed, 8 insertions(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 5f5933ef63..3c2197afcc 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -489,6 +489,13 @@ "<": "vim::CurrentLine" } }, + { + "context": "vim_operator == eq", + "use_layout_keys": true, + "bindings": { + "=": "vim::CurrentLine" + } + }, { "context": "vim_operator == gc", "use_layout_keys": true, diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index af187381ad..f43de2cf6f 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -487,6 +487,7 @@ impl Operator { Operator::Literal { prefix: Some(prefix), } => format!("^V{prefix}"), + Operator::AutoIndent => "=".to_string(), _ => self.id().to_string(), } } From aca23da9714e0482be8bee1b56695ac3ff1d4cc6 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 3 Dec 2024 16:25:09 -0500 Subject: [PATCH 137/215] assistant2: Render messages in the thread using a `list` (#21491) This PR updates the rendering of the messages in the current thread to use a `gpui::list`. Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 80 ++++++++++++++---------- crates/assistant2/src/message_editor.rs | 2 +- crates/assistant2/src/thread.rs | 15 +++-- 3 files changed, 57 insertions(+), 40 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 4e6b6ef227..b4ac2731e0 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -4,9 +4,9 @@ use anyhow::Result; use assistant_tool::ToolWorkingSet; use client::zed_urls; use gpui::{ - prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext, EventEmitter, FocusHandle, - FocusableView, FontWeight, Model, Pixels, Subscription, Task, View, ViewContext, WeakView, - WindowContext, + list, prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext, Empty, EventEmitter, + FocusHandle, FocusableView, FontWeight, ListAlignment, ListState, Model, Pixels, Subscription, + Task, View, ViewContext, WeakView, WindowContext, }; use language_model::{LanguageModelRegistry, Role}; use language_model_selector::LanguageModelSelector; @@ -15,7 +15,7 @@ use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; -use crate::thread::{Message, Thread, ThreadError, ThreadEvent}; +use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent}; use crate::thread_store::ThreadStore; use crate::{NewThread, ToggleFocus, ToggleModelSelector}; @@ -35,6 +35,8 @@ pub struct AssistantPanel { #[allow(unused)] thread_store: Model, thread: Model, + thread_messages: Vec, + thread_list_state: ListState, message_editor: View, tools: Arc, last_error: Option, @@ -77,6 +79,14 @@ impl AssistantPanel { workspace: workspace.weak_handle(), thread_store, thread: thread.clone(), + thread_messages: Vec::new(), + thread_list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), { + let this = cx.view().downgrade(); + move |ix, cx: &mut WindowContext| { + this.update(cx, |this, cx| this.render_message(ix, cx)) + .unwrap() + } + }), message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), tools, last_error: None, @@ -110,6 +120,12 @@ impl AssistantPanel { self.last_error = Some(error.clone()); } ThreadEvent::StreamedCompletion => {} + ThreadEvent::MessageAdded(message_id) => { + let old_len = self.thread_messages.len(); + self.thread_messages.push(*message_id); + self.thread_list_state.splice(old_len..old_len, 1); + cx.notify(); + } ThreadEvent::UsePendingTools => { let pending_tool_uses = self .thread @@ -301,31 +317,42 @@ impl AssistantPanel { ) } - fn render_message(&self, message: Message, cx: &mut ViewContext) -> impl IntoElement { + fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let message_id = self.thread_messages[ix]; + let Some(message) = self.thread.read(cx).message(message_id) else { + return Empty.into_any(); + }; + let (role_icon, role_name) = match message.role { Role::User => (IconName::Person, "You"), Role::Assistant => (IconName::ZedAssistant, "Assistant"), Role::System => (IconName::Settings, "System"), }; - v_flex() - .border_1() - .border_color(cx.theme().colors().border_variant) - .rounded_md() + div() + .id(("message-container", ix)) + .p_2() .child( - h_flex() - .justify_between() - .p_1p5() - .border_b_1() + v_flex() + .border_1() .border_color(cx.theme().colors().border_variant) + .rounded_md() .child( h_flex() - .gap_2() - .child(Icon::new(role_icon).size(IconSize::Small)) - .child(Label::new(role_name).size(LabelSize::Small)), - ), + .justify_between() + .p_1p5() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .child(Icon::new(role_icon).size(IconSize::Small)) + .child(Label::new(role_name).size(LabelSize::Small)), + ), + ) + .child(v_flex().p_1p5().child(Label::new(message.text.clone()))), ) - .child(v_flex().p_1p5().child(Label::new(message.text.clone()))) + .into_any() } fn render_last_error(&self, cx: &mut ViewContext) -> Option { @@ -477,8 +504,6 @@ impl AssistantPanel { impl Render for AssistantPanel { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - let messages = self.thread.read(cx).messages().cloned().collect::>(); - v_flex() .key_context("AssistantPanel2") .justify_between() @@ -487,20 +512,7 @@ impl Render for AssistantPanel { this.new_thread(cx); })) .child(self.render_toolbar(cx)) - .child( - v_flex() - .id("message-list") - .gap_2() - .size_full() - .p_2() - .overflow_y_scroll() - .bg(cx.theme().colors().panel_background) - .children( - messages - .into_iter() - .map(|message| self.render_message(message, cx)), - ), - ) + .child(list(self.thread_list_state.clone()).flex_1()) .child( h_flex() .border_t_1() diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index 7f789587c6..d1b1cf55e4 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -56,7 +56,7 @@ impl MessageEditor { }); self.thread.update(cx, |thread, cx| { - thread.insert_user_message(user_message); + thread.insert_user_message(user_message, cx); let mut request = thread.to_completion_request(request_kind, cx); if self.use_tools { diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index a5ab415a4d..43868fffff 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -63,8 +63,8 @@ impl Thread { } } - pub fn messages(&self) -> impl Iterator { - self.messages.iter() + pub fn message(&self, id: MessageId) -> Option<&Message> { + self.messages.iter().find(|message| message.id == id) } pub fn tools(&self) -> &Arc { @@ -75,12 +75,14 @@ impl Thread { self.pending_tool_uses_by_id.values().collect() } - pub fn insert_user_message(&mut self, text: impl Into) { + pub fn insert_user_message(&mut self, text: impl Into, cx: &mut ModelContext) { + let id = self.next_message_id.post_inc(); self.messages.push(Message { - id: self.next_message_id.post_inc(), + id, role: Role::User, text: text.into(), }); + cx.emit(ThreadEvent::MessageAdded(id)); } pub fn to_completion_request( @@ -150,11 +152,13 @@ impl Thread { thread.update(&mut cx, |thread, cx| { match event { LanguageModelCompletionEvent::StartMessage { .. } => { + let id = thread.next_message_id.post_inc(); thread.messages.push(Message { - id: thread.next_message_id.post_inc(), + id, role: Role::Assistant, text: String::new(), }); + cx.emit(ThreadEvent::MessageAdded(id)); } LanguageModelCompletionEvent::Stop(reason) => { stop_reason = reason; @@ -316,6 +320,7 @@ pub enum ThreadError { pub enum ThreadEvent { ShowError(ThreadError), StreamedCompletion, + MessageAdded(MessageId), UsePendingTools, ToolFinished { #[allow(unused)] From dc32ab25a0f76280ff0f1485333a729523840e27 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 3 Dec 2024 17:14:17 -0500 Subject: [PATCH 138/215] Open folds containing selections when jumping from multibuffer (#21433) When searching within a single buffer, activating a search result causes any fold containing the result to be unfolded. However, this didn't happen when jumping to a search result from a project-wide search multibuffer. This PR fixes that. Release Notes: - Fixed folds not opening when jumping from search results multibuffer --- crates/editor/src/editor.rs | 1 + crates/editor/src/editor_tests.rs | 70 ++++++++++++++++++++++++++++++- 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 88919f9295..2e6274ef8a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12876,6 +12876,7 @@ impl Editor { None => Autoscroll::newest(), }; let nav_history = editor.nav_history.take(); + editor.unfold_ranges(&ranges, false, true, cx); editor.change_selections(Some(autoscroll), cx, |s| { s.select_ranges(ranges); }); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 044e2765ed..0c15719ab5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11567,7 +11567,7 @@ async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_mutlibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { +async fn test_multibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let cols = 4; @@ -11856,6 +11856,74 @@ async fn test_mutlibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { .unwrap(); } +#[gpui::test] +async fn test_multibuffer_unfold_on_jump(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let texts = ["{\n\tx\n}".to_owned(), "y".to_owned()]; + let buffers = texts + .clone() + .map(|txt| cx.new_model(|cx| Buffer::local(txt, cx))); + let multi_buffer = cx.new_model(|cx| { + let mut multi_buffer = MultiBuffer::new(ReadWrite); + for i in 0..2 { + multi_buffer.push_excerpts( + buffers[i].clone(), + [ExcerptRange { + context: 0..texts[i].len(), + primary: None, + }], + cx, + ); + } + multi_buffer + }); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/project", + json!({ + "x": &texts[0], + "y": &texts[1], + }), + ) + .await; + let project = Project::test(fs, ["/project".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + + let multi_buffer_editor = cx.new_view(|cx| { + Editor::for_multibuffer(multi_buffer.clone(), Some(project.clone()), true, cx) + }); + let buffer_editor = + cx.new_view(|cx| Editor::for_buffer(buffers[0].clone(), Some(project.clone()), cx)); + workspace + .update(cx, |workspace, cx| { + workspace.add_item_to_active_pane( + Box::new(multi_buffer_editor.clone()), + None, + true, + cx, + ); + workspace.add_item_to_active_pane(Box::new(buffer_editor.clone()), None, false, cx); + }) + .unwrap(); + cx.executor().run_until_parked(); + buffer_editor.update(cx, |buffer_editor, cx| { + buffer_editor.fold_at_level(&FoldAtLevel { level: 1 }, cx); + assert!(buffer_editor.snapshot(cx).fold_count() == 1); + }); + cx.executor().run_until_parked(); + multi_buffer_editor.update(cx, |multi_buffer_editor, cx| { + multi_buffer_editor.change_selections(None, cx, |s| s.select_ranges([3..4])); + multi_buffer_editor.open_excerpts(&OpenExcerpts, cx); + }); + cx.executor().run_until_parked(); + buffer_editor.update(cx, |buffer_editor, cx| { + assert!(buffer_editor.snapshot(cx).fold_count() == 0); + }); +} + #[gpui::test] async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); From ecaf44511cd1a1efc6ded4b56f87f1435dbc0e07 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 3 Dec 2024 23:28:59 +0000 Subject: [PATCH 139/215] Fix Perplexity extension URL (#21495) --- extensions/perplexity/extension.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/perplexity/extension.toml b/extensions/perplexity/extension.toml index 205f8a5cc2..474d9ee981 100644 --- a/extensions/perplexity/extension.toml +++ b/extensions/perplexity/extension.toml @@ -3,7 +3,7 @@ name = "Perplexity" version = "0.1.0" description = "Ask questions to Perplexity AI directly from Zed" authors = ["Zed Industries "] -repository = "https://github.com/zed-industries/zed-perplexity" +repository = "https://github.com/zed-industries/zed" schema_version = 1 [slash_commands.perplexity] From 9f459ba573b8c029c89dbbc7a8edca6d1f7b712e Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 3 Dec 2024 18:32:13 -0500 Subject: [PATCH 140/215] assistant2: Render messages as Markdown (#21496) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR updates Assistant 2 to render the messages in the thread as Markdown: Screenshot 2024-12-03 at 6 09 27 PM Release Notes: - N/A --- Cargo.lock | 2 + crates/assistant2/Cargo.toml | 4 +- crates/assistant2/src/assistant_panel.rs | 74 +++++++++++++++++++++++- crates/assistant2/src/thread.rs | 5 ++ 4 files changed, 81 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6266df7d86..e1ff5d6dae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -464,10 +464,12 @@ dependencies = [ "feature_flags", "futures 0.3.31", "gpui", + "language", "language_model", "language_model_selector", "language_models", "log", + "markdown", "project", "proto", "serde", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 20e8dfbc9a..257183a4ac 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -23,15 +23,17 @@ editor.workspace = true feature_flags.workspace = true futures.workspace = true gpui.workspace = true +language.workspace = true language_model.workspace = true language_model_selector.workspace = true language_models.workspace = true log.workspace = true +markdown.workspace = true project.workspace = true proto.workspace = true -settings.workspace = true serde.workspace = true serde_json.workspace = true +settings.workspace = true smol.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index b4ac2731e0..b8ce5b1a36 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -3,13 +3,19 @@ use std::sync::Arc; use anyhow::Result; use assistant_tool::ToolWorkingSet; use client::zed_urls; +use collections::HashMap; use gpui::{ list, prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext, Empty, EventEmitter, - FocusHandle, FocusableView, FontWeight, ListAlignment, ListState, Model, Pixels, Subscription, - Task, View, ViewContext, WeakView, WindowContext, + FocusHandle, FocusableView, FontWeight, ListAlignment, ListState, Model, Pixels, + StyleRefinement, Subscription, Task, TextStyleRefinement, View, ViewContext, WeakView, + WindowContext, }; +use language::LanguageRegistry; use language_model::{LanguageModelRegistry, Role}; use language_model_selector::LanguageModelSelector; +use markdown::{Markdown, MarkdownStyle}; +use settings::Settings; +use theme::ThemeSettings; use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; @@ -32,10 +38,12 @@ pub fn init(cx: &mut AppContext) { pub struct AssistantPanel { workspace: WeakView, + language_registry: Arc, #[allow(unused)] thread_store: Model, thread: Model, thread_messages: Vec, + rendered_messages_by_id: HashMap>, thread_list_state: ListState, message_editor: View, tools: Arc, @@ -77,9 +85,11 @@ impl AssistantPanel { Self { workspace: workspace.weak_handle(), + language_registry: workspace.project().read(cx).languages().clone(), thread_store, thread: thread.clone(), thread_messages: Vec::new(), + rendered_messages_by_id: HashMap::default(), thread_list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), { let this = cx.view().downgrade(); move |ix, cx: &mut WindowContext| { @@ -104,6 +114,9 @@ impl AssistantPanel { self.message_editor = cx.new_view(|cx| MessageEditor::new(thread.clone(), cx)); self.thread = thread; + self.thread_messages.clear(); + self.thread_list_state.reset(0); + self.rendered_messages_by_id.clear(); self._subscriptions = subscriptions; self.message_editor.focus_handle(cx).focus(cx); @@ -120,10 +133,61 @@ impl AssistantPanel { self.last_error = Some(error.clone()); } ThreadEvent::StreamedCompletion => {} + ThreadEvent::StreamedAssistantText(message_id, text) => { + if let Some(markdown) = self.rendered_messages_by_id.get_mut(&message_id) { + markdown.update(cx, |markdown, cx| { + markdown.append(text, cx); + }); + } + } ThreadEvent::MessageAdded(message_id) => { let old_len = self.thread_messages.len(); self.thread_messages.push(*message_id); self.thread_list_state.splice(old_len..old_len, 1); + + if let Some(message_text) = self + .thread + .read(cx) + .message(*message_id) + .map(|message| message.text.clone()) + { + let theme_settings = ThemeSettings::get_global(cx); + + let mut text_style = cx.text_style(); + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + font_size: Some(TextSize::Default.rems(cx).into()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + let markdown_style = MarkdownStyle { + base_text_style: text_style, + syntax: cx.theme().syntax().clone(), + selection_background_color: cx.theme().players().local().selection, + code_block: StyleRefinement { + text: Some(TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(theme_settings.buffer_font_size.into()), + ..Default::default() + }), + ..Default::default() + }, + ..Default::default() + }; + + let markdown = cx.new_view(|cx| { + Markdown::new( + message_text, + markdown_style, + Some(self.language_registry.clone()), + None, + cx, + ) + }); + self.rendered_messages_by_id.insert(*message_id, markdown); + } + cx.notify(); } ThreadEvent::UsePendingTools => { @@ -323,6 +387,10 @@ impl AssistantPanel { return Empty.into_any(); }; + let Some(markdown) = self.rendered_messages_by_id.get(&message_id) else { + return Empty.into_any(); + }; + let (role_icon, role_name) = match message.role { Role::User => (IconName::Person, "You"), Role::Assistant => (IconName::ZedAssistant, "Assistant"), @@ -350,7 +418,7 @@ impl AssistantPanel { .child(Label::new(role_name).size(LabelSize::Small)), ), ) - .child(v_flex().p_1p5().child(Label::new(message.text.clone()))), + .child(v_flex().p_1p5().text_ui(cx).child(markdown.clone())), ) .into_any() } diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 43868fffff..a841325884 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -167,6 +167,10 @@ impl Thread { if let Some(last_message) = thread.messages.last_mut() { if last_message.role == Role::Assistant { last_message.text.push_str(&chunk); + cx.emit(ThreadEvent::StreamedAssistantText( + last_message.id, + chunk, + )); } } } @@ -320,6 +324,7 @@ pub enum ThreadError { pub enum ThreadEvent { ShowError(ThreadError), StreamedCompletion, + StreamedAssistantText(MessageId, String), MessageAdded(MessageId), UsePendingTools, ToolFinished { From 3019960f83e6852a5b9ce3706f88addce3aa2983 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Tue, 3 Dec 2024 18:39:00 -0500 Subject: [PATCH 141/215] markdown: Make `cx` the last parameter to `Markdown::new_text` (#21497) This PR is a follow-up to https://github.com/zed-industries/zed/pull/21487 to make sure that the `cx` is the last parameter to `Markdown::new_text` as well. Release Notes: - N/A --- crates/editor/src/hover_popover.rs | 2 +- crates/markdown/src/markdown.rs | 2 +- crates/recent_projects/src/ssh_connections.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/editor/src/hover_popover.rs b/crates/editor/src/hover_popover.rs index c402132bf3..9cac7dc713 100644 --- a/crates/editor/src/hover_popover.rs +++ b/crates/editor/src/hover_popover.rs @@ -378,7 +378,7 @@ fn show_hover( }, ..Default::default() }; - Markdown::new_text(text, markdown_style.clone(), None, cx, None) + Markdown::new_text(text, markdown_style.clone(), None, None, cx) }) .ok(); diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 39217b6930..cdb464877d 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -97,8 +97,8 @@ impl Markdown { source: String, style: MarkdownStyle, language_registry: Option>, - cx: &ViewContext, fallback_code_block_language: Option, + cx: &ViewContext, ) -> Self { let focus_handle = cx.focus_handle(); let mut this = Self { diff --git a/crates/recent_projects/src/ssh_connections.rs b/crates/recent_projects/src/ssh_connections.rs index a9aeacadd8..1c084bbf6e 100644 --- a/crates/recent_projects/src/ssh_connections.rs +++ b/crates/recent_projects/src/ssh_connections.rs @@ -201,7 +201,7 @@ impl SshPrompt { selection_background_color: cx.theme().players().local().selection, ..Default::default() }; - let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, cx, None)); + let markdown = cx.new_view(|cx| Markdown::new_text(prompt, markdown_style, None, None, cx)); self.prompt = Some((markdown, tx)); self.status_message.take(); cx.focus_view(&self.editor); From ce5f492404d24d0b1d071dd490ec13421cce7183 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 3 Dec 2024 23:22:26 -0500 Subject: [PATCH 142/215] Update rustls and sqlx (#21506) Release Notes: - N/A --- Cargo.lock | 59 ++++++++++++++++++++++------------------- Cargo.toml | 1 + crates/sqlez/Cargo.toml | 2 +- 3 files changed, 33 insertions(+), 29 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e1ff5d6dae..21db0ceb99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5774,7 +5774,7 @@ dependencies = [ "http 1.1.0", "hyper 1.5.0", "hyper-util", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-native-certs 0.8.0", "rustls-pki-types", "tokio", @@ -6866,9 +6866,9 @@ dependencies = [ [[package]] name = "libsqlite3-sys" -version = "0.28.0" +version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c10584274047cb335c23d3e61bcef8e323adae7c5c8c760540f73610177fc3f" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ "cc", "pkg-config", @@ -9512,7 +9512,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash 2.0.0", - "rustls 0.23.16", + "rustls 0.23.18", "socket2 0.5.7", "thiserror 2.0.3", "tokio", @@ -9530,7 +9530,7 @@ dependencies = [ "rand 0.8.5", "ring", "rustc-hash 2.0.0", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pki-types", "slab", "thiserror 2.0.3", @@ -10085,7 +10085,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.16", + "rustls 0.23.18", "rustls-native-certs 0.8.0", "rustls-pemfile 2.2.0", "rustls-pki-types", @@ -10473,9 +10473,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.16" +version = "0.23.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eee87ff5d9b36712a58574e12e9f0ea80f915a5b0ac518d322b24a465617925e" +checksum = "9c9cc1d47e243d655ace55ed38201c19ae02c148ae56412ab8750e8f0166ab7f" dependencies = [ "once_cell", "ring", @@ -11514,9 +11514,9 @@ dependencies = [ [[package]] name = "sqlx" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" +checksum = "fcfa89bea9500db4a0d038513d7a060566bfc51d46d1c014847049a45cce85e8" dependencies = [ "sqlx-core", "sqlx-macros", @@ -11527,9 +11527,9 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" +checksum = "d06e2f2bd861719b1f3f0c7dbe1d80c30bf59e76cf019f07d9014ed7eefb8e08" dependencies = [ "atoi", "bigdecimal", @@ -11555,8 +11555,8 @@ dependencies = [ "paste", "percent-encoding", "rust_decimal", - "rustls 0.21.12", - "rustls-pemfile 1.0.4", + "rustls 0.23.18", + "rustls-pemfile 2.2.0", "serde", "serde_json", "sha2", @@ -11569,14 +11569,14 @@ dependencies = [ "tracing", "url", "uuid", - "webpki-roots 0.25.4", + "webpki-roots 0.26.7", ] [[package]] name = "sqlx-macros" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" +checksum = "2f998a9defdbd48ed005a89362bd40dd2117502f15294f61c8d47034107dbbdc" dependencies = [ "proc-macro2", "quote", @@ -11587,9 +11587,9 @@ dependencies = [ [[package]] name = "sqlx-macros-core" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" +checksum = "3d100558134176a2629d46cec0c8891ba0be8910f7896abfdb75ef4ab6f4e7ce" dependencies = [ "dotenvy", "either", @@ -11613,9 +11613,9 @@ dependencies = [ [[package]] name = "sqlx-mysql" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" +checksum = "936cac0ab331b14cb3921c62156d913e4c15b74fb6ec0f3146bd4ef6e4fb3c12" dependencies = [ "atoi", "base64 0.22.1", @@ -11660,9 +11660,9 @@ dependencies = [ [[package]] name = "sqlx-postgres" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" +checksum = "9734dbce698c67ecf67c442f768a5e90a49b2a4d61a9f1d59f73874bd4cf0710" dependencies = [ "atoi", "base64 0.22.1", @@ -11704,9 +11704,9 @@ dependencies = [ [[package]] name = "sqlx-sqlite" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" +checksum = "a75b419c3c1b1697833dd927bdc4c6545a620bc1bbafabd44e1efbe9afcd337e" dependencies = [ "atoi", "chrono", @@ -12780,7 +12780,7 @@ version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls 0.23.16", + "rustls 0.23.18", "rustls-pki-types", "tokio", ] @@ -14448,9 +14448,12 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "0.25.4" +version = "0.26.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" +checksum = "5d642ff16b7e79272ae451b7322067cdc17cadf68c23264be9d94a32319efe7e" +dependencies = [ + "rustls-pki-types", +] [[package]] name = "weezl" diff --git a/Cargo.toml b/Cargo.toml index 0465545990..ab1e9d8e1a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -391,6 +391,7 @@ jsonwebtoken = "9.3" jupyter-protocol = { version = "0.5.0" } jupyter-websocket-client = { version = "0.8.0" } libc = "0.2" +libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } markup5ever_rcdom = "0.3.0" diff --git a/crates/sqlez/Cargo.toml b/crates/sqlez/Cargo.toml index 43626d7747..4204a45d80 100644 --- a/crates/sqlez/Cargo.toml +++ b/crates/sqlez/Cargo.toml @@ -13,7 +13,7 @@ anyhow.workspace = true collections.workspace = true futures.workspace = true indoc.workspace = true -libsqlite3-sys = { version = "0.28", features = ["bundled"] } +libsqlite3-sys.workspace = true parking_lot.workspace = true smol.workspace = true sqlformat.workspace = true From c5d15fd0653b90c5567912d0554bca946f643e1f Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 3 Dec 2024 23:23:16 -0500 Subject: [PATCH 143/215] Add FoldFunctionBodies editor action (#21504) Related to #19424 This uses the new text object support, so will only work for languages that have `textobjects.scm`. It does not integrate with indentation-based folding for now, and the syntax-based folds don't have matching fold markers in the gutter (unless they are folded). Release Notes: - Add an editor action to fold all function bodies Co-authored-by: Conrad --- crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 17 +++++++++++++++++ crates/editor/src/element.rs | 1 + crates/language/src/buffer.rs | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index a67dd55055..9a00f1efca 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -248,6 +248,7 @@ gpui::actions!( FindAllReferences, Fold, FoldAll, + FoldFunctionBodies, FoldRecursive, FoldSelectedRanges, ToggleFold, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 2e6274ef8a..3901ba47aa 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11097,6 +11097,23 @@ impl Editor { self.fold_creases(fold_ranges, true, cx); } + pub fn fold_function_bodies( + &mut self, + _: &actions::FoldFunctionBodies, + cx: &mut ViewContext, + ) { + let snapshot = self.buffer.read(cx).snapshot(cx); + let Some((_, _, buffer)) = snapshot.as_singleton() else { + return; + }; + let creases = buffer + .function_body_fold_ranges(0..buffer.len()) + .map(|range| Crease::simple(range, self.display_map.read(cx).fold_placeholder.clone())) + .collect(); + + self.fold_creases(creases, true, cx); + } + pub fn fold_recursive(&mut self, _: &actions::FoldRecursive, cx: &mut ViewContext) { let mut to_fold = Vec::new(); let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx)); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 975f1b8bf0..a82820f265 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -342,6 +342,7 @@ impl EditorElement { register_action(view, cx, Editor::fold); register_action(view, cx, Editor::fold_at_level); register_action(view, cx, Editor::fold_all); + register_action(view, cx, Editor::fold_function_bodies); register_action(view, cx, Editor::fold_at); register_action(view, cx, Editor::fold_recursive); register_action(view, cx, Editor::toggle_fold); diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index f3b6cb51ad..67b33ebd56 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -3355,6 +3355,14 @@ impl BufferSnapshot { }) } + pub fn function_body_fold_ranges( + &self, + within: Range, + ) -> impl Iterator> + '_ { + self.text_object_ranges(within, TreeSitterOptions::default()) + .filter_map(|(range, obj)| (obj == TextObject::InsideFunction).then_some(range)) + } + /// For each grammar in the language, runs the provided /// [`tree_sitter::Query`] against the given range. pub fn matches( From 8f08787cf0ce40521845bbf9a43d0945c8113ca4 Mon Sep 17 00:00:00 2001 From: Waleed Dahshan <58462210+wmstack@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:19:52 +1100 Subject: [PATCH 144/215] Implement Helix Support (WIP) (#19175) Closes #4642 - Added the ability to switch to helix normal mode, with an additional helix visual mode. - ctrlh from Insert mode goes to Helix Normal mode. i and a to go back. - Need to find a way to perform the helix normal mode selection with w , e , b as a first step. Need to figure out how the mode will interoperate with the VIM mode as the new additions are in the same crate. --- assets/keymaps/vim.json | 16 ++ crates/editor/src/movement.rs | 95 ++++++++ crates/language/src/buffer.rs | 10 +- crates/text/src/selection.rs | 25 +++ crates/vim/src/helix.rs | 271 +++++++++++++++++++++++ crates/vim/src/motion.rs | 4 + crates/vim/src/normal/case.rs | 2 + crates/vim/src/object.rs | 2 +- crates/vim/src/state.rs | 3 + crates/vim/src/test/neovim_connection.rs | 1 + crates/vim/src/vim.rs | 27 ++- 11 files changed, 444 insertions(+), 12 deletions(-) create mode 100644 crates/vim/src/helix.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 3c2197afcc..c80a6912cc 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -326,6 +326,22 @@ "ctrl-o": "vim::TemporaryNormal" } }, + { + "context": "vim_mode == helix_normal", + "bindings": { + "i": "vim::InsertBefore", + "a": "vim::InsertAfter", + "w": "vim::NextWordStart", + "e": "vim::NextWordEnd", + "b": "vim::PreviousWordStart", + + "h": "vim::Left", + "j": "vim::Down", + "k": "vim::Up", + "l": "vim::Right" + } + }, + { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", "use_layout_keys": true, diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 52bedde2e3..8189dd2947 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -488,6 +488,101 @@ pub fn find_boundary_point( map.clip_point(offset.to_display_point(map), Bias::Right) } +pub fn find_preceding_boundary_trail( + map: &DisplaySnapshot, + head: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> (Option, DisplayPoint) { + let mut offset = head.to_offset(map, Bias::Left); + let mut trail_offset = None; + + let mut prev_ch = map.buffer_snapshot.chars_at(offset).next(); + let mut forward = map.buffer_snapshot.reversed_chars_at(offset).peekable(); + + // Skip newlines + while let Some(&ch) = forward.peek() { + if ch == '\n' { + prev_ch = forward.next(); + offset -= ch.len_utf8(); + trail_offset = Some(offset); + } else { + break; + } + } + + // Find the boundary + let start_offset = offset; + for ch in forward { + if let Some(prev_ch) = prev_ch { + if is_boundary(prev_ch, ch) { + if start_offset == offset { + trail_offset = Some(offset); + } else { + break; + } + } + } + offset -= ch.len_utf8(); + prev_ch = Some(ch); + } + + let trail = trail_offset + .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Left)); + + ( + trail, + map.clip_point(offset.to_display_point(map), Bias::Left), + ) +} + +/// Finds the location of a boundary +pub fn find_boundary_trail( + map: &DisplaySnapshot, + head: DisplayPoint, + mut is_boundary: impl FnMut(char, char) -> bool, +) -> (Option, DisplayPoint) { + let mut offset = head.to_offset(map, Bias::Right); + let mut trail_offset = None; + + let mut prev_ch = map.buffer_snapshot.reversed_chars_at(offset).next(); + let mut forward = map.buffer_snapshot.chars_at(offset).peekable(); + + // Skip newlines + while let Some(&ch) = forward.peek() { + if ch == '\n' { + prev_ch = forward.next(); + offset += ch.len_utf8(); + trail_offset = Some(offset); + } else { + break; + } + } + + // Find the boundary + let start_offset = offset; + for ch in forward { + if let Some(prev_ch) = prev_ch { + if is_boundary(prev_ch, ch) { + if start_offset == offset { + trail_offset = Some(offset); + } else { + break; + } + } + } + offset += ch.len_utf8(); + prev_ch = Some(ch); + } + + let trail = trail_offset + .map(|trail_offset: usize| map.clip_point(trail_offset.to_display_point(map), Bias::Right)); + + ( + trail, + map.clip_point(offset.to_display_point(map), Bias::Right), + ) +} + pub fn find_boundary( map: &DisplaySnapshot, from: DisplayPoint, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 67b33ebd56..c9f5d54299 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4632,7 +4632,7 @@ impl CharClassifier { self.kind(c) == CharKind::Punctuation } - pub fn kind(&self, c: char) -> CharKind { + pub fn kind_with(&self, c: char, ignore_punctuation: bool) -> CharKind { if c.is_whitespace() { return CharKind::Whitespace; } else if c.is_alphanumeric() || c == '_' { @@ -4642,7 +4642,7 @@ impl CharClassifier { if let Some(scope) = &self.scope { if let Some(characters) = scope.word_characters() { if characters.contains(&c) { - if c == '-' && !self.for_completion && !self.ignore_punctuation { + if c == '-' && !self.for_completion && !ignore_punctuation { return CharKind::Punctuation; } return CharKind::Word; @@ -4650,12 +4650,16 @@ impl CharClassifier { } } - if self.ignore_punctuation { + if ignore_punctuation { CharKind::Word } else { CharKind::Punctuation } } + + pub fn kind(&self, c: char) -> CharKind { + self.kind_with(c, self.ignore_punctuation) + } } /// Find all of the ranges of whitespace that occur at the ends of lines diff --git a/crates/text/src/selection.rs b/crates/text/src/selection.rs index 94c373d630..fffece26b2 100644 --- a/crates/text/src/selection.rs +++ b/crates/text/src/selection.rs @@ -84,6 +84,31 @@ impl Selection { } self.goal = new_goal; } + + pub fn set_tail(&mut self, tail: T, new_goal: SelectionGoal) { + if tail.cmp(&self.head()) <= Ordering::Equal { + if self.reversed { + self.end = self.start; + self.reversed = false; + } + self.start = tail; + } else { + if !self.reversed { + self.start = self.end; + self.reversed = true; + } + self.end = tail; + } + self.goal = new_goal; + } + + pub fn swap_head_tail(&mut self) { + if self.reversed { + self.reversed = false; + } else { + std::mem::swap(&mut self.start, &mut self.end); + } + } } impl Selection { diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs new file mode 100644 index 0000000000..21abb5cbaa --- /dev/null +++ b/crates/vim/src/helix.rs @@ -0,0 +1,271 @@ +use editor::{movement, scroll::Autoscroll, DisplayPoint, Editor}; +use gpui::{actions, Action}; +use language::{CharClassifier, CharKind}; +use ui::ViewContext; + +use crate::{motion::Motion, state::Mode, Vim}; + +actions!(vim, [HelixNormalAfter]); + +pub fn register(editor: &mut Editor, cx: &mut ViewContext) { + Vim::action(editor, cx, Vim::helix_normal_after); +} + +impl Vim { + pub fn helix_normal_after(&mut self, action: &HelixNormalAfter, cx: &mut ViewContext) { + if self.active_operator().is_some() { + self.operator_stack.clear(); + self.sync_vim_settings(cx); + return; + } + self.stop_recording_immediately(action.boxed_clone(), cx); + self.switch_mode(Mode::HelixNormal, false, cx); + return; + } + + pub fn helix_normal_motion( + &mut self, + motion: Motion, + times: Option, + cx: &mut ViewContext, + ) { + self.helix_move_cursor(motion, times, cx); + } + + fn helix_find_range_forward( + &mut self, + times: Option, + cx: &mut ViewContext, + mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, + ) { + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let times = times.unwrap_or(1); + + if selection.head() == map.max_point() { + return; + } + + // collapse to block cursor + if selection.tail() < selection.head() { + selection.set_tail(movement::left(map, selection.head()), selection.goal); + } else { + selection.set_tail(selection.head(), selection.goal); + selection.set_head(movement::right(map, selection.head()), selection.goal); + } + + // create a classifier + let classifier = map + .buffer_snapshot + .char_classifier_at(selection.head().to_point(map)); + + let mut last_selection = selection.clone(); + for _ in 0..times { + let (new_tail, new_head) = + movement::find_boundary_trail(map, selection.head(), |left, right| { + is_boundary(left, right, &classifier) + }); + + selection.set_head(new_head, selection.goal); + if let Some(new_tail) = new_tail { + selection.set_tail(new_tail, selection.goal); + } + + if selection.head() == last_selection.head() + && selection.tail() == last_selection.tail() + { + break; + } + last_selection = selection.clone(); + } + }); + }); + }); + } + + fn helix_find_range_backward( + &mut self, + times: Option, + cx: &mut ViewContext, + mut is_boundary: impl FnMut(char, char, &CharClassifier) -> bool, + ) { + self.update_editor(cx, |_, editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let times = times.unwrap_or(1); + + if selection.head() == DisplayPoint::zero() { + return; + } + + // collapse to block cursor + if selection.tail() < selection.head() { + selection.set_tail(movement::left(map, selection.head()), selection.goal); + } else { + selection.set_tail(selection.head(), selection.goal); + selection.set_head(movement::right(map, selection.head()), selection.goal); + } + + // flip the selection + selection.swap_head_tail(); + + // create a classifier + let classifier = map + .buffer_snapshot + .char_classifier_at(selection.head().to_point(map)); + + let mut last_selection = selection.clone(); + for _ in 0..times { + let (new_tail, new_head) = movement::find_preceding_boundary_trail( + map, + selection.head(), + |left, right| is_boundary(left, right, &classifier), + ); + + selection.set_head(new_head, selection.goal); + if let Some(new_tail) = new_tail { + selection.set_tail(new_tail, selection.goal); + } + + if selection.head() == last_selection.head() + && selection.tail() == last_selection.tail() + { + break; + } + last_selection = selection.clone(); + } + }); + }) + }); + } + + pub fn helix_move_and_collapse( + &mut self, + motion: Motion, + times: Option, + cx: &mut ViewContext, + ) { + self.update_editor(cx, |_, editor, cx| { + let text_layout_details = editor.text_layout_details(cx); + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + let goal = selection.goal; + let cursor = if selection.is_empty() || selection.reversed { + selection.head() + } else { + movement::left(map, selection.head()) + }; + + let (point, goal) = motion + .move_point(map, cursor, selection.goal, times, &text_layout_details) + .unwrap_or((cursor, goal)); + + selection.collapse_to(point, goal) + }) + }); + }); + } + + pub fn helix_move_cursor( + &mut self, + motion: Motion, + times: Option, + cx: &mut ViewContext, + ) { + match motion { + Motion::NextWordStart { ignore_punctuation } => { + self.helix_find_range_forward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = + left_kind != right_kind && right_kind != CharKind::Whitespace || at_newline; + + found + }) + } + Motion::NextWordEnd { ignore_punctuation } => { + self.helix_find_range_forward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = left_kind != right_kind + && (left_kind != CharKind::Whitespace || at_newline); + + found + }) + } + Motion::PreviousWordStart { ignore_punctuation } => { + self.helix_find_range_backward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = left_kind != right_kind + && (left_kind != CharKind::Whitespace || at_newline); + + found + }) + } + Motion::PreviousWordEnd { ignore_punctuation } => { + self.helix_find_range_backward(times, cx, |left, right, classifier| { + let left_kind = classifier.kind_with(left, ignore_punctuation); + let right_kind = classifier.kind_with(right, ignore_punctuation); + let at_newline = right == '\n'; + + let found = left_kind != right_kind + && right_kind != CharKind::Whitespace + && !at_newline; + + found + }) + } + _ => self.helix_move_and_collapse(motion, times, cx), + } + } +} + +#[cfg(test)] +mod test { + use indoc::indoc; + + use crate::{state::Mode, test::VimTestContext}; + + #[gpui::test] + async fn test_next_word_start(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + // « + // ˇ + // » + cx.set_state( + indoc! {" + The quˇick brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("w"); + + cx.assert_state( + indoc! {" + The qu«ick ˇ»brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("w"); + + cx.assert_state( + indoc! {" + The quick «brownˇ» + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + } +} diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index eb6e8464a3..08cf219722 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -529,6 +529,8 @@ impl Vim { return; } } + + Mode::HelixNormal => {} } } @@ -558,6 +560,8 @@ impl Vim { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { self.visual_motion(motion.clone(), count, cx) } + + Mode::HelixNormal => self.helix_normal_motion(motion.clone(), count, cx), } self.clear_operator(cx); if let Some(operator) = waiting_operator { diff --git a/crates/vim/src/normal/case.rs b/crates/vim/src/normal/case.rs index 0aeb4c7e98..405185adf5 100644 --- a/crates/vim/src/normal/case.rs +++ b/crates/vim/src/normal/case.rs @@ -145,6 +145,8 @@ impl Vim { cursor_positions.push(selection.start..selection.start); } } + + Mode::HelixNormal => {} Mode::Insert | Mode::Normal | Mode::Replace => { let start = selection.start; let mut end = start; diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 380acc896a..b6f164cdb1 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -143,7 +143,7 @@ impl Vim { match self.mode { Mode::Normal => self.normal_object(object, cx), Mode::Visual | Mode::VisualLine | Mode::VisualBlock => self.visual_object(object, cx), - Mode::Insert | Mode::Replace => { + Mode::Insert | Mode::Replace | Mode::HelixNormal => { // Shouldn't execute a text object in insert mode. Ignoring } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index f43de2cf6f..e93eeef404 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -26,6 +26,7 @@ pub enum Mode { Visual, VisualLine, VisualBlock, + HelixNormal, } impl Display for Mode { @@ -37,6 +38,7 @@ impl Display for Mode { Mode::Visual => write!(f, "VISUAL"), Mode::VisualLine => write!(f, "VISUAL LINE"), Mode::VisualBlock => write!(f, "VISUAL BLOCK"), + Mode::HelixNormal => write!(f, "HELIX NORMAL"), } } } @@ -46,6 +48,7 @@ impl Mode { match self { Mode::Normal | Mode::Insert | Mode::Replace => false, Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true, + Mode::HelixNormal => false, } } } diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index a2ab1f3972..a0a2343bdf 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -442,6 +442,7 @@ impl NeovimConnection { } Mode::Insert | Mode::Normal | Mode::Replace => selections .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)), + Mode::HelixNormal => unreachable!(), } let ranges = encode_ranges(&text, &selections); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index db0a765170..c395e9c37e 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -6,6 +6,7 @@ mod test; mod change_list; mod command; mod digraph; +mod helix; mod indent; mod insert; mod mode_indicator; @@ -337,6 +338,7 @@ impl Vim { normal::register(editor, cx); insert::register(editor, cx); + helix::register(editor, cx); motion::register(editor, cx); command::register(editor, cx); replace::register(editor, cx); @@ -631,7 +633,9 @@ impl Vim { } } Mode::Replace => CursorShape::Underline, - Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block, + Mode::HelixNormal | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + CursorShape::Block + } Mode::Insert => CursorShape::Bar, } } @@ -645,9 +649,12 @@ impl Vim { true } } - Mode::Normal | Mode::Replace | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { - false - } + Mode::Normal + | Mode::HelixNormal + | Mode::Replace + | Mode::Visual + | Mode::VisualLine + | Mode::VisualBlock => false, } } @@ -657,9 +664,12 @@ impl Vim { pub fn clip_at_line_ends(&self) -> bool { match self.mode { - Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::Replace => { - false - } + Mode::Insert + | Mode::Visual + | Mode::VisualLine + | Mode::VisualBlock + | Mode::Replace + | Mode::HelixNormal => false, Mode::Normal => true, } } @@ -670,6 +680,7 @@ impl Vim { Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual", Mode::Insert => "insert", Mode::Replace => "replace", + Mode::HelixNormal => "helix_normal", } .to_string(); @@ -998,7 +1009,7 @@ impl Vim { }) }); } - Mode::Insert | Mode::Replace => {} + Mode::Insert | Mode::Replace | Mode::HelixNormal => {} } } From e231321655a170ca30438c1040da053432885b6d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 22:20:25 -0800 Subject: [PATCH 145/215] Fix panic in update_ime_position (#21510) This can call back into the app, so must be done when the platform lock is not held. Release Notes: - Fixes a (rare) panic when changing tab --- crates/gpui/src/platform/mac/window.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 9266f81f74..8ea7ebd4d5 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -1111,10 +1111,16 @@ impl PlatformWindow for MacWindow { } fn update_ime_position(&self, _bounds: Bounds) { - unsafe { - let input_context: id = msg_send![class!(NSTextInputContext), currentInputContext]; - let _: () = msg_send![input_context, invalidateCharacterCoordinates]; - } + let executor = self.0.lock().executor.clone(); + executor + .spawn(async move { + unsafe { + let input_context: id = + msg_send![class!(NSTextInputContext), currentInputContext]; + let _: () = msg_send![input_context, invalidateCharacterCoordinates]; + } + }) + .detach() } } From 196fd65601af0b22cba3f6f0fbbda821b0403427 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 3 Dec 2024 23:01:32 -0800 Subject: [PATCH 146/215] Fix panic folding in multi-buffers (#21511) Closes #19054 Rename `max_buffer_row()` to `widest_line_number()` to (hopefully) prevent people assuming it means the same as `max_point().row`. Release Notes: - Fixed a panic when folding in a multibuffer --- crates/editor/src/display_map.rs | 13 ++++++------- crates/editor/src/display_map/inlay_map.rs | 2 +- crates/editor/src/editor.rs | 14 +++++++++++--- crates/editor/src/element.rs | 8 +------- crates/editor/src/movement.rs | 6 +++--- crates/multi_buffer/src/multi_buffer.rs | 19 +++++++++++-------- crates/vim/src/command.rs | 12 +++++++----- crates/vim/src/motion.rs | 2 +- crates/vim/src/normal/yank.rs | 4 ++-- crates/vim/src/object.rs | 6 +++--- 10 files changed, 46 insertions(+), 40 deletions(-) diff --git a/crates/editor/src/display_map.rs b/crates/editor/src/display_map.rs index b95c9312c5..a75c2ce9fa 100644 --- a/crates/editor/src/display_map.rs +++ b/crates/editor/src/display_map.rs @@ -684,8 +684,8 @@ impl DisplaySnapshot { .map(|row| row.map(MultiBufferRow)) } - pub fn max_buffer_row(&self) -> MultiBufferRow { - self.buffer_snapshot.max_buffer_row() + pub fn widest_line_number(&self) -> u32 { + self.buffer_snapshot.widest_line_number() } pub fn prev_line_boundary(&self, mut point: MultiBufferPoint) -> (Point, DisplayPoint) { @@ -726,11 +726,10 @@ impl DisplaySnapshot { // used by line_mode selections and tries to match vim behavior pub fn expand_to_line(&self, range: Range) -> Range { + let max_row = self.buffer_snapshot.max_row().0; let new_start = if range.start.row == 0 { MultiBufferPoint::new(0, 0) - } else if range.start.row == self.max_buffer_row().0 - || (range.end.column > 0 && range.end.row == self.max_buffer_row().0) - { + } else if range.start.row == max_row || (range.end.column > 0 && range.end.row == max_row) { MultiBufferPoint::new( range.start.row - 1, self.buffer_snapshot @@ -742,7 +741,7 @@ impl DisplaySnapshot { let new_end = if range.end.column == 0 { range.end - } else if range.end.row < self.max_buffer_row().0 { + } else if range.end.row < max_row { self.buffer_snapshot .clip_point(MultiBufferPoint::new(range.end.row + 1, 0), Bias::Left) } else { @@ -1127,7 +1126,7 @@ impl DisplaySnapshot { } pub fn starts_indent(&self, buffer_row: MultiBufferRow) -> bool { - let max_row = self.buffer_snapshot.max_buffer_row(); + let max_row = self.buffer_snapshot.max_row(); if buffer_row >= max_row { return false; } diff --git a/crates/editor/src/display_map/inlay_map.rs b/crates/editor/src/display_map/inlay_map.rs index 673b9383bc..4598a5c015 100644 --- a/crates/editor/src/display_map/inlay_map.rs +++ b/crates/editor/src/display_map/inlay_map.rs @@ -1019,7 +1019,7 @@ impl InlaySnapshot { let inlay_point = InlayPoint::new(row, 0); cursor.seek(&inlay_point, Bias::Left, &()); - let max_buffer_row = MultiBufferRow(self.buffer.max_point().row); + let max_buffer_row = self.buffer.max_row(); let mut buffer_point = cursor.start().1; let buffer_row = if row == 0 { MultiBufferRow(0) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3901ba47aa..ea03d027c4 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6502,7 +6502,7 @@ impl Editor { let mut revert_changes = HashMap::default(); let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); for hunk in hunks_for_rows( - Some(MultiBufferRow(0)..multi_buffer_snapshot.max_buffer_row()).into_iter(), + Some(MultiBufferRow(0)..multi_buffer_snapshot.max_row()).into_iter(), &multi_buffer_snapshot, ) { Self::prepare_revert_change(&mut revert_changes, self.buffer(), &hunk, cx); @@ -11051,10 +11051,14 @@ impl Editor { } fn fold_at_level(&mut self, fold_at: &FoldAtLevel, cx: &mut ViewContext) { + if !self.buffer.read(cx).is_singleton() { + return; + } + let fold_at_level = fold_at.level; let snapshot = self.buffer.read(cx).snapshot(cx); let mut to_fold = Vec::new(); - let mut stack = vec![(0, snapshot.max_buffer_row().0, 1)]; + let mut stack = vec![(0, snapshot.max_row().0, 1)]; while let Some((mut start_row, end_row, current_level)) = stack.pop() { while start_row < end_row { @@ -11083,10 +11087,14 @@ impl Editor { } pub fn fold_all(&mut self, _: &actions::FoldAll, cx: &mut ViewContext) { + if !self.buffer.read(cx).is_singleton() { + return; + } + let mut fold_ranges = Vec::new(); let snapshot = self.buffer.read(cx).snapshot(cx); - for row in 0..snapshot.max_buffer_row().0 { + for row in 0..snapshot.max_row().0 { if let Some(foldable_range) = self.snapshot(cx).crease_for_buffer_row(MultiBufferRow(row)) { diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index a82820f265..2bb40c4602 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4141,13 +4141,7 @@ impl EditorElement { } fn max_line_number_width(&self, snapshot: &EditorSnapshot, cx: &WindowContext) -> Pixels { - let digit_count = snapshot - .max_buffer_row() - .next_row() - .as_f32() - .log10() - .floor() as usize - + 1; + let digit_count = (snapshot.widest_line_number() as f32).log10().floor() as usize + 1; self.column_pixels(digit_count, cx) } } diff --git a/crates/editor/src/movement.rs b/crates/editor/src/movement.rs index 8189dd2947..8fbf0d16f1 100644 --- a/crates/editor/src/movement.rs +++ b/crates/editor/src/movement.rs @@ -2,7 +2,7 @@ //! in editor given a given motion (e.g. it handles converting a "move left" command into coordinates in editor). It is exposed mostly for use by vim crate. use super::{Bias, DisplayPoint, DisplaySnapshot, SelectionGoal, ToDisplayPoint}; -use crate::{scroll::ScrollAnchor, CharKind, DisplayRow, EditorStyle, RowExt, ToOffset, ToPoint}; +use crate::{scroll::ScrollAnchor, CharKind, DisplayRow, EditorStyle, ToOffset, ToPoint}; use gpui::{Pixels, WindowTextSystem}; use language::Point; use multi_buffer::{MultiBufferRow, MultiBufferSnapshot}; @@ -382,12 +382,12 @@ pub fn end_of_paragraph( mut count: usize, ) -> DisplayPoint { let point = display_point.to_point(map); - if point.row == map.max_buffer_row().0 { + if point.row == map.buffer_snapshot.max_row().0 { return map.max_point(); } let mut found_non_blank_line = false; - for row in point.row..map.max_buffer_row().next_row().0 { + for row in point.row..=map.buffer_snapshot.max_row().0 { let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row)); if found_non_blank_line && blank { if count <= 1 { diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 461498d00d..d52d65bca2 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -281,8 +281,7 @@ pub struct ExcerptSummary { excerpt_id: ExcerptId, /// The location of the last [`Excerpt`] being summarized excerpt_locator: Locator, - /// The maximum row of the [`Excerpt`]s being summarized - max_buffer_row: MultiBufferRow, + widest_line_number: u32, text: TextSummary, } @@ -2556,8 +2555,8 @@ impl MultiBufferSnapshot { self.excerpts.summary().text.len == 0 } - pub fn max_buffer_row(&self) -> MultiBufferRow { - self.excerpts.summary().max_buffer_row + pub fn widest_line_number(&self) -> u32 { + self.excerpts.summary().widest_line_number + 1 } pub fn clip_offset(&self, offset: usize, bias: Bias) -> usize { @@ -3026,6 +3025,10 @@ impl MultiBufferSnapshot { self.text_summary().lines } + pub fn max_row(&self) -> MultiBufferRow { + MultiBufferRow(self.text_summary().lines.row) + } + pub fn text_summary(&self) -> TextSummary { self.excerpts.summary().text.clone() } @@ -4824,7 +4827,7 @@ impl sum_tree::Item for Excerpt { ExcerptSummary { excerpt_id: self.id, excerpt_locator: self.locator.clone(), - max_buffer_row: MultiBufferRow(self.max_buffer_row), + widest_line_number: self.max_buffer_row, text, } } @@ -4869,7 +4872,7 @@ impl sum_tree::Summary for ExcerptSummary { debug_assert!(summary.excerpt_locator > self.excerpt_locator); self.excerpt_locator = summary.excerpt_locator.clone(); self.text.add_summary(&summary.text, &()); - self.max_buffer_row = cmp::max(self.max_buffer_row, summary.max_buffer_row); + self.widest_line_number = cmp::max(self.widest_line_number, summary.widest_line_number); } } @@ -6383,8 +6386,8 @@ mod tests { } assert_eq!( - snapshot.max_buffer_row().0, - expected_buffer_rows.into_iter().flatten().max().unwrap() + snapshot.widest_line_number(), + expected_buffer_rows.into_iter().flatten().max().unwrap() + 1 ); let mut excerpt_starts = excerpt_starts.into_iter(); diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 5a958da012..68aefc8cd7 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -136,7 +136,7 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext) { vim.update_editor(cx, |vim, editor, cx| { let snapshot = editor.snapshot(cx); if let Ok(range) = action.range.buffer_range(vim, editor, cx) { - let end = if range.end < snapshot.max_buffer_row() { + let end = if range.end < snapshot.buffer_snapshot.max_row() { Point::new(range.end.0 + 1, 0) } else { snapshot.buffer_snapshot.max_point() @@ -436,9 +436,11 @@ impl Position { .row .saturating_add_signed(*offset) } - Position::LastLine { offset } => { - snapshot.max_buffer_row().0.saturating_add_signed(*offset) - } + Position::LastLine { offset } => snapshot + .buffer_snapshot + .max_row() + .0 + .saturating_add_signed(*offset), Position::CurrentLine { offset } => editor .selections .newest_anchor() @@ -448,7 +450,7 @@ impl Position { .saturating_add_signed(*offset), }; - Ok(MultiBufferRow(target).min(snapshot.max_buffer_row())) + Ok(MultiBufferRow(target).min(snapshot.buffer_snapshot.max_row())) } } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 08cf219722..0e236861b6 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -1866,7 +1866,7 @@ fn end_of_document( let new_row = if let Some(line) = line { (line - 1) as u32 } else { - map.max_buffer_row().0 + map.buffer_snapshot.max_row().0 }; let new_point = Point::new(new_row, point.column()); diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 763f1a3d16..85c6531f6a 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -154,9 +154,9 @@ impl Vim { // contains a newline (so that delete works as expected). We undo that change // here. let is_last_line = linewise - && end.row == buffer.max_buffer_row().0 + && end.row == buffer.max_row().0 && buffer.max_point().column > 0 - && start.row < buffer.max_buffer_row().0 + && start.row < buffer.max_row().0 && start == Point::new(start.row, buffer.line_len(MultiBufferRow(start.row))); if is_last_line { diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index b6f164cdb1..c63cb0e843 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -724,7 +724,7 @@ fn indent( // Loop forwards until we find a non-blank line with less indent let mut end_row = row; - let max_rows = map.max_buffer_row().0; + let max_rows = map.buffer_snapshot.max_row().0; for next_row in (row + 1)..=max_rows { let indent = map.line_indent_for_buffer_row(MultiBufferRow(next_row)); if indent.is_line_empty() { @@ -958,13 +958,13 @@ pub fn start_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> /// The trailing newline is excluded from the paragraph. pub fn end_of_paragraph(map: &DisplaySnapshot, display_point: DisplayPoint) -> DisplayPoint { let point = display_point.to_point(map); - if point.row == map.max_buffer_row().0 { + if point.row == map.buffer_snapshot.max_row().0 { return map.max_point(); } let is_current_line_blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(point.row)); - for row in point.row + 1..map.max_buffer_row().0 + 1 { + for row in point.row + 1..map.buffer_snapshot.max_row().0 + 1 { let blank = map.buffer_snapshot.is_line_blank(MultiBufferRow(row)); if blank != is_current_line_blank { let previous_row = row - 1; From d8732adfb2c96ffb0c3d9df679bf782b02e82b52 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Wed, 4 Dec 2024 18:10:53 +0530 Subject: [PATCH 147/215] Add fuzzy matching for snippets completions (#21524) Closes #21439 This PR uses fuzzy matching for snippet completions instead of fixed-prefix matching. This mimics the behavior of VSCode. fuzzy Release Notes: - Improved suggestions for snippets. --- crates/editor/src/editor.rs | 180 +++++++++++++++++++++++------------- 1 file changed, 115 insertions(+), 65 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index ea03d027c4..69383ceb82 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13812,80 +13812,130 @@ fn snippet_completions( buffer: &Model, buffer_position: text::Anchor, cx: &mut AppContext, -) -> Vec { +) -> Task>> { let language = buffer.read(cx).language_at(buffer_position); let language_name = language.as_ref().map(|language| language.lsp_id()); let snippet_store = project.snippets().read(cx); let snippets = snippet_store.snippets_for(language_name, cx); if snippets.is_empty() { - return vec![]; + return Task::ready(Ok(vec![])); } let snapshot = buffer.read(cx).text_snapshot(); - let chars = snapshot.reversed_chars_for_range(text::Anchor::MIN..buffer_position); + let chars: String = snapshot + .reversed_chars_for_range(text::Anchor::MIN..buffer_position) + .collect(); let scope = language.map(|language| language.default_scope()); - let classifier = CharClassifier::new(scope).for_completion(true); - let mut last_word = chars - .take_while(|c| classifier.is_word(*c)) - .collect::(); - last_word = last_word.chars().rev().collect(); - let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); - let to_lsp = |point: &text::Anchor| { - let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); - point_to_lsp(end) - }; - let lsp_end = to_lsp(&buffer_position); - snippets - .into_iter() - .filter_map(|snippet| { - let matching_prefix = snippet - .prefix - .iter() - .find(|prefix| prefix.starts_with(&last_word))?; - let start = as_offset - last_word.len(); - let start = snapshot.anchor_before(start); - let range = start..buffer_position; - let lsp_start = to_lsp(&start); - let lsp_range = lsp::Range { - start: lsp_start, - end: lsp_end, - }; - Some(Completion { - old_range: range, - new_text: snippet.body.clone(), - label: CodeLabel { - text: matching_prefix.clone(), - runs: vec![], - filter_range: 0..matching_prefix.len(), - }, - server_id: LanguageServerId(usize::MAX), - documentation: snippet.description.clone().map(Documentation::SingleLine), - lsp_completion: lsp::CompletionItem { - label: snippet.prefix.first().unwrap().clone(), - kind: Some(CompletionItemKind::SNIPPET), - label_details: snippet.description.as_ref().map(|description| { - lsp::CompletionItemLabelDetails { - detail: Some(description.clone()), - description: None, - } - }), - insert_text_format: Some(InsertTextFormat::SNIPPET), - text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( - lsp::InsertReplaceEdit { - new_text: snippet.body.clone(), - insert: lsp_range, - replace: lsp_range, - }, - )), - filter_text: Some(snippet.body.clone()), - sort_text: Some(char::MAX.to_string()), - ..Default::default() - }, - confirm: None, + let executor = cx.background_executor().clone(); + + cx.background_executor().spawn(async move { + let classifier = CharClassifier::new(scope).for_completion(true); + let mut last_word = chars + .chars() + .take_while(|c| classifier.is_word(*c)) + .collect::(); + last_word = last_word.chars().rev().collect(); + let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); + let to_lsp = |point: &text::Anchor| { + let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); + point_to_lsp(end) + }; + let lsp_end = to_lsp(&buffer_position); + + let candidates = snippets + .iter() + .enumerate() + .flat_map(|(ix, snippet)| { + snippet + .prefix + .iter() + .map(move |prefix| StringMatchCandidate::new(ix, prefix.clone())) }) - }) - .collect() + .collect::>(); + + let mut matches = fuzzy::match_strings( + &candidates, + &last_word, + last_word.chars().any(|c| c.is_uppercase()), + 100, + &Default::default(), + executor, + ) + .await; + + // Remove all candidates where the query's start does not match the start of any word in the candidate + if let Some(query_start) = last_word.chars().next() { + matches.retain(|string_match| { + split_words(&string_match.string).any(|word| { + // Check that the first codepoint of the word as lowercase matches the first + // codepoint of the query as lowercase + word.chars() + .flat_map(|codepoint| codepoint.to_lowercase()) + .zip(query_start.to_lowercase()) + .all(|(word_cp, query_cp)| word_cp == query_cp) + }) + }); + } + + let matched_strings = matches + .into_iter() + .map(|m| m.string) + .collect::>(); + + let result: Vec = snippets + .into_iter() + .filter_map(|snippet| { + let matching_prefix = snippet + .prefix + .iter() + .find(|prefix| matched_strings.contains(*prefix))?; + let start = as_offset - last_word.len(); + let start = snapshot.anchor_before(start); + let range = start..buffer_position; + let lsp_start = to_lsp(&start); + let lsp_range = lsp::Range { + start: lsp_start, + end: lsp_end, + }; + Some(Completion { + old_range: range, + new_text: snippet.body.clone(), + label: CodeLabel { + text: matching_prefix.clone(), + runs: vec![], + filter_range: 0..matching_prefix.len(), + }, + server_id: LanguageServerId(usize::MAX), + documentation: snippet.description.clone().map(Documentation::SingleLine), + lsp_completion: lsp::CompletionItem { + label: snippet.prefix.first().unwrap().clone(), + kind: Some(CompletionItemKind::SNIPPET), + label_details: snippet.description.as_ref().map(|description| { + lsp::CompletionItemLabelDetails { + detail: Some(description.clone()), + description: None, + } + }), + insert_text_format: Some(InsertTextFormat::SNIPPET), + text_edit: Some(lsp::CompletionTextEdit::InsertAndReplace( + lsp::InsertReplaceEdit { + new_text: snippet.body.clone(), + insert: lsp_range, + replace: lsp_range, + }, + )), + filter_text: Some(snippet.body.clone()), + sort_text: Some(char::MAX.to_string()), + ..Default::default() + }, + confirm: None, + }) + }) + .collect(); + + Ok(result) + }) } impl CompletionProvider for Model { @@ -13901,8 +13951,8 @@ impl CompletionProvider for Model { let project_completions = project.completions(buffer, buffer_position, options, cx); cx.background_executor().spawn(async move { let mut completions = project_completions.await?; - //let snippets = snippets.into_iter().; - completions.extend(snippets); + let snippets_completions = snippets.await?; + completions.extend(snippets_completions); Ok(completions) }) }) From 0ee99c6d9c60dcd84c03fee377ee26fdb82ac89b Mon Sep 17 00:00:00 2001 From: David Soria Parra <167242713+dsp-ant@users.noreply.github.com> Date: Wed, 4 Dec 2024 15:45:25 +0000 Subject: [PATCH 148/215] context_server: Add missing types for MCP spec to protocol 2024-11-05 (#21498) This commit syncs missing types for the mcp spec 2024-11-05. Release Notes: - N/A --- .../slash_command/context_server_command.rs | 2 +- crates/context_server/src/types.rs | 82 ++++++++++++++++++- 2 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/assistant/src/slash_command/context_server_command.rs b/crates/assistant/src/slash_command/context_server_command.rs index b183a77f54..8c53ddb773 100644 --- a/crates/assistant/src/slash_command/context_server_command.rs +++ b/crates/assistant/src/slash_command/context_server_command.rs @@ -164,7 +164,7 @@ impl SlashCommand for ContextServerSlashCommand { .messages .into_iter() .filter_map(|msg| match msg.content { - context_server::types::MessageContent::Text { text } => Some(text), + context_server::types::MessageContent::Text { text, .. } => Some(text), _ => None, }) .collect::>() diff --git a/crates/context_server/src/types.rs b/crates/context_server/src/types.rs index 851ebbf08b..f3c6e1c5e2 100644 --- a/crates/context_server/src/types.rs +++ b/crates/context_server/src/types.rs @@ -167,11 +167,18 @@ pub struct InitializeResponse { #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResourcesReadResponse { - pub contents: Vec, + pub contents: Vec, #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option>, } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum ResourceContentsType { + Text(TextResourceContents), + Blob(BlobResourceContents), +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ResourcesListResponse { @@ -181,6 +188,7 @@ pub struct ResourcesListResponse { #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] pub meta: Option>, } + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SamplingMessage { @@ -188,6 +196,35 @@ pub struct SamplingMessage { pub content: MessageContent, } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMessageRequest { + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_preferences: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub system_prompt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub include_context: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + pub max_tokens: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_sequences: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub metadata: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateMessageResult { + pub role: Role, + pub content: MessageContent, + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub stop_reason: Option, +} + #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PromptMessage { @@ -206,11 +243,33 @@ pub enum Role { #[serde(tag = "type")] pub enum MessageContent { #[serde(rename = "text")] - Text { text: String }, + Text { + text: String, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option, + }, #[serde(rename = "image")] - Image { data: String, mime_type: String }, + Image { + data: String, + mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option, + }, #[serde(rename = "resource")] - Resource { resource: ResourceContents }, + Resource { + resource: ResourceContents, + #[serde(skip_serializing_if = "Option::is_none")] + annotations: Option, + }, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MessageAnnotations { + #[serde(skip_serializing_if = "Option::is_none")] + pub audience: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub priority: Option, } #[derive(Debug, Deserialize)] @@ -460,6 +519,11 @@ pub enum ClientNotification { Initialized, Progress(ProgressParams), RootsListChanged, + Cancelled { + request_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + reason: Option, + }, } #[derive(Debug, Serialize, Deserialize)] @@ -532,6 +596,16 @@ pub struct ListToolsResponse { pub meta: Option>, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListResourceTemplatesResponse { + pub resource_templates: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + #[serde(rename = "_meta", skip_serializing_if = "Option::is_none")] + pub meta: Option>, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ListRootsResponse { From 207eb51df198eae9417fcd15e0df2c2d4bd3bee6 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 4 Dec 2024 11:14:35 -0500 Subject: [PATCH 149/215] assistant2: Style inline code in Markdown (#21536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds some styling for inline code within the messages to differentiate them from the surrounding text: Screenshot 2024-12-04 at 10 58 14 AM Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index b8ce5b1a36..16d5e62a7c 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -152,11 +152,13 @@ impl AssistantPanel { .map(|message| message.text.clone()) { let theme_settings = ThemeSettings::get_global(cx); + let ui_font_size = TextSize::Default.rems(cx); + let buffer_font_size = theme_settings.buffer_font_size; let mut text_style = cx.text_style(); text_style.refine(&TextStyleRefinement { font_family: Some(theme_settings.ui_font.family.clone()), - font_size: Some(TextSize::Default.rems(cx).into()), + font_size: Some(ui_font_size.into()), color: Some(cx.theme().colors().text), ..Default::default() }); @@ -168,11 +170,17 @@ impl AssistantPanel { code_block: StyleRefinement { text: Some(TextStyleRefinement { font_family: Some(theme_settings.buffer_font.family.clone()), - font_size: Some(theme_settings.buffer_font_size.into()), + font_size: Some(buffer_font_size.into()), ..Default::default() }), ..Default::default() }, + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(ui_font_size.into()), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }, ..Default::default() }; From 5948ea217bcf8b220098b45e74e7a9e7fb492421 Mon Sep 17 00:00:00 2001 From: Vedant Matanhelia Date: Wed, 4 Dec 2024 21:53:31 +0530 Subject: [PATCH 150/215] Configure Highlight settings on yank vim (#21479) Release Notes: - Add settings / config variables to control `highlight_on_yank` or `highlight_on_copy` --------- Co-authored-by: Conrad Irwin --- assets/settings/default.json | 1 + crates/vim/src/normal/yank.rs | 8 +++++--- crates/vim/src/vim.rs | 2 ++ docs/src/vim.md | 2 ++ 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 5930537856..97e05c9ad5 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1129,6 +1129,7 @@ "use_system_clipboard": "always", "use_multiline_find": false, "use_smartcase_find": false, + "highlight_on_yank_duration": 200, "custom_digraphs": {} }, // The server to connect to. If the environment variable diff --git a/crates/vim/src/normal/yank.rs b/crates/vim/src/normal/yank.rs index 85c6531f6a..d23dc2f9b0 100644 --- a/crates/vim/src/normal/yank.rs +++ b/crates/vim/src/normal/yank.rs @@ -4,13 +4,14 @@ use crate::{ motion::Motion, object::Object, state::{Mode, Register}, - Vim, + Vim, VimSettings, }; use collections::HashMap; use editor::{ClipboardSelection, Editor}; use gpui::ViewContext; use language::Point; use multi_buffer::MultiBufferRow; +use settings::Settings; struct HighlightOnYank; @@ -195,7 +196,8 @@ impl Vim { ) }); - if !is_yank || self.mode == Mode::Visual { + let highlight_duration = VimSettings::get_global(cx).highlight_on_yank_duration; + if !is_yank || self.mode == Mode::Visual || highlight_duration == 0 { return; } @@ -206,7 +208,7 @@ impl Vim { ); cx.spawn(|this, mut cx| async move { cx.background_executor() - .timer(Duration::from_millis(200)) + .timer(Duration::from_millis(highlight_duration)) .await; this.update(&mut cx, |editor, cx| { editor.clear_background_highlights::(cx) diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index c395e9c37e..843b094700 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1199,6 +1199,7 @@ struct VimSettings { pub use_multiline_find: bool, pub use_smartcase_find: bool, pub custom_digraphs: HashMap>, + pub highlight_on_yank_duration: u64, } #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -1208,6 +1209,7 @@ struct VimSettingsContent { pub use_multiline_find: Option, pub use_smartcase_find: Option, pub custom_digraphs: Option>>, + pub highlight_on_yank_duration: Option, } impl Settings for VimSettings { diff --git a/docs/src/vim.md b/docs/src/vim.md index c0a7fed2e2..a350fb7773 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -438,6 +438,7 @@ You can change the following settings to modify vim mode's behavior: | use_smartcase_find | If `true`, `f` and `t` motions are case-insensitive when the target letter is lowercase. | false | | toggle_relative_line_numbers | If `true`, line numbers are relative in normal mode and absolute in insert mode, giving you the best of both options. | false | | custom_digraphs | An object that allows you to add custom digraphs. Read below for an example. | {} | +| highlight_on_yank_duration | The duration of the highlight animation(in ms). Set to `0` to disable | 200 | Here's an example of adding a digraph for the zombie emoji. This allows you to type `ctrl-k f z` to insert a zombie emoji. You can add as many digraphs as you like. @@ -460,6 +461,7 @@ Here's an example of these settings changed: "use_multiline_find": true, "use_smartcase_find": true, "toggle_relative_line_numbers": true, + "highlight_on_yank_duration": 50, "custom_digraphs": { "fz": "🧟‍♀️" } From 706372fe4eb64e52274012af6f18f42065bcc509 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 4 Dec 2024 17:59:27 +0100 Subject: [PATCH 151/215] title_bar: Add show_user_picture setting to let users hide their profile picture (#21526) Fixes #21464 Closes #21464 Release Notes: - Added `show_user_picture` setting (default: true) to allow users to hide their profile picture in titlebar. --- assets/settings/default.json | 2 ++ crates/title_bar/Cargo.toml | 1 + crates/title_bar/src/title_bar.rs | 7 ++++++- crates/workspace/src/workspace_settings.rs | 5 +++++ 4 files changed, 14 insertions(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index 97e05c9ad5..db3b7130e0 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1187,6 +1187,8 @@ // "W": "workspace::Save" // } "command_aliases": {}, + // Whether to show user picture in titlebar. + "show_user_picture": true, // ssh_connections is an array of ssh connections. // You can configure these from `project: Open Remote` in the command palette. // Zed's ssh support will pull configuration from your ~/.ssh too. diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index 0a2878b357..9d2fb598fa 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -37,6 +37,7 @@ project.workspace = true remote.workspace = true rpc.workspace = true serde.workspace = true +settings.workspace = true smallvec.workspace = true story = { workspace = true, optional = true } theme.workspace = true diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 4e9a99433a..b6e08e2126 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -19,6 +19,7 @@ use gpui::{ }; use project::{Project, RepositoryEntry}; use rpc::proto; +use settings::Settings as _; use smallvec::SmallVec; use std::sync::Arc; use theme::ActiveTheme; @@ -600,7 +601,11 @@ impl TitleBar { .child( h_flex() .gap_0p5() - .child(Avatar::new(user.avatar_uri.clone())) + .children( + workspace::WorkspaceSettings::get_global(cx) + .show_user_picture + .then(|| Avatar::new(user.avatar_uri.clone())), + ) .child( Icon::new(IconName::ChevronDown) .size(IconSize::Small) diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 0d872425c1..b27a09c24c 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -19,6 +19,7 @@ pub struct WorkspaceSettings { pub when_closing_with_no_tabs: CloseWindowWhenNoItems, pub use_system_path_prompts: bool, pub command_aliases: HashMap, + pub show_user_picture: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema)] @@ -128,6 +129,10 @@ pub struct WorkspaceSettingsContent { /// /// Default: true pub command_aliases: Option>, + /// Whether to show user avatar in the title bar. + /// + /// Default: true + pub show_user_picture: Option, } #[derive(Deserialize)] From cf781dff716bdda7d6e64fd3db0ee3ac7706a9f7 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 4 Dec 2024 12:01:28 -0500 Subject: [PATCH 152/215] v0.166.x dev --- Cargo.lock | 2 +- crates/zed/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 21db0ceb99..fc64ca4093 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15594,7 +15594,7 @@ dependencies = [ [[package]] name = "zed" -version = "0.165.0" +version = "0.166.0" dependencies = [ "activity_indicator", "anyhow", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 24fc0dec8b..74dd2601ad 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -2,7 +2,7 @@ description = "The fast, collaborative code editor." edition = "2021" name = "zed" -version = "0.165.0" +version = "0.166.0" publish = false license = "GPL-3.0-or-later" authors = ["Zed Team "] From fee0624299fe68304da1c6cb4d3a23856237c4fe Mon Sep 17 00:00:00 2001 From: Stanislav Alekseev <43210583+WeetHet@users.noreply.github.com> Date: Wed, 4 Dec 2024 19:39:23 +0200 Subject: [PATCH 153/215] Force code actions to be single line (#21409) Addresses #21403 partially. Is consistent with the behaviour in VSCode Before: 391571084-1bef4ef9-b8f5-4c8f-9a32-9c0ab6c91af1 After: Screenshot 2024-12-02 at 18 35 11 Release Notes: - Fixed an issue with multiline code actions' rendering by forcing them to be single line --- crates/editor/src/editor.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 69383ceb82..2464ce8427 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1690,7 +1690,9 @@ impl CodeActionsMenu { }), ) // TASK: It would be good to make lsp_action.title a SharedString to avoid allocating here. - .child(SharedString::from(action.lsp_action.title.clone())) + .child(SharedString::from( + action.lsp_action.title.replace("\n", ""), + )) }) .when_some(action.as_task(), |this, task| { this.on_mouse_down( @@ -1707,7 +1709,7 @@ impl CodeActionsMenu { } }), ) - .child(SharedString::from(task.resolved_label.clone())) + .child(SharedString::from(task.resolved_label.replace("\n", ""))) }) }) .collect() From 7cfc972df660e8b3594c020dd5e00395cdf625b7 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 4 Dec 2024 12:44:03 -0500 Subject: [PATCH 154/215] assistant2: Add empty state for new threads (#21542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds an empty state for new threads in Assistant2: Screenshot 2024-12-04 at 12 17 46 PM This is mostly just a sketch in its current state. Release Notes: - N/A --- assets/keymaps/default-macos.json | 3 +- crates/assistant2/src/assistant.rs | 8 +- crates/assistant2/src/assistant_panel.rs | 142 +++++++++++++++++++++-- 3 files changed, 141 insertions(+), 12 deletions(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 71d997d2b1..65389230ac 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -210,7 +210,8 @@ { "context": "AssistantPanel2", "bindings": { - "cmd-n": "assistant2::NewThread" + "cmd-n": "assistant2::NewThread", + "cmd-shift-h": "assistant2::OpenHistory" } }, { diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 8ef4a1d9dc..aa79ce0c67 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -11,7 +11,13 @@ pub use crate::assistant_panel::AssistantPanel; actions!( assistant2, - [ToggleFocus, NewThread, ToggleModelSelector, Chat] + [ + ToggleFocus, + NewThread, + ToggleModelSelector, + OpenHistory, + Chat + ] ); const NAMESPACE: &str = "assistant2"; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 16d5e62a7c..2dc4582eee 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -5,8 +5,8 @@ use assistant_tool::ToolWorkingSet; use client::zed_urls; use collections::HashMap; use gpui::{ - list, prelude::*, px, Action, AnyElement, AppContext, AsyncWindowContext, Empty, EventEmitter, - FocusHandle, FocusableView, FontWeight, ListAlignment, ListState, Model, Pixels, + list, prelude::*, px, svg, Action, AnyElement, AppContext, AsyncWindowContext, Empty, + EventEmitter, FocusHandle, FocusableView, FontWeight, ListAlignment, ListState, Model, Pixels, StyleRefinement, Subscription, Task, TextStyleRefinement, View, ViewContext, WeakView, WindowContext, }; @@ -16,14 +16,14 @@ use language_model_selector::LanguageModelSelector; use markdown::{Markdown, MarkdownStyle}; use settings::Settings; use theme::ThemeSettings; -use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, Tab, Tooltip}; +use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, KeyBinding, ListItem, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent}; use crate::thread_store::ThreadStore; -use crate::{NewThread, ToggleFocus, ToggleModelSelector}; +use crate::{NewThread, OpenHistory, ToggleFocus, ToggleModelSelector}; pub fn init(cx: &mut AppContext) { cx.observe_new_views( @@ -311,8 +311,8 @@ impl AssistantPanel { ) } }) - .on_click(move |_event, _cx| { - println!("New Thread"); + .on_click(move |_event, cx| { + cx.dispatch_action(NewThread.boxed_clone()); }), ) .child( @@ -320,9 +320,19 @@ impl AssistantPanel { .shape(IconButtonShape::Square) .icon_size(IconSize::Small) .style(ButtonStyle::Subtle) - .tooltip(move |cx| Tooltip::text("Open History", cx)) - .on_click(move |_event, _cx| { - println!("Open History"); + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Open History", + &OpenHistory, + &focus_handle, + cx, + ) + } + }) + .on_click(move |_event, cx| { + cx.dispatch_action(OpenHistory.boxed_clone()); }), ) .child( @@ -389,6 +399,99 @@ impl AssistantPanel { ) } + fn render_message_list(&self, cx: &mut ViewContext) -> AnyElement { + if self.thread_messages.is_empty() { + #[allow(clippy::useless_vec)] + let recent_threads = vec![1, 2, 3]; + + return v_flex() + .gap_2() + .mx_auto() + .child( + v_flex().w_full().child( + svg() + .path("icons/logo_96.svg") + .text_color(cx.theme().colors().text) + .w(px(40.)) + .h(px(40.)) + .mx_auto() + .mb_4(), + ), + ) + .child(v_flex()) + .child( + h_flex() + .w_full() + .justify_center() + .child(Label::new("Context Examples:").size(LabelSize::Small)), + ) + .child( + h_flex() + .gap_2() + .justify_center() + .child( + h_flex() + .gap_1() + .p_0p5() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .child( + Icon::new(IconName::Terminal) + .size(IconSize::Small) + .color(Color::Disabled), + ) + .child(Label::new("Terminal").size(LabelSize::Small)), + ) + .child( + h_flex() + .gap_1() + .p_0p5() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .child( + Icon::new(IconName::Folder) + .size(IconSize::Small) + .color(Color::Disabled), + ) + .child(Label::new("/src/components").size(LabelSize::Small)), + ), + ) + .child( + h_flex() + .w_full() + .justify_center() + .child(Label::new("Recent Threads:").size(LabelSize::Small)), + ) + .child( + v_flex().gap_2().children( + recent_threads + .iter() + .map(|_thread| self.render_past_thread(cx)), + ), + ) + .child( + h_flex().w_full().justify_center().child( + Button::new("view-all-past-threads", "View All Past Threads") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + cx, + )) + .on_click(move |_event, cx| { + cx.dispatch_action(OpenHistory.boxed_clone()); + }), + ), + ) + .into_any(); + } + + list(self.thread_list_state.clone()).flex_1().into_any() + } + fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { let message_id = self.thread_messages[ix]; let Some(message) = self.thread.read(cx).message(message_id) else { @@ -431,6 +534,22 @@ impl AssistantPanel { .into_any() } + fn render_past_thread(&self, _cx: &mut ViewContext) -> impl IntoElement { + ListItem::new("temp") + .start_slot(Icon::new(IconName::MessageBubbles)) + .child(Label::new("Some Thread Title")) + .end_slot( + h_flex() + .gap_2() + .child(Label::new("1 hour ago").color(Color::Disabled)) + .child( + IconButton::new("delete", IconName::TrashAlt) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small), + ), + ) + } + fn render_last_error(&self, cx: &mut ViewContext) -> Option { let last_error = self.last_error.as_ref()?; @@ -587,8 +706,11 @@ impl Render for AssistantPanel { .on_action(cx.listener(|this, _: &NewThread, cx| { this.new_thread(cx); })) + .on_action(cx.listener(|_this, _: &OpenHistory, _cx| { + println!("Open History"); + })) .child(self.render_toolbar(cx)) - .child(list(self.thread_list_state.clone()).flex_1()) + .child(self.render_message_list(cx)) .child( h_flex() .border_t_1() From 44264ffedcfe1a87606e7d6a9a52aaadf88061f5 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 4 Dec 2024 10:58:56 -0800 Subject: [PATCH 155/215] Revert accidental change to Rust outline files (#21545) Release Notes: - Preview only: Fixed impl blocks in the rust outline view --- crates/languages/src/rust/outline.scm | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/languages/src/rust/outline.scm b/crates/languages/src/rust/outline.scm index 4299a01f19..3012995e2a 100644 --- a/crates/languages/src/rust/outline.scm +++ b/crates/languages/src/rust/outline.scm @@ -15,7 +15,11 @@ (visibility_modifier)? @context name: (_) @name) @item -(function_item +(impl_item + "impl" @context + trait: (_)? @name + "for"? @context + type: (_) @name body: (_ "{" @open (_)* "}" @close)) @item (trait_item From 0bde0f8e2f3879a8f304716ba570375955bfe9b2 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 4 Dec 2024 14:35:44 -0500 Subject: [PATCH 156/215] assistant2: Add ability to open past threads (#21548) This PR adds the ability to open past threads in Assistant 2. There are also some mocked threads in the history for testing purposes. Release Notes: - N/A --- Cargo.lock | 2 + crates/assistant2/Cargo.toml | 2 + crates/assistant2/src/assistant_panel.rs | 149 ++++++++++++++--------- crates/assistant2/src/thread.rs | 37 +++++- crates/assistant2/src/thread_store.rs | 113 ++++++++++++++++- 5 files changed, 243 insertions(+), 60 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc64ca4093..72bace069b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -478,7 +478,9 @@ dependencies = [ "smol", "theme", "ui", + "unindent", "util", + "uuid", "workspace", ] diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index 257183a4ac..fb7dcbe520 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -37,5 +37,7 @@ settings.workspace = true smol.workspace = true theme.workspace = true ui.workspace = true +unindent.workspace = true util.workspace = true +uuid.workspace = true workspace.workspace = true diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 2dc4582eee..00bd15de2e 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -21,7 +21,7 @@ use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::message_editor::MessageEditor; -use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent}; +use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent, ThreadId}; use crate::thread_store::ThreadStore; use crate::{NewThread, OpenHistory, ToggleFocus, ToggleModelSelector}; @@ -77,7 +77,7 @@ impl AssistantPanel { tools: Arc, cx: &mut ViewContext, ) -> Self { - let thread = cx.new_model(|cx| Thread::new(tools.clone(), cx)); + let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); let subscriptions = vec![ cx.observe(&thread, |_, _, cx| cx.notify()), cx.subscribe(&thread, Self::handle_thread_event), @@ -105,8 +105,27 @@ impl AssistantPanel { } fn new_thread(&mut self, cx: &mut ViewContext) { - let tools = self.thread.read(cx).tools().clone(); - let thread = cx.new_model(|cx| Thread::new(tools, cx)); + let thread = self + .thread_store + .update(cx, |this, cx| this.create_thread(cx)); + self.reset_thread(thread, cx); + } + + fn open_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { + let Some(thread) = self + .thread_store + .update(cx, |this, cx| this.open_thread(thread_id, cx)) + else { + return; + }; + self.reset_thread(thread.clone(), cx); + + for message in thread.read(cx).messages().cloned().collect::>() { + self.push_message(&message.id, message.text.clone(), cx); + } + } + + fn reset_thread(&mut self, thread: Model, cx: &mut ViewContext) { let subscriptions = vec![ cx.observe(&thread, |_, _, cx| cx.notify()), cx.subscribe(&thread, Self::handle_thread_event), @@ -122,6 +141,56 @@ impl AssistantPanel { self.message_editor.focus_handle(cx).focus(cx); } + fn push_message(&mut self, id: &MessageId, text: String, cx: &mut ViewContext) { + let old_len = self.thread_messages.len(); + self.thread_messages.push(*id); + self.thread_list_state.splice(old_len..old_len, 1); + + let theme_settings = ThemeSettings::get_global(cx); + let ui_font_size = TextSize::Default.rems(cx); + let buffer_font_size = theme_settings.buffer_font_size; + + let mut text_style = cx.text_style(); + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + font_size: Some(ui_font_size.into()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + let markdown_style = MarkdownStyle { + base_text_style: text_style, + syntax: cx.theme().syntax().clone(), + selection_background_color: cx.theme().players().local().selection, + code_block: StyleRefinement { + text: Some(TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }), + ..Default::default() + }, + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(ui_font_size.into()), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }, + ..Default::default() + }; + + let markdown = cx.new_view(|cx| { + Markdown::new( + text, + markdown_style, + Some(self.language_registry.clone()), + None, + cx, + ) + }); + self.rendered_messages_by_id.insert(*id, markdown); + } + fn handle_thread_event( &mut self, _: Model, @@ -141,59 +210,13 @@ impl AssistantPanel { } } ThreadEvent::MessageAdded(message_id) => { - let old_len = self.thread_messages.len(); - self.thread_messages.push(*message_id); - self.thread_list_state.splice(old_len..old_len, 1); - if let Some(message_text) = self .thread .read(cx) .message(*message_id) .map(|message| message.text.clone()) { - let theme_settings = ThemeSettings::get_global(cx); - let ui_font_size = TextSize::Default.rems(cx); - let buffer_font_size = theme_settings.buffer_font_size; - - let mut text_style = cx.text_style(); - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_size: Some(ui_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - let markdown_style = MarkdownStyle { - base_text_style: text_style, - syntax: cx.theme().syntax().clone(), - selection_background_color: cx.theme().players().local().selection, - code_block: StyleRefinement { - text: Some(TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_size: Some(buffer_font_size.into()), - ..Default::default() - }), - ..Default::default() - }, - inline_code: TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_size: Some(ui_font_size.into()), - background_color: Some(cx.theme().colors().editor_background), - ..Default::default() - }, - ..Default::default() - }; - - let markdown = cx.new_view(|cx| { - Markdown::new( - message_text, - markdown_style, - Some(self.language_registry.clone()), - None, - cx, - ) - }); - self.rendered_messages_by_id.insert(*message_id, markdown); + self.push_message(message_id, message_text, cx); } cx.notify(); @@ -401,8 +424,9 @@ impl AssistantPanel { fn render_message_list(&self, cx: &mut ViewContext) -> AnyElement { if self.thread_messages.is_empty() { - #[allow(clippy::useless_vec)] - let recent_threads = vec![1, 2, 3]; + let recent_threads = self + .thread_store + .update(cx, |this, cx| this.recent_threads(3, cx)); return v_flex() .gap_2() @@ -467,8 +491,8 @@ impl AssistantPanel { .child( v_flex().gap_2().children( recent_threads - .iter() - .map(|_thread| self.render_past_thread(cx)), + .into_iter() + .map(|thread| self.render_past_thread(thread, cx)), ), ) .child( @@ -534,10 +558,16 @@ impl AssistantPanel { .into_any() } - fn render_past_thread(&self, _cx: &mut ViewContext) -> impl IntoElement { - ListItem::new("temp") + fn render_past_thread( + &self, + thread: Model, + cx: &mut ViewContext, + ) -> impl IntoElement { + let id = thread.read(cx).id().clone(); + + ListItem::new(("past-thread", thread.entity_id())) .start_slot(Icon::new(IconName::MessageBubbles)) - .child(Label::new("Some Thread Title")) + .child(Label::new(format!("Thread {id}"))) .end_slot( h_flex() .gap_2() @@ -548,6 +578,9 @@ impl AssistantPanel { .icon_size(IconSize::Small), ), ) + .on_click(cx.listener(move |this, _event, cx| { + this.open_thread(&id, cx); + })) } fn render_last_error(&self, cx: &mut ViewContext) -> Option { diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index a841325884..fc5e0d6a15 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -14,12 +14,28 @@ use language_model::{ use language_models::provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}; use serde::{Deserialize, Serialize}; use util::post_inc; +use uuid::Uuid; #[derive(Debug, Clone, Copy)] pub enum RequestKind { Chat, } +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize)] +pub struct ThreadId(Arc); + +impl ThreadId { + pub fn new() -> Self { + Self(Uuid::new_v4().to_string().into()) + } +} + +impl std::fmt::Display for ThreadId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, Serialize, Deserialize)] pub struct MessageId(usize); @@ -39,6 +55,7 @@ pub struct Message { /// A thread of conversation with the LLM. pub struct Thread { + id: ThreadId, messages: Vec, next_message_id: MessageId, completion_count: usize, @@ -52,6 +69,7 @@ pub struct Thread { impl Thread { pub fn new(tools: Arc, _cx: &mut ModelContext) -> Self { Self { + id: ThreadId::new(), messages: Vec::new(), next_message_id: MessageId(0), completion_count: 0, @@ -63,10 +81,18 @@ impl Thread { } } + pub fn id(&self) -> &ThreadId { + &self.id + } + pub fn message(&self, id: MessageId) -> Option<&Message> { self.messages.iter().find(|message| message.id == id) } + pub fn messages(&self) -> impl Iterator { + self.messages.iter() + } + pub fn tools(&self) -> &Arc { &self.tools } @@ -76,10 +102,19 @@ impl Thread { } pub fn insert_user_message(&mut self, text: impl Into, cx: &mut ModelContext) { + self.insert_message(Role::User, text, cx) + } + + pub fn insert_message( + &mut self, + role: Role, + text: impl Into, + cx: &mut ModelContext, + ) { let id = self.next_message_id.post_inc(); self.messages.push(Message { id, - role: Role::User, + role, text: text.into(), }); cx.emit(ThreadEvent::MessageAdded(id)); diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs index 99f90eace8..d784c842c9 100644 --- a/crates/assistant2/src/thread_store.rs +++ b/crates/assistant2/src/thread_store.rs @@ -7,14 +7,18 @@ use context_server::manager::ContextServerManager; use context_server::{ContextServerFactoryRegistry, ContextServerTool}; use gpui::{prelude::*, AppContext, Model, ModelContext, Task}; use project::Project; +use unindent::Unindent; use util::ResultExt as _; +use crate::thread::{Thread, ThreadId}; + pub struct ThreadStore { #[allow(unused)] project: Model, tools: Arc, context_server_manager: Model, context_server_tool_ids: HashMap, Vec>, + threads: Vec>, } impl ThreadStore { @@ -31,12 +35,14 @@ impl ThreadStore { ContextServerManager::new(context_server_factory_registry, project.clone(), cx) }); - let this = Self { + let mut this = Self { project, tools, context_server_manager, context_server_tool_ids: HashMap::default(), + threads: Vec::new(), }; + this.mock_recent_threads(cx); this.register_context_server_handlers(cx); this @@ -46,6 +52,23 @@ impl ThreadStore { }) } + pub fn recent_threads(&self, limit: usize, _cx: &ModelContext) -> Vec> { + self.threads.iter().take(limit).cloned().collect() + } + + pub fn create_thread(&mut self, cx: &mut ModelContext) -> Model { + let thread = cx.new_model(|cx| Thread::new(self.tools.clone(), cx)); + self.threads.push(thread.clone()); + thread + } + + pub fn open_thread(&self, id: &ThreadId, cx: &mut ModelContext) -> Option> { + self.threads + .iter() + .find(|thread| thread.read(cx).id() == id) + .cloned() + } + fn register_context_server_handlers(&self, cx: &mut ModelContext) { cx.subscribe( &self.context_server_manager.clone(), @@ -112,3 +135,91 @@ impl ThreadStore { } } } + +impl ThreadStore { + /// Creates some mocked recent threads for testing purposes. + fn mock_recent_threads(&mut self, cx: &mut ModelContext) { + use language_model::Role; + + self.threads.push(cx.new_model(|cx| { + let mut thread = Thread::new(self.tools.clone(), cx); + thread.insert_user_message("Hello! Can you help me understand quantum computing?", cx); + thread.insert_message(Role::Assistant, "Of course! I'd be happy to help you understand quantum computing. Quantum computing is a fascinating field that uses the principles of quantum mechanics to process information. Unlike classical computers that use bits (0s and 1s), quantum computers use quantum bits or 'qubits'. These qubits can exist in multiple states simultaneously, a property called superposition. This allows quantum computers to perform certain calculations much faster than classical computers. What specific aspect of quantum computing would you like to know more about?", cx); + thread.insert_user_message("That's interesting! Can you explain how quantum entanglement is used in quantum computing?", cx); + thread.insert_message(Role::Assistant, "Certainly! Quantum entanglement is a key principle used in quantum computing. When two qubits become entangled, the state of one qubit is directly related to the state of the other, regardless of the distance between them. This property is used in quantum computing to create complex quantum states and to perform operations on multiple qubits simultaneously. Entanglement allows quantum computers to process information in ways that classical computers cannot, potentially solving certain problems much more efficiently. For example, it's crucial in quantum error correction and in algorithms like quantum teleportation, which is important for quantum communication.", cx); + thread + })); + + self.threads.push(cx.new_model(|cx| { + let mut thread = Thread::new(self.tools.clone(), cx); + thread.insert_user_message("Can you show me an example of Rust code for a simple web server?", cx); + thread.insert_message(Role::Assistant, "Certainly! Here's an example of a simple web server in Rust using the `actix-web` framework: + + ```rust + use actix_web::{web, App, HttpResponse, HttpServer, Responder}; + + async fn hello() -> impl Responder { + HttpResponse::Ok().body(\"Hello, World!\") + } + + #[actix_web::main] + async fn main() -> std::io::Result<()> { + HttpServer::new(|| { + App::new() + .route(\"/\", web::get().to(hello)) + }) + .bind(\"127.0.0.1:8080\")? + .run() + .await + } + ``` + + This code creates a basic web server that responds with 'Hello, World!' when you access the root URL. Here's a breakdown of what's happening: + + 1. We import necessary items from the `actix-web` crate. + 2. We define an async `hello` function that returns a simple HTTP response. + 3. In the `main` function, we set up the server to listen on `127.0.0.1:8080`. + 4. We configure the app to respond to GET requests on the root path with our `hello` function. + + To run this, you'd need to add `actix-web` to your `Cargo.toml` dependencies: + + ```toml + [dependencies] + actix-web = \"4.0\" + ``` + + Then you can run the server with `cargo run` and access it at `http://localhost:8080`.".unindent(), cx); + thread.insert_user_message("That's great! Can you explain more about async functions in Rust?", cx); + thread.insert_message(Role::Assistant, "Certainly! Async functions are a key feature in Rust for writing efficient, non-blocking code, especially for I/O-bound operations. Here's an overview: + + 1. **Syntax**: Async functions are declared using the `async` keyword: + + ```rust + async fn my_async_function() -> Result<(), Error> { + // Asynchronous code here + } + ``` + + 2. **Futures**: Async functions return a `Future`. A `Future` represents a value that may not be available yet but will be at some point. + + 3. **Await**: Inside an async function, you can use the `.await` syntax to wait for other async operations to complete: + + ```rust + async fn fetch_data() -> Result { + let response = make_http_request().await?; + let data = process_response(response).await?; + Ok(data) + } + ``` + + 4. **Non-blocking**: Async functions allow the runtime to work on other tasks while waiting for I/O or other operations to complete, making efficient use of system resources. + + 5. **Runtime**: To execute async code, you need a runtime like `tokio` or `async-std`. Actix-web, which we used in the previous example, includes its own runtime. + + 6. **Error Handling**: Async functions work well with Rust's `?` operator for error handling. + + Async programming in Rust provides a powerful way to write concurrent code that's both safe and efficient. It's particularly useful for servers, network programming, and any application that deals with many concurrent operations.".unindent(), cx); + thread + })); + } +} From f0fac41ca4ec7433addcbbe3f8cae39a633ad95a Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Wed, 4 Dec 2024 14:13:50 -0700 Subject: [PATCH 157/215] Add action `editor::OpenContextMenu` (#21494) This addresses the editor context menu portion of #17819. Release Notes: - Added `editor::OpenContextMenu` action to open context menu at current cursor position. --- assets/keymaps/default-linux.json | 4 +- crates/editor/src/actions.rs | 1 + crates/editor/src/editor.rs | 38 ++++++++++----- crates/editor/src/element.rs | 29 +++++------ crates/editor/src/mouse_context_menu.rs | 65 ++++++++++++------------- 5 files changed, 77 insertions(+), 60 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 2b792f353f..3787f97a8d 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -108,7 +108,9 @@ "ctrl-'": "editor::ToggleHunkDiff", "ctrl-\"": "editor::ExpandAllHunkDiffs", "ctrl-i": "editor::ShowSignatureHelp", - "alt-g b": "editor::ToggleGitBlame" + "alt-g b": "editor::ToggleGitBlame", + "menu": "editor::OpenContextMenu", + "shift-f10": "editor::OpenContextMenu" } }, { diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 9a00f1efca..99e7c6cd0b 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -296,6 +296,7 @@ gpui::actions!( NewlineBelow, NextInlineCompletion, NextScreen, + OpenContextMenu, OpenExcerpts, OpenExcerptsSplit, OpenProposedChangesEditor, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 2464ce8427..883ea570a9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13075,6 +13075,12 @@ impl Editor { cx.write_to_clipboard(ClipboardItem::new_string(lines)); } + pub fn open_context_menu(&mut self, _: &OpenContextMenu, cx: &mut ViewContext) { + self.request_autoscroll(Autoscroll::newest(), cx); + let position = self.selections.newest_display(cx).start; + mouse_context_menu::deploy_context_menu(self, None, position, cx); + } + pub fn inlay_hint_cache(&self) -> &InlayHintCache { &self.inlay_hint_cache } @@ -13296,6 +13302,23 @@ impl Editor { .get(&type_id) .and_then(|item| item.to_any().downcast_ref::()) } + + fn character_size(&self, cx: &mut ViewContext) -> gpui::Point { + let text_layout_details = self.text_layout_details(cx); + let style = &text_layout_details.editor_style; + let font_id = cx.text_system().resolve_font(&style.text.font()); + 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; + + gpui::Point::new(em_width, line_height) + } } fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { @@ -14725,17 +14748,10 @@ impl ViewInputHandler for Editor { cx: &mut ViewContext, ) -> Option> { let text_layout_details = self.text_layout_details(cx); - let style = &text_layout_details.editor_style; - let font_id = cx.text_system().resolve_font(&style.text.font()); - 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 gpui::Point { + x: em_width, + y: line_height, + } = self.character_size(cx); let snapshot = self.snapshot(cx); let scroll_position = snapshot.scroll_position(); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 2bb40c4602..47de2609f7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -169,6 +169,7 @@ impl EditorElement { crate::rust_analyzer_ext::apply_related_actions(view, cx); crate::clangd_ext::apply_related_actions(view, cx); + register_action(view, cx, Editor::open_context_menu); register_action(view, cx, Editor::move_left); register_action(view, cx, Editor::move_right); register_action(view, cx, Editor::move_down); @@ -595,7 +596,7 @@ impl EditorElement { position_map.point_for_position(text_hitbox.bounds, event.position); mouse_context_menu::deploy_context_menu( editor, - event.position, + Some(event.position), point_for_position.previous_valid, cx, ); @@ -2730,6 +2731,7 @@ impl EditorElement { &self, editor_snapshot: &EditorSnapshot, visible_range: Range, + content_origin: gpui::Point, cx: &mut WindowContext, ) -> Option { let position = self.editor.update(cx, |editor, cx| { @@ -2747,16 +2749,11 @@ impl EditorElement { let mouse_context_menu = editor.mouse_context_menu.as_ref()?; let (source_display_point, position) = match mouse_context_menu.position { MenuPosition::PinnedToScreen(point) => (None, point), - MenuPosition::PinnedToEditor { - source, - offset_x, - offset_y, - } => { + MenuPosition::PinnedToEditor { source, offset } => { let source_display_point = source.to_display_point(editor_snapshot); - let mut source_point = editor.to_pixel_point(source, editor_snapshot, cx)?; - source_point.x += offset_x; - source_point.y += offset_y; - (Some(source_display_point), source_point) + let source_point = editor.to_pixel_point(source, editor_snapshot, cx)?; + let position = content_origin + source_point + offset; + (Some(source_display_point), position) } }; @@ -4325,8 +4322,8 @@ fn deploy_blame_entry_context_menu( }); editor.update(cx, move |editor, cx| { - editor.mouse_context_menu = Some(MouseContextMenu::pinned_to_screen( - position, + editor.mouse_context_menu = Some(MouseContextMenu::new( + MenuPosition::PinnedToScreen(position), context_menu, cx, )); @@ -5578,8 +5575,12 @@ impl Element for EditorElement { ); } - let mouse_context_menu = - self.layout_mouse_context_menu(&snapshot, start_row..end_row, cx); + let mouse_context_menu = self.layout_mouse_context_menu( + &snapshot, + start_row..end_row, + content_origin, + cx, + ); cx.with_element_namespace("crease_toggles", |cx| { self.prepaint_crease_toggles( diff --git a/crates/editor/src/mouse_context_menu.rs b/crates/editor/src/mouse_context_menu.rs index 9abf4d990c..6861d424ec 100644 --- a/crates/editor/src/mouse_context_menu.rs +++ b/crates/editor/src/mouse_context_menu.rs @@ -20,8 +20,7 @@ pub enum MenuPosition { /// Disappears when the position is no longer visible. PinnedToEditor { source: multi_buffer::Anchor, - offset_x: Pixels, - offset_y: Pixels, + offset: Point, }, } @@ -48,36 +47,22 @@ impl MouseContextMenu { context_menu: View, cx: &mut ViewContext, ) -> Option { - let context_menu_focus = context_menu.focus_handle(cx); - cx.focus(&context_menu_focus); - - let _subscription = cx.subscribe( - &context_menu, - move |editor, _, _event: &DismissEvent, cx| { - editor.mouse_context_menu.take(); - if context_menu_focus.contains_focused(cx) { - editor.focus(cx); - } - }, - ); - let editor_snapshot = editor.snapshot(cx); - let source_point = editor.to_pixel_point(source, &editor_snapshot, cx)?; - let offset = position - source_point; - - Some(Self { - position: MenuPosition::PinnedToEditor { - source, - offset_x: offset.x, - offset_y: offset.y, - }, - context_menu, - _subscription, - }) + let content_origin = editor.last_bounds?.origin + + Point { + x: editor.gutter_dimensions.width, + y: Pixels(0.0), + }; + let source_position = editor.to_pixel_point(source, &editor_snapshot, cx)?; + let menu_position = MenuPosition::PinnedToEditor { + source, + offset: position - (source_position + content_origin), + }; + return Some(MouseContextMenu::new(menu_position, context_menu, cx)); } - pub(crate) fn pinned_to_screen( - position: Point, + pub(crate) fn new( + position: MenuPosition, context_menu: View, cx: &mut ViewContext, ) -> Self { @@ -95,7 +80,7 @@ impl MouseContextMenu { ); Self { - position: MenuPosition::PinnedToScreen(position), + position, context_menu, _subscription, } @@ -119,7 +104,7 @@ fn display_ranges<'a>( pub fn deploy_context_menu( editor: &mut Editor, - position: Point, + position: Option>, point: DisplayPoint, cx: &mut ViewContext, ) { @@ -213,8 +198,18 @@ pub fn deploy_context_menu( }) }; - editor.mouse_context_menu = - MouseContextMenu::pinned_to_editor(editor, source_anchor, position, context_menu, cx); + editor.mouse_context_menu = match position { + Some(position) => { + MouseContextMenu::pinned_to_editor(editor, source_anchor, position, context_menu, cx) + } + None => { + let menu_position = MenuPosition::PinnedToEditor { + source: source_anchor, + offset: editor.character_size(cx), + }; + Some(MouseContextMenu::new(menu_position, context_menu, cx)) + } + }; cx.notify(); } @@ -248,7 +243,9 @@ mod tests { } "}); 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.update_editor(|editor, cx| { + deploy_context_menu(editor, Some(Default::default()), point, cx) + }); cx.assert_editor_state(indoc! {" fn test() { From 8d18dfa4c1c91ee5c77f3e4be3c8b6116d2992fc Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 4 Dec 2024 23:36:36 +0200 Subject: [PATCH 158/215] Add a prototype with a multi buffer having all project git changes (#21543) Part of https://github.com/zed-industries/zed/issues/20925 This prototype is behind a feature flag and being merged to avoid conflicts with further git-related resturctures. To be a proper, public feature, this needs at least: * showing deleted files * better performance * randomized tests * `TODO`s in the `project_diff.rs` file fixed The good thing is, >90% of the changes are in the `project_diff.rs` file only, have a basic test and already work on simple cases. Release Notes: - N/A --------- Co-authored-by: Thorsten Ball Co-authored-by: Cole Miller --- Cargo.lock | 2 + crates/editor/Cargo.toml | 2 + crates/editor/src/editor.rs | 1 + crates/editor/src/git.rs | 1 + crates/editor/src/git/blame.rs | 2 +- crates/editor/src/git/project_diff.rs | 1235 ++++++++++++++++++++ crates/file_finder/src/file_finder.rs | 2 +- crates/git/src/diff.rs | 1 - crates/language/src/buffer.rs | 1 - crates/project/src/project.rs | 41 +- crates/project_panel/src/project_panel.rs | 2 +- crates/semantic_index/src/project_index.rs | 2 +- crates/workspace/src/workspace.rs | 4 +- 13 files changed, 1269 insertions(+), 27 deletions(-) create mode 100644 crates/editor/src/git/project_diff.rs diff --git a/Cargo.lock b/Cargo.lock index 72bace069b..f3c0fa3176 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3790,6 +3790,7 @@ dependencies = [ "db", "emojis", "env_logger 0.11.5", + "feature_flags", "file_icons", "fs", "futures 0.3.31", @@ -3823,6 +3824,7 @@ dependencies = [ "snippet", "sum_tree", "task", + "tempfile", "text", "theme", "time", diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index f1f1b34981..166e7383fc 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -39,6 +39,7 @@ collections.workspace = true convert_case.workspace = true db.workspace = true emojis.workspace = true +feature_flags.workspace = true file_icons.workspace = true futures.workspace = true fuzzy.workspace = true @@ -97,6 +98,7 @@ project = { workspace = true, features = ["test-support"] } release_channel.workspace = true rand.workspace = true settings = { workspace = true, features = ["test-support"] } +tempfile.workspace = true text = { workspace = true, features = ["test-support"] } theme = { workspace = true, features = ["test-support"] } tree-sitter-html.workspace = true diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 883ea570a9..b11e15b567 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -327,6 +327,7 @@ pub fn init(cx: &mut AppContext) { .detach(); } }); + git::project_diff::init(cx); } pub struct SearchWithinRange; diff --git a/crates/editor/src/git.rs b/crates/editor/src/git.rs index 080babe4c6..97ca80ea29 100644 --- a/crates/editor/src/git.rs +++ b/crates/editor/src/git.rs @@ -1 +1,2 @@ pub mod blame; +pub mod project_diff; diff --git a/crates/editor/src/git/blame.rs b/crates/editor/src/git/blame.rs index c5cfb2e850..b4fe2efec6 100644 --- a/crates/editor/src/git/blame.rs +++ b/crates/editor/src/git/blame.rs @@ -154,7 +154,7 @@ impl GitBlame { this.generate(cx); } } - project::Event::WorktreeUpdatedGitRepositories => { + project::Event::WorktreeUpdatedGitRepositories(_) => { log::debug!("Status of git repositories updated. Regenerating blame data...",); this.generate(cx); } diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs new file mode 100644 index 0000000000..3e28e28a18 --- /dev/null +++ b/crates/editor/src/git/project_diff.rs @@ -0,0 +1,1235 @@ +use std::{ + any::{Any, TypeId}, + cmp::Ordering, + collections::HashSet, + ops::Range, + time::Duration, +}; + +use anyhow::Context as _; +use collections::{BTreeMap, HashMap}; +use feature_flags::FeatureFlagAppExt; +use futures::{stream::FuturesUnordered, StreamExt}; +use git::{diff::DiffHunk, repository::GitFileStatus}; +use gpui::{ + actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView, + InteractiveElement, Model, Render, Subscription, Task, View, WeakView, +}; +use language::{Buffer, BufferRow, BufferSnapshot}; +use multi_buffer::{ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer}; +use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; +use text::{OffsetRangeExt, ToPoint}; +use theme::ActiveTheme; +use ui::{ + div, h_flex, Color, Context, FluentBuilder, Icon, IconName, IntoElement, Label, LabelCommon, + ParentElement, SharedString, Styled, ViewContext, VisualContext, WindowContext, +}; +use util::{paths::compare_paths, ResultExt}; +use workspace::{ + item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams}, + ItemNavHistory, ToolbarItemLocation, Workspace, +}; + +use crate::{Editor, EditorEvent, DEFAULT_MULTIBUFFER_CONTEXT}; + +actions!(project_diff, [Deploy]); + +pub fn init(cx: &mut AppContext) { + cx.observe_new_views(ProjectDiffEditor::register).detach(); +} + +const UPDATE_DEBOUNCE: Duration = Duration::from_millis(50); + +struct ProjectDiffEditor { + buffer_changes: BTreeMap>, + entry_order: HashMap>, + excerpts: Model, + editor: View, + + project: Model, + workspace: WeakView, + focus_handle: FocusHandle, + worktree_rescans: HashMap>, + _subscriptions: Vec, +} + +struct Changes { + _status: GitFileStatus, + buffer: Model, + hunks: Vec, +} + +impl ProjectDiffEditor { + fn register(workspace: &mut Workspace, cx: &mut ViewContext) { + if cx.is_staff() { + workspace.register_action(Self::deploy); + } + } + + fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if let Some(existing) = workspace.item_of_type::(cx) { + workspace.activate_item(&existing, true, true, cx); + } else { + let workspace_handle = cx.view().downgrade(); + let project_diff = + cx.new_view(|cx| Self::new(workspace.project().clone(), workspace_handle, cx)); + workspace.add_item_to_active_pane(Box::new(project_diff), None, true, cx); + } + } + + fn new( + project: Model, + workspace: WeakView, + cx: &mut ViewContext, + ) -> Self { + // TODO diff change subscriptions. For that, needed: + // * `-20/+50` stats retrieval: some background process that reacts on file changes + let focus_handle = cx.focus_handle(); + let changed_entries_subscription = + cx.subscribe(&project, |project_diff_editor, _, e, cx| { + let mut worktree_to_rescan = None; + match e { + project::Event::WorktreeAdded(id) => { + worktree_to_rescan = Some(*id); + // project_diff_editor + // .buffer_changes + // .insert(*id, HashMap::default()); + } + project::Event::WorktreeRemoved(id) => { + project_diff_editor.buffer_changes.remove(id); + } + project::Event::WorktreeUpdatedEntries(id, _updated_entries) => { + // TODO cannot invalidate buffer entries without invalidating the corresponding excerpts and order entries. + worktree_to_rescan = Some(*id); + // let entry_changes = + // project_diff_editor.buffer_changes.entry(*id).or_default(); + // for (_, entry_id, change) in updated_entries.iter() { + // let changes = entry_changes.entry(*entry_id); + // match change { + // project::PathChange::Removed => { + // if let hash_map::Entry::Occupied(entry) = changes { + // entry.remove(); + // } + // } + // // TODO understand the invalidation case better: now, we do that but still rescan the entire worktree + // // What if we already have the buffer loaded inside the diff multi buffer and it was edited there? We should not do anything. + // _ => match changes { + // hash_map::Entry::Occupied(mut o) => o.get_mut().invalidate(), + // hash_map::Entry::Vacant(v) => { + // v.insert(None); + // } + // }, + // } + // } + } + project::Event::WorktreeUpdatedGitRepositories(id) => { + worktree_to_rescan = Some(*id); + // project_diff_editor.buffer_changes.clear(); + } + project::Event::DeletedEntry(id, _entry_id) => { + worktree_to_rescan = Some(*id); + // if let Some(entries) = project_diff_editor.buffer_changes.get_mut(id) { + // entries.remove(entry_id); + // } + } + project::Event::Closed => { + project_diff_editor.buffer_changes.clear(); + } + _ => {} + } + + if let Some(worktree_to_rescan) = worktree_to_rescan { + project_diff_editor.schedule_worktree_rescan(worktree_to_rescan, cx); + } + }); + + let excerpts = cx.new_model(|cx| MultiBuffer::new(project.read(cx).capability())); + + let editor = cx.new_view(|cx| { + let mut diff_display_editor = + Editor::for_multibuffer(excerpts.clone(), Some(project.clone()), true, cx); + diff_display_editor.set_expand_all_diff_hunks(); + diff_display_editor + }); + + let mut new_self = Self { + project, + workspace, + buffer_changes: BTreeMap::default(), + entry_order: HashMap::default(), + worktree_rescans: HashMap::default(), + focus_handle, + editor, + excerpts, + _subscriptions: vec![changed_entries_subscription], + }; + new_self.schedule_rescan_all(cx); + new_self + } + + fn schedule_rescan_all(&mut self, cx: &mut ViewContext) { + let mut current_worktrees = HashSet::::default(); + for worktree in self.project.read(cx).worktrees(cx).collect::>() { + let worktree_id = worktree.read(cx).id(); + current_worktrees.insert(worktree_id); + self.schedule_worktree_rescan(worktree_id, cx); + } + + self.worktree_rescans + .retain(|worktree_id, _| current_worktrees.contains(worktree_id)); + self.buffer_changes + .retain(|worktree_id, _| current_worktrees.contains(worktree_id)); + self.entry_order + .retain(|worktree_id, _| current_worktrees.contains(worktree_id)); + } + + fn schedule_worktree_rescan(&mut self, id: WorktreeId, cx: &mut ViewContext) { + let project = self.project.clone(); + self.worktree_rescans.insert( + id, + cx.spawn(|project_diff_editor, mut cx| async move { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + let open_tasks = project + .update(&mut cx, |project, cx| { + let worktree = project.worktree_for_id(id, cx)?; + let applicable_entries = worktree + .read(cx) + .entries(false, 0) + .filter(|entry| !entry.is_external) + .filter(|entry| entry.is_file()) + .filter_map(|entry| Some((entry.git_status?, entry))) + .filter_map(|(git_status, entry)| { + Some((git_status, entry.id, project.path_for_entry(entry.id, cx)?)) + }) + .collect::>(); + Some( + applicable_entries + .into_iter() + .map(|(status, entry_id, entry_path)| { + let open_task = project.open_path(entry_path.clone(), cx); + (status, entry_id, entry_path, open_task) + }) + .collect::>(), + ) + }) + .ok() + .flatten() + .unwrap_or_default(); + let buffers_with_git_diff = cx + .background_executor() + .spawn(async move { + let mut open_tasks = open_tasks + .into_iter() + .map(|(status, entry_id, entry_path, open_task)| async move { + let (_, opened_model) = open_task.await.with_context(|| { + format!( + "loading buffer {} for git diff", + entry_path.path.display() + ) + })?; + let buffer = match opened_model.downcast::() { + Ok(buffer) => buffer, + Err(_model) => anyhow::bail!( + "Could not load {} as a buffer for git diff", + entry_path.path.display() + ), + }; + anyhow::Ok((status, entry_id, entry_path, buffer)) + }) + .collect::>(); + + let mut buffers_with_git_diff = Vec::new(); + while let Some(opened_buffer) = open_tasks.next().await { + if let Some(opened_buffer) = opened_buffer.log_err() { + buffers_with_git_diff.push(opened_buffer); + } + } + buffers_with_git_diff + }) + .await; + + let Some((buffers, mut new_entries)) = cx + .update(|cx| { + let mut buffers = HashMap::< + ProjectEntryId, + (GitFileStatus, Model, BufferSnapshot), + >::default(); + let mut new_entries = Vec::new(); + for (status, entry_id, entry_path, buffer) in buffers_with_git_diff { + let buffer_snapshot = buffer.read(cx).snapshot(); + buffers.insert(entry_id, (status, buffer, buffer_snapshot)); + new_entries.push((entry_path, entry_id)); + } + (buffers, new_entries) + }) + .ok() + else { + return; + }; + + let (new_changes, new_entry_order) = cx + .background_executor() + .spawn(async move { + let mut new_changes = HashMap::::default(); + for (entry_id, (status, buffer, buffer_snapshot)) in buffers { + new_changes.insert( + entry_id, + Changes { + _status: status, + buffer, + hunks: buffer_snapshot + .git_diff_hunks_in_row_range(0..BufferRow::MAX) + .collect::>(), + }, + ); + } + + new_entries.sort_by(|(project_path_a, _), (project_path_b, _)| { + compare_paths( + (project_path_a.path.as_ref(), true), + (project_path_b.path.as_ref(), true), + ) + }); + (new_changes, new_entries) + }) + .await; + + let mut diff_recalculations = FuturesUnordered::new(); + project_diff_editor + .update(&mut cx, |project_diff_editor, cx| { + project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx); + for buffer in project_diff_editor + .editor + .read(cx) + .buffer() + .read(cx) + .all_buffers() + { + buffer.update(cx, |buffer, cx| { + if let Some(diff_recalculation) = buffer.recalculate_diff(cx) { + diff_recalculations.push(diff_recalculation); + } + }); + } + }) + .ok(); + + cx.background_executor() + .spawn(async move { + while let Some(()) = diff_recalculations.next().await { + // another diff is calculated + } + }) + .await; + }), + ); + } + + fn update_excerpts( + &mut self, + worktree_id: WorktreeId, + new_changes: HashMap, + new_entry_order: Vec<(ProjectPath, ProjectEntryId)>, + cx: &mut ViewContext, + ) { + if let Some(current_order) = self.entry_order.get(&worktree_id) { + let current_entries = self.buffer_changes.entry(worktree_id).or_default(); + let mut new_order_entries = new_entry_order.iter().fuse().peekable(); + let mut excerpts_to_remove = Vec::new(); + let mut new_excerpt_hunks = BTreeMap::< + ExcerptId, + Vec<(ProjectPath, Model, Vec>)>, + >::new(); + let mut excerpt_to_expand = + HashMap::<(u32, ExpandExcerptDirection), Vec>::default(); + let mut latest_excerpt_id = ExcerptId::min(); + + for (current_path, current_entry_id) in current_order { + let current_changes = match current_entries.get(current_entry_id) { + Some(current_changes) => { + if current_changes.hunks.is_empty() { + continue; + } + current_changes + } + None => continue, + }; + let buffer_excerpts = self + .excerpts + .read(cx) + .excerpts_for_buffer(¤t_changes.buffer, cx); + let last_current_excerpt_id = + buffer_excerpts.last().map(|(excerpt_id, _)| *excerpt_id); + let mut current_excerpts = buffer_excerpts.into_iter().fuse().peekable(); + loop { + match new_order_entries.peek() { + Some((new_path, new_entry)) => { + match compare_paths( + (current_path.path.as_ref(), true), + (new_path.path.as_ref(), true), + ) { + Ordering::Less => { + excerpts_to_remove + .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id)); + break; + } + Ordering::Greater => { + if let Some(new_changes) = new_changes.get(new_entry) { + if !new_changes.hunks.is_empty() { + let hunks = new_excerpt_hunks + .entry(latest_excerpt_id) + .or_default(); + match hunks.binary_search_by(|(probe, ..)| { + compare_paths( + (new_path.path.as_ref(), true), + (probe.path.as_ref(), true), + ) + }) { + Ok(i) => hunks[i].2.extend( + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()), + ), + Err(i) => hunks.insert( + i, + ( + new_path.clone(), + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + ), + ), + } + } + }; + let _ = new_order_entries.next(); + } + Ordering::Equal => { + match new_changes.get(new_entry) { + Some(new_changes) => { + let buffer_snapshot = + new_changes.buffer.read(cx).snapshot(); + let mut current_hunks = + current_changes.hunks.iter().fuse().peekable(); + let mut new_hunks_unchanged = + Vec::with_capacity(new_changes.hunks.len()); + let mut new_hunks_with_updates = + Vec::with_capacity(new_changes.hunks.len()); + 'new_changes: for new_hunk in &new_changes.hunks { + loop { + match current_hunks.peek() { + Some(current_hunk) => { + match ( + current_hunk + .buffer_range + .start + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ), + current_hunk.buffer_range.end.cmp( + &new_hunk.buffer_range.end, + &buffer_snapshot, + ), + ) { + ( + Ordering::Equal, + Ordering::Equal, + ) => { + new_hunks_unchanged + .push(new_hunk); + let _ = current_hunks.next(); + continue 'new_changes; + } + (Ordering::Equal, _) + | (_, Ordering::Equal) => { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } + ( + Ordering::Less, + Ordering::Greater, + ) + | ( + Ordering::Greater, + Ordering::Less, + ) => { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } + ( + Ordering::Less, + Ordering::Less, + ) => { + if current_hunk + .buffer_range + .start + .cmp( + &new_hunk + .buffer_range + .end, + &buffer_snapshot, + ) + .is_le() + { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } else { + let _ = + current_hunks.next(); + } + } + ( + Ordering::Greater, + Ordering::Greater, + ) => { + if current_hunk + .buffer_range + .end + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ) + .is_ge() + { + new_hunks_with_updates + .push(new_hunk); + continue 'new_changes; + } else { + let _ = + current_hunks.next(); + } + } + } + } + None => { + new_hunks_with_updates.push(new_hunk); + continue 'new_changes; + } + } + } + } + + let mut excerpts_with_new_changes = + HashSet::::default(); + 'new_hunks: for new_hunk in new_hunks_with_updates { + loop { + match current_excerpts.peek() { + Some(( + current_excerpt_id, + current_excerpt_range, + )) => { + match ( + current_excerpt_range + .context + .start + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ), + current_excerpt_range + .context + .end + .cmp( + &new_hunk.buffer_range.end, + &buffer_snapshot, + ), + ) { + ( + Ordering::Less + | Ordering::Equal, + Ordering::Greater + | Ordering::Equal, + ) => { + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } + ( + Ordering::Greater + | Ordering::Equal, + Ordering::Less + | Ordering::Equal, + ) => { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_hunk + .buffer_range + .start + .to_point(&buffer_snapshot) + .row, + ); + let expand_down = new_hunk + .buffer_range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point( + &buffer_snapshot, + ) + .row, + ); + excerpt_to_expand.entry((expand_up.max(expand_down).max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::UpAndDown)).or_default().push(*current_excerpt_id); + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } + ( + Ordering::Less, + Ordering::Less, + ) => { + if current_excerpt_range + .context + .start + .cmp( + &new_hunk + .buffer_range + .end, + &buffer_snapshot, + ) + .is_le() + { + let expand_up = current_excerpt_range + .context + .start + .to_point(&buffer_snapshot) + .row + .saturating_sub( + new_hunk.buffer_range + .start + .to_point( + &buffer_snapshot, + ) + .row, + ); + excerpt_to_expand.entry((expand_up.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Up)).or_default().push(*current_excerpt_id); + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } else { + if !new_changes + .hunks + .is_empty() + { + let hunks = new_excerpt_hunks + .entry(latest_excerpt_id) + .or_default(); + match hunks.binary_search_by(|(probe, ..)| { + compare_paths( + (new_path.path.as_ref(), true), + (probe.path.as_ref(), true), + ) + }) { + Ok(i) => hunks[i].2.extend( + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()), + ), + Err(i) => hunks.insert( + i, + ( + new_path.clone(), + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + ), + ), + } + } + continue 'new_hunks; + } + } + /* TODO remove or leave? + [ ><<<<<<<--]----<-- + cur_s > cur_e < + > < + new_s>>>>>>>>< + */ + ( + Ordering::Greater, + Ordering::Greater, + ) => { + if current_excerpt_range + .context + .end + .cmp( + &new_hunk + .buffer_range + .start, + &buffer_snapshot, + ) + .is_ge() + { + let expand_down = new_hunk + .buffer_range + .end + .to_point(&buffer_snapshot) + .row + .saturating_sub( + current_excerpt_range + .context + .end + .to_point( + &buffer_snapshot, + ) + .row, + ); + excerpt_to_expand.entry((expand_down.max(DEFAULT_MULTIBUFFER_CONTEXT), ExpandExcerptDirection::Down)).or_default().push(*current_excerpt_id); + excerpts_with_new_changes + .insert( + *current_excerpt_id, + ); + continue 'new_hunks; + } else { + latest_excerpt_id = + *current_excerpt_id; + let _ = + current_excerpts.next(); + } + } + } + } + None => { + let hunks = new_excerpt_hunks + .entry(latest_excerpt_id) + .or_default(); + match hunks.binary_search_by( + |(probe, ..)| { + compare_paths( + ( + new_path.path.as_ref(), + true, + ), + (probe.path.as_ref(), true), + ) + }, + ) { + Ok(i) => hunks[i].2.extend( + new_changes.hunks.iter().map( + |hunk| { + hunk.buffer_range + .clone() + }, + ), + ), + Err(i) => hunks.insert( + i, + ( + new_path.clone(), + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| { + hunk.buffer_range + .clone() + }) + .collect(), + ), + ), + } + continue 'new_hunks; + } + } + } + } + + for (excerpt_id, excerpt_range) in current_excerpts { + if !excerpts_with_new_changes.contains(&excerpt_id) + && !new_hunks_unchanged.iter().any(|hunk| { + excerpt_range + .context + .start + .cmp( + &hunk.buffer_range.end, + &buffer_snapshot, + ) + .is_le() + && excerpt_range + .context + .end + .cmp( + &hunk.buffer_range.start, + &buffer_snapshot, + ) + .is_ge() + }) + { + excerpts_to_remove.push(excerpt_id); + } + latest_excerpt_id = excerpt_id; + } + } + None => excerpts_to_remove.extend( + current_excerpts.map(|(excerpt_id, _)| excerpt_id), + ), + } + let _ = new_order_entries.next(); + break; + } + } + } + None => { + excerpts_to_remove + .extend(current_excerpts.map(|(excerpt_id, _)| excerpt_id)); + break; + } + } + } + latest_excerpt_id = last_current_excerpt_id.unwrap_or(latest_excerpt_id); + } + + for (path, project_entry_id) in new_order_entries { + if let Some(changes) = new_changes.get(project_entry_id) { + if !changes.hunks.is_empty() { + let hunks = new_excerpt_hunks.entry(latest_excerpt_id).or_default(); + match hunks.binary_search_by(|(probe, ..)| { + compare_paths((path.path.as_ref(), true), (probe.path.as_ref(), true)) + }) { + Ok(i) => hunks[i] + .2 + .extend(changes.hunks.iter().map(|hunk| hunk.buffer_range.clone())), + Err(i) => hunks.insert( + i, + ( + path.clone(), + changes.buffer.clone(), + changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + ), + ), + } + } + } + } + + self.excerpts.update(cx, |multi_buffer, cx| { + for (mut after_excerpt_id, excerpts_to_add) in new_excerpt_hunks { + for (_, buffer, hunk_ranges) in excerpts_to_add { + let buffer_snapshot = buffer.read(cx).snapshot(); + let max_point = buffer_snapshot.max_point(); + let new_excerpts = multi_buffer.insert_excerpts_after( + after_excerpt_id, + buffer, + hunk_ranges.into_iter().map(|range| { + let mut extended_point_range = range.to_point(&buffer_snapshot); + extended_point_range.start.row = extended_point_range + .start + .row + .saturating_sub(DEFAULT_MULTIBUFFER_CONTEXT); + extended_point_range.end.row = (extended_point_range.end.row + + DEFAULT_MULTIBUFFER_CONTEXT) + .min(max_point.row); + ExcerptRange { + context: extended_point_range, + primary: None, + } + }), + cx, + ); + after_excerpt_id = new_excerpts.last().copied().unwrap_or(after_excerpt_id); + } + } + multi_buffer.remove_excerpts(excerpts_to_remove, cx); + for ((line_count, direction), excerpts) in excerpt_to_expand { + multi_buffer.expand_excerpts(excerpts, line_count, direction, cx); + } + }); + } else { + self.excerpts.update(cx, |multi_buffer, cx| { + for new_changes in new_entry_order + .iter() + .filter_map(|(_, entry_id)| new_changes.get(entry_id)) + { + multi_buffer.push_excerpts_with_context_lines( + new_changes.buffer.clone(), + new_changes + .hunks + .iter() + .map(|hunk| hunk.buffer_range.clone()) + .collect(), + DEFAULT_MULTIBUFFER_CONTEXT, + cx, + ); + } + }); + }; + + let mut new_changes = new_changes; + let mut new_entry_order = new_entry_order; + std::mem::swap( + self.buffer_changes.entry(worktree_id).or_default(), + &mut new_changes, + ); + std::mem::swap( + self.entry_order.entry(worktree_id).or_default(), + &mut new_entry_order, + ); + } +} + +impl EventEmitter for ProjectDiffEditor {} + +impl FocusableView for ProjectDiffEditor { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Item for ProjectDiffEditor { + type Event = EditorEvent; + + fn to_item_events(event: &EditorEvent, f: impl FnMut(ItemEvent)) { + Editor::to_item_events(event, f) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + self.editor.update(cx, |editor, cx| editor.deactivated(cx)); + } + + fn navigate(&mut self, data: Box, cx: &mut ViewContext) -> bool { + self.editor + .update(cx, |editor, cx| editor.navigate(data, cx)) + } + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some("Project Diff".into()) + } + + fn tab_content(&self, params: TabContentParams, _: &WindowContext) -> AnyElement { + if self.buffer_changes.is_empty() { + Label::new("No changes") + .color(if params.selected { + Color::Default + } else { + Color::Muted + }) + .into_any_element() + } else { + h_flex() + .gap_1() + .when(true, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::XCircle).color(Color::Error)) + .child(Label::new(self.buffer_changes.len().to_string()).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + )), + ) + }) + .when(true, |then| { + then.child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Indicator).color(Color::Warning)) + .child(Label::new(self.buffer_changes.len().to_string()).color( + if params.selected { + Color::Default + } else { + Color::Muted + }, + )), + ) + }) + .into_any_element() + } + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + Some("project diagnostics") + } + + fn for_each_project_item( + &self, + cx: &AppContext, + f: &mut dyn FnMut(gpui::EntityId, &dyn project::ProjectItem), + ) { + self.editor.for_each_project_item(cx, f) + } + + fn is_singleton(&self, _: &AppContext) -> bool { + false + } + + fn set_nav_history(&mut self, nav_history: ItemNavHistory, cx: &mut ViewContext) { + self.editor.update(cx, |editor, _| { + editor.set_nav_history(Some(nav_history)); + }); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> + where + Self: Sized, + { + Some(cx.new_view(|cx| { + ProjectDiffEditor::new(self.project.clone(), self.workspace.clone(), cx) + })) + } + + fn is_dirty(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).is_dirty(cx) + } + + fn has_conflict(&self, cx: &AppContext) -> bool { + self.excerpts.read(cx).has_conflict(cx) + } + + fn can_save(&self, _: &AppContext) -> bool { + true + } + + fn save( + &mut self, + format: bool, + project: Model, + cx: &mut ViewContext, + ) -> Task> { + self.editor.save(format, project, cx) + } + + fn save_as( + &mut self, + _: Model, + _: ProjectPath, + _: &mut ViewContext, + ) -> Task> { + unreachable!() + } + + fn reload( + &mut self, + project: Model, + cx: &mut ViewContext, + ) -> Task> { + self.editor.reload(project, cx) + } + + fn act_as_type<'a>( + &'a self, + type_id: TypeId, + self_handle: &'a View, + _: &'a AppContext, + ) -> Option { + if type_id == TypeId::of::() { + Some(self_handle.to_any()) + } else if type_id == TypeId::of::() { + Some(self.editor.to_any()) + } else { + None + } + } + + fn breadcrumb_location(&self, _: &AppContext) -> ToolbarItemLocation { + ToolbarItemLocation::PrimaryLeft + } + + fn breadcrumbs(&self, theme: &theme::Theme, cx: &AppContext) -> Option> { + self.editor.breadcrumbs(theme, cx) + } + + fn added_to_workspace(&mut self, workspace: &mut Workspace, cx: &mut ViewContext) { + self.editor + .update(cx, |editor, cx| editor.added_to_workspace(workspace, cx)); + } +} + +impl Render for ProjectDiffEditor { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let child = if self.buffer_changes.is_empty() { + div() + .bg(cx.theme().colors().editor_background) + .flex() + .items_center() + .justify_center() + .size_full() + .child(Label::new("No changes in the workspace")) + } else { + div().size_full().child(self.editor.clone()) + }; + + div() + .track_focus(&self.focus_handle) + .size_full() + .child(child) + } +} + +#[cfg(test)] +mod tests { + use std::{ops::Deref as _, path::Path, sync::Arc}; + + use fs::RealFs; + use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; + use settings::SettingsStore; + + use super::*; + + // TODO finish + // #[gpui::test] + // async fn randomized_tests(cx: &mut TestAppContext) { + // // Create a new project (how?? temp fs?), + // let fs = FakeFs::new(cx.executor()); + // let project = Project::test(fs, [], cx).await; + + // // create random files with random content + + // // Commit it into git somehow (technically can do with "real" fs in a temp dir) + // // + // // Apply randomized changes to the project: select a random file, random change and apply to buffers + // } + + #[gpui::test] + async fn simple_edit_test(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); + + let dir = tempfile::tempdir().unwrap(); + let dst = dir.path(); + + std::fs::write(dst.join("file_a"), "This is file_a").unwrap(); + std::fs::write(dst.join("file_b"), "This is file_b").unwrap(); + + run_git(dst, &["init"]); + run_git(dst, &["add", "*"]); + run_git(dst, &["commit", "-m", "Initial commit"]); + + let project = Project::test(Arc::new(RealFs::default()), [dst], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + + let file_a_editor = workspace + .update(cx, |workspace, cx| { + let file_a_editor = workspace.open_abs_path(dst.join("file_a"), true, cx); + ProjectDiffEditor::deploy(workspace, &Deploy, cx); + file_a_editor + }) + .unwrap() + .await + .expect("did not open an item at all") + .downcast::() + .expect("did not open an editor for file_a"); + + let project_diff_editor = workspace + .update(cx, |workspace, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + }) + .unwrap() + .expect("did not find a ProjectDiffEditor"); + project_diff_editor.update(cx, |project_diff_editor, cx| { + assert!( + project_diff_editor.editor.read(cx).text(cx).is_empty(), + "Should have no changes after opening the diff on no git changes" + ); + }); + + let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx)); + let change = "an edit after git add"; + file_a_editor + .update(cx, |file_a_editor, cx| { + file_a_editor.insert(change, cx); + file_a_editor.save(false, project.clone(), cx) + }) + .await + .expect("failed to save a file"); + cx.executor().advance_clock(Duration::from_secs(1)); + cx.run_until_parked(); + + // TODO does not work on Linux for some reason, returning a blank line + // hence disable the last check for now, and do some fiddling to avoid the warnings. + #[cfg(target_os = "linux")] + { + if true { + return; + } + } + project_diff_editor.update(cx, |project_diff_editor, cx| { + // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added) + assert_eq!( + project_diff_editor.editor.read(cx).text(cx), + format!("{change}{old_text}"), + "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards" + ); + }); + } + + fn run_git(path: &Path, args: &[&str]) -> String { + let output = std::process::Command::new("git") + .args(args) + .current_dir(path) + .output() + .expect("git commit failed"); + + format!( + "Stdout: {}; stderr: {}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap() + ) + } + + fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } + + cx.update(|cx| { + assets::Assets.load_test_fonts(cx); + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + crate::init(cx); + }); + } +} diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 62e0818b74..10cde076e1 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -648,7 +648,7 @@ impl FileFinderDelegate { cx.subscribe(project, |file_finder, _, event, cx| { match event { project::Event::WorktreeUpdatedEntries(_, _) - | project::Event::WorktreeAdded + | project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => file_finder .picker .update(cx, |picker, cx| picker.refresh(cx)), diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs index baad824577..23e9388a28 100644 --- a/crates/git/src/diff.rs +++ b/crates/git/src/diff.rs @@ -80,7 +80,6 @@ impl BufferDiff { self.tree.is_empty() } - #[cfg(any(test, feature = "test-support"))] pub fn hunks_in_row_range<'a>( &'a self, range: Range, diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index c9f5d54299..e39d4523d7 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -3998,7 +3998,6 @@ impl BufferSnapshot { } /// Returns all the Git diff hunks intersecting the given row range. - #[cfg(any(test, feature = "test-support"))] pub fn git_diff_hunks_in_row_range( &self, range: Range, diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 30732fc8b2..74bd065c32 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -240,11 +240,11 @@ pub enum Event { LanguageNotFound(Model), ActiveEntryChanged(Option), ActivateProjectPanel, - WorktreeAdded, + WorktreeAdded(WorktreeId), WorktreeOrderChanged, WorktreeRemoved(WorktreeId), WorktreeUpdatedEntries(WorktreeId, UpdatedEntriesSet), - WorktreeUpdatedGitRepositories, + WorktreeUpdatedGitRepositories(WorktreeId), DiskBasedDiagnosticsStarted { language_server_id: LanguageServerId, }, @@ -259,7 +259,7 @@ pub enum Event { DisconnectedFromHost, DisconnectedFromSshRemote, Closed, - DeletedEntry(ProjectEntryId), + DeletedEntry(WorktreeId, ProjectEntryId), CollaboratorUpdated { old_peer_id: proto::PeerId, new_peer_id: proto::PeerId, @@ -1504,6 +1504,7 @@ impl Project { cx: &mut ModelContext, ) -> Option>> { let worktree = self.worktree_for_entry(entry_id, cx)?; + cx.emit(Event::DeletedEntry(worktree.read(cx).id(), entry_id)); worktree.update(cx, |worktree, cx| { worktree.delete_entry(entry_id, trash, cx) }) @@ -2204,7 +2205,7 @@ impl Project { match event { WorktreeStoreEvent::WorktreeAdded(worktree) => { self.on_worktree_added(worktree, cx); - cx.emit(Event::WorktreeAdded); + cx.emit(Event::WorktreeAdded(worktree.read(cx).id())); } WorktreeStoreEvent::WorktreeRemoved(_, id) => { cx.emit(Event::WorktreeRemoved(*id)); @@ -2225,23 +2226,25 @@ impl Project { } } cx.observe(worktree, |_, _, cx| cx.notify()).detach(); - cx.subscribe(worktree, |project, worktree, event, cx| match event { - worktree::Event::UpdatedEntries(changes) => { - cx.emit(Event::WorktreeUpdatedEntries( - worktree.read(cx).id(), - changes.clone(), - )); + cx.subscribe(worktree, |project, worktree, event, cx| { + let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); + match event { + worktree::Event::UpdatedEntries(changes) => { + cx.emit(Event::WorktreeUpdatedEntries( + worktree.read(cx).id(), + changes.clone(), + )); - let worktree_id = worktree.update(cx, |worktree, _| worktree.id()); - project - .client() - .telemetry() - .report_discovered_project_events(worktree_id, changes); + project + .client() + .telemetry() + .report_discovered_project_events(worktree_id, changes); + } + worktree::Event::UpdatedGitRepositories(_) => { + cx.emit(Event::WorktreeUpdatedGitRepositories(worktree_id)); + } + worktree::Event::DeletedEntry(id) => cx.emit(Event::DeletedEntry(worktree_id, *id)), } - worktree::Event::UpdatedGitRepositories(_) => { - cx.emit(Event::WorktreeUpdatedGitRepositories); - } - worktree::Event::DeletedEntry(id) => cx.emit(Event::DeletedEntry(*id)), }) .detach(); cx.notify(); diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index df78ff1118..3ef9f1905d 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -304,7 +304,7 @@ impl ProjectPanel { cx.notify(); } project::Event::WorktreeUpdatedEntries(_, _) - | project::Event::WorktreeAdded + | project::Event::WorktreeAdded(_) | project::Event::WorktreeOrderChanged => { this.update_visible_entries(None, cx); cx.notify(); diff --git a/crates/semantic_index/src/project_index.rs b/crates/semantic_index/src/project_index.rs index 21c036d60a..bc18eccc18 100644 --- a/crates/semantic_index/src/project_index.rs +++ b/crates/semantic_index/src/project_index.rs @@ -125,7 +125,7 @@ impl ProjectIndex { cx: &mut ModelContext, ) { match event { - project::Event::WorktreeAdded | project::Event::WorktreeRemoved(_) => { + project::Event::WorktreeAdded(_) | project::Event::WorktreeRemoved(_) => { self.update_worktree_indices(cx); } _ => {} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index c5de8822dc..0d47cec441 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -810,7 +810,7 @@ impl Workspace { this.collaborator_left(*peer_id, cx); } - project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded => { + project::Event::WorktreeRemoved(_) | project::Event::WorktreeAdded(_) => { this.update_window_title(cx); this.serialize_workspace(cx); } @@ -832,7 +832,7 @@ impl Workspace { cx.remove_window(); } - project::Event::DeletedEntry(entry_id) => { + project::Event::DeletedEntry(_, entry_id) => { for pane in this.panes.iter() { pane.update(cx, |pane, cx| { pane.handle_deleted_project_item(*entry_id, cx) From 55ecb3c51b17e7c335e0f47d60cb262a7c1dd54e Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Wed, 4 Dec 2024 23:37:24 +0200 Subject: [PATCH 159/215] Regenerate completion labels on resolve (#21521) Closes https://github.com/zed-industries/zed/issues/21516 Technically, this is an LSP violation from `vtsls`, but seems that it's not going to be fixed adequately on that side, see https://github.com/yioneko/vtsls/issues/213 for more context. So, we have to accommodate at least for now. Release Notes: - Fixed completion item labels not being updated after the resolve for non-LSP compliant servers --- crates/editor/src/editor.rs | 4 +- crates/editor/src/editor_tests.rs | 91 +++++++++++++++++++++++++++++++ crates/project/src/lsp_store.rs | 35 ++++++++++-- 3 files changed, 124 insertions(+), 6 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index b11e15b567..8af10cd0c9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1181,9 +1181,9 @@ impl CompletionsMenu { let delay = Duration::from_millis(delay_ms); completion_resolve.lock().fire_new(delay, cx, |_, cx| { - cx.spawn(move |this, mut cx| async move { + cx.spawn(move |editor, mut cx| async move { if let Some(true) = resolve_task.await.log_err() { - this.update(&mut cx, |_, cx| cx.notify()).ok(); + editor.update(&mut cx, |_, cx| cx.notify()).ok(); } }) }); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 0c15719ab5..136003dcc3 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -10625,6 +10625,97 @@ async fn test_completions_with_additional_edits(cx: &mut gpui::TestAppContext) { cx.assert_editor_state(indoc! {"fn main() { let a = Some(2)ˇ; }"}); } +#[gpui::test] +async fn test_completions_resolve_updates_labels(cx: &mut gpui::TestAppContext) { + init_test(cx, |_| {}); + + let mut cx = EditorLspTestContext::new_rust( + lsp::ServerCapabilities { + completion_provider: Some(lsp::CompletionOptions { + trigger_characters: Some(vec![".".to_string()]), + resolve_provider: Some(true), + ..Default::default() + }), + ..Default::default() + }, + cx, + ) + .await; + + cx.set_state(indoc! {"fn main() { let a = 2ˇ; }"}); + cx.simulate_keystroke("."); + + let completion_item = lsp::CompletionItem { + label: "unresolved".to_string(), + detail: None, + documentation: None, + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".unresolved".to_string(), + })), + ..lsp::CompletionItem::default() + }; + + cx.handle_request::(move |_, _, _| { + let item = completion_item.clone(); + async move { Ok(Some(lsp::CompletionResponse::Array(vec![item]))) } + }) + .next() + .await; + + cx.condition(|editor, _| editor.context_menu_visible()) + .await; + cx.update_editor(|editor, _| { + let context_menu = editor.context_menu.read(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + ContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.read(); + assert_eq!(completions.len(), 1, "Should have one completion"); + assert_eq!(completions.get(0).unwrap().label.text, "unresolved"); + } + ContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); + + cx.handle_request::(move |_, _, _| async move { + Ok(lsp::CompletionItem { + label: "resolved".to_string(), + detail: Some("Now resolved!".to_string()), + documentation: Some(lsp::Documentation::String("Docs".to_string())), + text_edit: Some(lsp::CompletionTextEdit::Edit(lsp::TextEdit { + range: lsp::Range::new(lsp::Position::new(0, 22), lsp::Position::new(0, 22)), + new_text: ".resolved".to_string(), + })), + ..lsp::CompletionItem::default() + }) + }) + .next() + .await; + cx.run_until_parked(); + + cx.update_editor(|editor, _| { + let context_menu = editor.context_menu.read(); + let context_menu = context_menu + .as_ref() + .expect("Should have the context menu deployed"); + match context_menu { + ContextMenu::Completions(completions_menu) => { + let completions = completions_menu.completions.read(); + assert_eq!(completions.len(), 1, "Should have one completion"); + assert_eq!( + completions.get(0).unwrap().label.text, + "resolved", + "Should update the completion label after resolving" + ); + } + ContextMenu::CodeActions(_) => panic!("Should show the completions menu"), + } + }); +} + #[gpui::test] async fn test_completions_default_resolve_data_handling(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 41a3ccc0a3..ff2a3d47e7 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -2241,17 +2241,23 @@ impl LspStore { (server_id, completion) }; - let server = this - .read_with(&cx, |this, _| this.language_server_for_id(server_id)) + let server_and_adapter = this + .read_with(&cx, |lsp_store, _| { + let server = lsp_store.language_server_for_id(server_id)?; + let adapter = + lsp_store.language_server_adapter_for_id(server.server_id())?; + Some((server, adapter)) + }) .ok() .flatten(); - let Some(server) = server else { + let Some((server, adapter)) = server_and_adapter else { continue; }; did_resolve = true; Self::resolve_completion_local( server, + adapter, &buffer_snapshot, completions.clone(), completion_index, @@ -2268,6 +2274,7 @@ impl LspStore { async fn resolve_completion_local( server: Arc, + adapter: Arc, snapshot: &BufferSnapshot, completions: Arc>>, completion_index: usize, @@ -2293,7 +2300,7 @@ impl LspStore { let documentation = language::prepare_completion_documentation( lsp_documentation, &language_registry, - None, // TODO: Try to reasonably work out which language the completion is for + snapshot.language().cloned(), ) .await; @@ -2332,9 +2339,29 @@ impl LspStore { } } + // NB: Zed does not have `details` inside the completion resolve capabilities, but certain language servers violate the spec and do not return `details` immediately, e.g. https://github.com/yioneko/vtsls/issues/213 + // So we have to update the label here anyway... + let new_label = match snapshot.language() { + Some(language) => adapter + .labels_for_completions(&[completion_item.clone()], language) + .await + .log_err() + .unwrap_or_default(), + None => Vec::new(), + } + .pop() + .flatten() + .unwrap_or_else(|| { + CodeLabel::plain( + completion_item.label.clone(), + completion_item.filter_text.as_deref(), + ) + }); + let mut completions = completions.write(); let completion = &mut completions[completion_index]; completion.lsp_completion = completion_item; + completion.label = new_label; } #[allow(clippy::too_many_arguments)] From a30ea2fc682bdaec572f3e130631e297670612a6 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 4 Dec 2024 16:39:39 -0500 Subject: [PATCH 160/215] assistant2: Factor out `ActiveThread` view (#21555) This PR factors a new `ActiveThread` view out of the `AssistantPanel` to group together the state that pertains solely to the active view. There was a bunch of related state on the `AssistantPanel` pertaining to the active thread that needed to be initialized/reset together and it makes for a clearer narrative is this state is encapsulated in its own view. Release Notes: - N/A --- crates/assistant2/src/active_thread.rs | 237 ++++++++++++ crates/assistant2/src/assistant.rs | 1 + crates/assistant2/src/assistant_panel.rs | 436 ++++++++--------------- crates/assistant2/src/thread.rs | 4 + crates/assistant2/src/thread_store.rs | 9 +- 5 files changed, 396 insertions(+), 291 deletions(-) create mode 100644 crates/assistant2/src/active_thread.rs diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs new file mode 100644 index 0000000000..13b67dc437 --- /dev/null +++ b/crates/assistant2/src/active_thread.rs @@ -0,0 +1,237 @@ +use std::sync::Arc; + +use assistant_tool::ToolWorkingSet; +use collections::HashMap; +use gpui::{ + list, AnyElement, Empty, ListAlignment, ListState, Model, StyleRefinement, Subscription, + TextStyleRefinement, View, WeakView, +}; +use language::LanguageRegistry; +use language_model::Role; +use markdown::{Markdown, MarkdownStyle}; +use settings::Settings as _; +use theme::ThemeSettings; +use ui::prelude::*; +use workspace::Workspace; + +use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent}; + +pub struct ActiveThread { + workspace: WeakView, + language_registry: Arc, + tools: Arc, + thread: Model, + messages: Vec, + list_state: ListState, + rendered_messages_by_id: HashMap>, + last_error: Option, + _subscriptions: Vec, +} + +impl ActiveThread { + pub fn new( + thread: Model, + workspace: WeakView, + language_registry: Arc, + tools: Arc, + cx: &mut ViewContext, + ) -> Self { + let subscriptions = vec![ + cx.observe(&thread, |_, _, cx| cx.notify()), + cx.subscribe(&thread, Self::handle_thread_event), + ]; + + let mut this = Self { + workspace, + language_registry, + tools, + thread: thread.clone(), + messages: Vec::new(), + rendered_messages_by_id: HashMap::default(), + list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), { + let this = cx.view().downgrade(); + move |ix, cx: &mut WindowContext| { + this.update(cx, |this, cx| this.render_message(ix, cx)) + .unwrap() + } + }), + last_error: None, + _subscriptions: subscriptions, + }; + + for message in thread.read(cx).messages().cloned().collect::>() { + this.push_message(&message.id, message.text.clone(), cx); + } + + this + } + + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } + + pub fn last_error(&self) -> Option { + self.last_error.clone() + } + + pub fn clear_last_error(&mut self) { + self.last_error.take(); + } + + fn push_message(&mut self, id: &MessageId, text: String, cx: &mut ViewContext) { + let old_len = self.messages.len(); + self.messages.push(*id); + self.list_state.splice(old_len..old_len, 1); + + let theme_settings = ThemeSettings::get_global(cx); + let ui_font_size = TextSize::Default.rems(cx); + let buffer_font_size = theme_settings.buffer_font_size; + + let mut text_style = cx.text_style(); + text_style.refine(&TextStyleRefinement { + font_family: Some(theme_settings.ui_font.family.clone()), + font_size: Some(ui_font_size.into()), + color: Some(cx.theme().colors().text), + ..Default::default() + }); + + let markdown_style = MarkdownStyle { + base_text_style: text_style, + syntax: cx.theme().syntax().clone(), + selection_background_color: cx.theme().players().local().selection, + code_block: StyleRefinement { + text: Some(TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(buffer_font_size.into()), + ..Default::default() + }), + ..Default::default() + }, + inline_code: TextStyleRefinement { + font_family: Some(theme_settings.buffer_font.family.clone()), + font_size: Some(ui_font_size.into()), + background_color: Some(cx.theme().colors().editor_background), + ..Default::default() + }, + ..Default::default() + }; + + let markdown = cx.new_view(|cx| { + Markdown::new( + text, + markdown_style, + Some(self.language_registry.clone()), + None, + cx, + ) + }); + self.rendered_messages_by_id.insert(*id, markdown); + } + + fn handle_thread_event( + &mut self, + _: Model, + event: &ThreadEvent, + cx: &mut ViewContext, + ) { + match event { + ThreadEvent::ShowError(error) => { + self.last_error = Some(error.clone()); + } + ThreadEvent::StreamedCompletion => {} + ThreadEvent::StreamedAssistantText(message_id, text) => { + if let Some(markdown) = self.rendered_messages_by_id.get_mut(&message_id) { + markdown.update(cx, |markdown, cx| { + markdown.append(text, cx); + }); + } + } + ThreadEvent::MessageAdded(message_id) => { + if let Some(message_text) = self + .thread + .read(cx) + .message(*message_id) + .map(|message| message.text.clone()) + { + self.push_message(message_id, message_text, cx); + } + + cx.notify(); + } + ThreadEvent::UsePendingTools => { + let pending_tool_uses = self + .thread + .read(cx) + .pending_tool_uses() + .into_iter() + .filter(|tool_use| tool_use.status.is_idle()) + .cloned() + .collect::>(); + + for tool_use in pending_tool_uses { + if let Some(tool) = self.tools.tool(&tool_use.name, cx) { + let task = tool.run(tool_use.input, self.workspace.clone(), cx); + + self.thread.update(cx, |thread, cx| { + thread.insert_tool_output( + tool_use.assistant_message_id, + tool_use.id.clone(), + task, + cx, + ); + }); + } + } + } + ThreadEvent::ToolFinished { .. } => {} + } + } + + fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { + let message_id = self.messages[ix]; + let Some(message) = self.thread.read(cx).message(message_id) else { + return Empty.into_any(); + }; + + let Some(markdown) = self.rendered_messages_by_id.get(&message_id) else { + return Empty.into_any(); + }; + + let (role_icon, role_name) = match message.role { + Role::User => (IconName::Person, "You"), + Role::Assistant => (IconName::ZedAssistant, "Assistant"), + Role::System => (IconName::Settings, "System"), + }; + + div() + .id(("message-container", ix)) + .p_2() + .child( + v_flex() + .border_1() + .border_color(cx.theme().colors().border_variant) + .rounded_md() + .child( + h_flex() + .justify_between() + .p_1p5() + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child( + h_flex() + .gap_2() + .child(Icon::new(role_icon).size(IconSize::Small)) + .child(Label::new(role_name).size(LabelSize::Small)), + ), + ) + .child(v_flex().p_1p5().text_ui(cx).child(markdown.clone())), + ) + .into_any() + } +} + +impl Render for ActiveThread { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + list(self.list_state.clone()).flex_1() + } +} diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index aa79ce0c67..dfa361ad8c 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,3 +1,4 @@ +mod active_thread; mod assistant_panel; mod message_editor; mod thread; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 00bd15de2e..d17480cd0e 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -3,25 +3,21 @@ use std::sync::Arc; use anyhow::Result; use assistant_tool::ToolWorkingSet; use client::zed_urls; -use collections::HashMap; use gpui::{ - list, prelude::*, px, svg, Action, AnyElement, AppContext, AsyncWindowContext, Empty, - EventEmitter, FocusHandle, FocusableView, FontWeight, ListAlignment, ListState, Model, Pixels, - StyleRefinement, Subscription, Task, TextStyleRefinement, View, ViewContext, WeakView, + prelude::*, px, svg, Action, AnyElement, AppContext, AsyncWindowContext, EventEmitter, + FocusHandle, FocusableView, FontWeight, Model, Pixels, Task, View, ViewContext, WeakView, WindowContext, }; use language::LanguageRegistry; -use language_model::{LanguageModelRegistry, Role}; +use language_model::LanguageModelRegistry; use language_model_selector::LanguageModelSelector; -use markdown::{Markdown, MarkdownStyle}; -use settings::Settings; -use theme::ThemeSettings; use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, KeyBinding, ListItem, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; +use crate::active_thread::ActiveThread; use crate::message_editor::MessageEditor; -use crate::thread::{MessageId, Thread, ThreadError, ThreadEvent, ThreadId}; +use crate::thread::{Thread, ThreadError, ThreadId}; use crate::thread_store::ThreadStore; use crate::{NewThread, OpenHistory, ToggleFocus, ToggleModelSelector}; @@ -39,16 +35,10 @@ pub fn init(cx: &mut AppContext) { pub struct AssistantPanel { workspace: WeakView, language_registry: Arc, - #[allow(unused)] thread_store: Model, - thread: Model, - thread_messages: Vec, - rendered_messages_by_id: HashMap>, - thread_list_state: ListState, + thread: Option>, message_editor: View, tools: Arc, - last_error: Option, - _subscriptions: Vec, } impl AssistantPanel { @@ -78,29 +68,14 @@ impl AssistantPanel { cx: &mut ViewContext, ) -> Self { let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); - let subscriptions = vec![ - cx.observe(&thread, |_, _, cx| cx.notify()), - cx.subscribe(&thread, Self::handle_thread_event), - ]; Self { workspace: workspace.weak_handle(), language_registry: workspace.project().read(cx).languages().clone(), thread_store, - thread: thread.clone(), - thread_messages: Vec::new(), - rendered_messages_by_id: HashMap::default(), - thread_list_state: ListState::new(0, ListAlignment::Bottom, px(1024.), { - let this = cx.view().downgrade(); - move |ix, cx: &mut WindowContext| { - this.update(cx, |this, cx| this.render_message(ix, cx)) - .unwrap() - } - }), + thread: None, message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), tools, - last_error: None, - _subscriptions: subscriptions, } } @@ -108,7 +83,18 @@ impl AssistantPanel { let thread = self .thread_store .update(cx, |this, cx| this.create_thread(cx)); - self.reset_thread(thread, cx); + + self.thread = Some(cx.new_view(|cx| { + ActiveThread::new( + thread.clone(), + self.workspace.clone(), + self.language_registry.clone(), + self.tools.clone(), + cx, + ) + })); + self.message_editor = cx.new_view(|cx| MessageEditor::new(thread, cx)); + self.message_editor.focus_handle(cx).focus(cx); } fn open_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { @@ -118,136 +104,18 @@ impl AssistantPanel { else { return; }; - self.reset_thread(thread.clone(), cx); - for message in thread.read(cx).messages().cloned().collect::>() { - self.push_message(&message.id, message.text.clone(), cx); - } - } - - fn reset_thread(&mut self, thread: Model, cx: &mut ViewContext) { - let subscriptions = vec![ - cx.observe(&thread, |_, _, cx| cx.notify()), - cx.subscribe(&thread, Self::handle_thread_event), - ]; - - self.message_editor = cx.new_view(|cx| MessageEditor::new(thread.clone(), cx)); - self.thread = thread; - self.thread_messages.clear(); - self.thread_list_state.reset(0); - self.rendered_messages_by_id.clear(); - self._subscriptions = subscriptions; - - self.message_editor.focus_handle(cx).focus(cx); - } - - fn push_message(&mut self, id: &MessageId, text: String, cx: &mut ViewContext) { - let old_len = self.thread_messages.len(); - self.thread_messages.push(*id); - self.thread_list_state.splice(old_len..old_len, 1); - - let theme_settings = ThemeSettings::get_global(cx); - let ui_font_size = TextSize::Default.rems(cx); - let buffer_font_size = theme_settings.buffer_font_size; - - let mut text_style = cx.text_style(); - text_style.refine(&TextStyleRefinement { - font_family: Some(theme_settings.ui_font.family.clone()), - font_size: Some(ui_font_size.into()), - color: Some(cx.theme().colors().text), - ..Default::default() - }); - - let markdown_style = MarkdownStyle { - base_text_style: text_style, - syntax: cx.theme().syntax().clone(), - selection_background_color: cx.theme().players().local().selection, - code_block: StyleRefinement { - text: Some(TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_size: Some(buffer_font_size.into()), - ..Default::default() - }), - ..Default::default() - }, - inline_code: TextStyleRefinement { - font_family: Some(theme_settings.buffer_font.family.clone()), - font_size: Some(ui_font_size.into()), - background_color: Some(cx.theme().colors().editor_background), - ..Default::default() - }, - ..Default::default() - }; - - let markdown = cx.new_view(|cx| { - Markdown::new( - text, - markdown_style, - Some(self.language_registry.clone()), - None, + self.thread = Some(cx.new_view(|cx| { + ActiveThread::new( + thread.clone(), + self.workspace.clone(), + self.language_registry.clone(), + self.tools.clone(), cx, ) - }); - self.rendered_messages_by_id.insert(*id, markdown); - } - - fn handle_thread_event( - &mut self, - _: Model, - event: &ThreadEvent, - cx: &mut ViewContext, - ) { - match event { - ThreadEvent::ShowError(error) => { - self.last_error = Some(error.clone()); - } - ThreadEvent::StreamedCompletion => {} - ThreadEvent::StreamedAssistantText(message_id, text) => { - if let Some(markdown) = self.rendered_messages_by_id.get_mut(&message_id) { - markdown.update(cx, |markdown, cx| { - markdown.append(text, cx); - }); - } - } - ThreadEvent::MessageAdded(message_id) => { - if let Some(message_text) = self - .thread - .read(cx) - .message(*message_id) - .map(|message| message.text.clone()) - { - self.push_message(message_id, message_text, cx); - } - - cx.notify(); - } - ThreadEvent::UsePendingTools => { - let pending_tool_uses = self - .thread - .read(cx) - .pending_tool_uses() - .into_iter() - .filter(|tool_use| tool_use.status.is_idle()) - .cloned() - .collect::>(); - - for tool_use in pending_tool_uses { - if let Some(tool) = self.tools.tool(&tool_use.name, cx) { - let task = tool.run(tool_use.input, self.workspace.clone(), cx); - - self.thread.update(cx, |thread, cx| { - thread.insert_tool_output( - tool_use.assistant_message_id, - tool_use.id.clone(), - task, - cx, - ); - }); - } - } - } - ThreadEvent::ToolFinished { .. } => {} - } + })); + self.message_editor = cx.new_view(|cx| MessageEditor::new(thread, cx)); + self.message_editor.focus_handle(cx).focus(cx); } } @@ -422,140 +290,105 @@ impl AssistantPanel { ) } - fn render_message_list(&self, cx: &mut ViewContext) -> AnyElement { - if self.thread_messages.is_empty() { - let recent_threads = self - .thread_store - .update(cx, |this, cx| this.recent_threads(3, cx)); + fn render_active_thread_or_empty_state(&self, cx: &mut ViewContext) -> AnyElement { + let Some(thread) = self.thread.as_ref() else { + return self.render_thread_empty_state(cx).into_any_element(); + }; - return v_flex() - .gap_2() - .mx_auto() - .child( - v_flex().w_full().child( - svg() - .path("icons/logo_96.svg") - .text_color(cx.theme().colors().text) - .w(px(40.)) - .h(px(40.)) - .mx_auto() - .mb_4(), - ), - ) - .child(v_flex()) - .child( - h_flex() - .w_full() - .justify_center() - .child(Label::new("Context Examples:").size(LabelSize::Small)), - ) - .child( - h_flex() - .gap_2() - .justify_center() - .child( - h_flex() - .gap_1() - .p_0p5() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border_variant) - .child( - Icon::new(IconName::Terminal) - .size(IconSize::Small) - .color(Color::Disabled), - ) - .child(Label::new("Terminal").size(LabelSize::Small)), - ) - .child( - h_flex() - .gap_1() - .p_0p5() - .rounded_md() - .border_1() - .border_color(cx.theme().colors().border_variant) - .child( - Icon::new(IconName::Folder) - .size(IconSize::Small) - .color(Color::Disabled), - ) - .child(Label::new("/src/components").size(LabelSize::Small)), - ), - ) - .child( - h_flex() - .w_full() - .justify_center() - .child(Label::new("Recent Threads:").size(LabelSize::Small)), - ) - .child( - v_flex().gap_2().children( - recent_threads - .into_iter() - .map(|thread| self.render_past_thread(thread, cx)), - ), - ) - .child( - h_flex().w_full().justify_center().child( - Button::new("view-all-past-threads", "View All Past Threads") - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .key_binding(KeyBinding::for_action_in( - &OpenHistory, - &self.focus_handle(cx), - cx, - )) - .on_click(move |_event, cx| { - cx.dispatch_action(OpenHistory.boxed_clone()); - }), - ), - ) - .into_any(); + if thread.read(cx).is_empty() { + return self.render_thread_empty_state(cx).into_any_element(); } - list(self.thread_list_state.clone()).flex_1().into_any() + thread.clone().into_any() } - fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { - let message_id = self.thread_messages[ix]; - let Some(message) = self.thread.read(cx).message(message_id) else { - return Empty.into_any(); - }; + fn render_thread_empty_state(&self, cx: &mut ViewContext) -> impl IntoElement { + let recent_threads = self + .thread_store + .update(cx, |this, cx| this.recent_threads(3, cx)); - let Some(markdown) = self.rendered_messages_by_id.get(&message_id) else { - return Empty.into_any(); - }; - - let (role_icon, role_name) = match message.role { - Role::User => (IconName::Person, "You"), - Role::Assistant => (IconName::ZedAssistant, "Assistant"), - Role::System => (IconName::Settings, "System"), - }; - - div() - .id(("message-container", ix)) - .p_2() + v_flex() + .gap_2() + .mx_auto() .child( - v_flex() - .border_1() - .border_color(cx.theme().colors().border_variant) - .rounded_md() + v_flex().w_full().child( + svg() + .path("icons/logo_96.svg") + .text_color(cx.theme().colors().text) + .w(px(40.)) + .h(px(40.)) + .mx_auto() + .mb_4(), + ), + ) + .child(v_flex()) + .child( + h_flex() + .w_full() + .justify_center() + .child(Label::new("Context Examples:").size(LabelSize::Small)), + ) + .child( + h_flex() + .gap_2() + .justify_center() .child( h_flex() - .justify_between() - .p_1p5() - .border_b_1() + .gap_1() + .p_0p5() + .rounded_md() + .border_1() .border_color(cx.theme().colors().border_variant) .child( - h_flex() - .gap_2() - .child(Icon::new(role_icon).size(IconSize::Small)) - .child(Label::new(role_name).size(LabelSize::Small)), - ), + Icon::new(IconName::Terminal) + .size(IconSize::Small) + .color(Color::Disabled), + ) + .child(Label::new("Terminal").size(LabelSize::Small)), ) - .child(v_flex().p_1p5().text_ui(cx).child(markdown.clone())), + .child( + h_flex() + .gap_1() + .p_0p5() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border_variant) + .child( + Icon::new(IconName::Folder) + .size(IconSize::Small) + .color(Color::Disabled), + ) + .child(Label::new("/src/components").size(LabelSize::Small)), + ), + ) + .child( + h_flex() + .w_full() + .justify_center() + .child(Label::new("Recent Threads:").size(LabelSize::Small)), + ) + .child( + v_flex().gap_2().children( + recent_threads + .into_iter() + .map(|thread| self.render_past_thread(thread, cx)), + ), + ) + .child( + h_flex().w_full().justify_center().child( + Button::new("view-all-past-threads", "View All Past Threads") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + cx, + )) + .on_click(move |_event, cx| { + cx.dispatch_action(OpenHistory.boxed_clone()); + }), + ), ) - .into_any() } fn render_past_thread( @@ -584,7 +417,7 @@ impl AssistantPanel { } fn render_last_error(&self, cx: &mut ViewContext) -> Option { - let last_error = self.last_error.as_ref()?; + let last_error = self.thread.as_ref()?.read(cx).last_error()?; Some( div() @@ -602,7 +435,7 @@ impl AssistantPanel { self.render_max_monthly_spend_reached_error(cx) } ThreadError::Message(error_message) => { - self.render_error_message(error_message, cx) + self.render_error_message(&error_message, cx) } }) .into_any(), @@ -634,14 +467,24 @@ impl AssistantPanel { .mt_1() .child(Button::new("subscribe", "Subscribe").on_click(cx.listener( |this, _, cx| { - this.last_error = None; + if let Some(thread) = this.thread.as_ref() { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + } + cx.open_url(&zed_urls::account_url(cx)); cx.notify(); }, ))) .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( |this, _, cx| { - this.last_error = None; + if let Some(thread) = this.thread.as_ref() { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + } + cx.notify(); }, ))), @@ -675,7 +518,12 @@ impl AssistantPanel { .child( Button::new("subscribe", "Update Monthly Spend Limit").on_click( cx.listener(|this, _, cx| { - this.last_error = None; + if let Some(thread) = this.thread.as_ref() { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + } + cx.open_url(&zed_urls::account_url(cx)); cx.notify(); }), @@ -683,7 +531,12 @@ impl AssistantPanel { ) .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( |this, _, cx| { - this.last_error = None; + if let Some(thread) = this.thread.as_ref() { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + } + cx.notify(); }, ))), @@ -721,7 +574,12 @@ impl AssistantPanel { .mt_1() .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( |this, _, cx| { - this.last_error = None; + if let Some(thread) = this.thread.as_ref() { + thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); + } + cx.notify(); }, ))), @@ -743,7 +601,7 @@ impl Render for AssistantPanel { println!("Open History"); })) .child(self.render_toolbar(cx)) - .child(self.render_message_list(cx)) + .child(self.render_active_thread_or_empty_state(cx)) .child( h_flex() .border_t_1() diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index fc5e0d6a15..185719fa98 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -85,6 +85,10 @@ impl Thread { &self.id } + pub fn is_empty(&self) -> bool { + self.messages.is_empty() + } + pub fn message(&self, id: MessageId) -> Option<&Message> { self.messages.iter().find(|message| message.id == id) } diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs index d784c842c9..80e6d29265 100644 --- a/crates/assistant2/src/thread_store.rs +++ b/crates/assistant2/src/thread_store.rs @@ -52,8 +52,13 @@ impl ThreadStore { }) } - pub fn recent_threads(&self, limit: usize, _cx: &ModelContext) -> Vec> { - self.threads.iter().take(limit).cloned().collect() + pub fn recent_threads(&self, limit: usize, cx: &ModelContext) -> Vec> { + self.threads + .iter() + .filter(|thread| !thread.read(cx).is_empty()) + .take(limit) + .cloned() + .collect() } pub fn create_thread(&mut self, cx: &mut ModelContext) -> Model { From 31796171deb2c11946c5e9fc1c41b3cc791eb3f3 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 4 Dec 2024 18:00:28 -0500 Subject: [PATCH 161/215] assistant2: Sketch in context picker (#21560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR sketches in a context picker into the message editor in Assistant 2. Not functional yet. Screenshot 2024-12-04 at 5 45 19 PM Release Notes: - N/A --- Cargo.lock | 1 + crates/assistant2/Cargo.toml | 1 + crates/assistant2/src/assistant.rs | 1 + crates/assistant2/src/context_picker.rs | 197 ++++++++++++++++++++++++ crates/assistant2/src/message_editor.rs | 48 +++--- 5 files changed, 227 insertions(+), 21 deletions(-) create mode 100644 crates/assistant2/src/context_picker.rs diff --git a/Cargo.lock b/Cargo.lock index f3c0fa3176..c47c2fd126 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -470,6 +470,7 @@ dependencies = [ "language_models", "log", "markdown", + "picker", "project", "proto", "serde", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index fb7dcbe520..e5253adbce 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -29,6 +29,7 @@ language_model_selector.workspace = true language_models.workspace = true log.workspace = true markdown.workspace = true +picker.workspace = true project.workspace = true proto.workspace = true serde.workspace = true diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index dfa361ad8c..13ac2d821b 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -1,5 +1,6 @@ mod active_thread; mod assistant_panel; +mod context_picker; mod message_editor; mod thread; mod thread_store; diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs new file mode 100644 index 0000000000..679ba8b9e7 --- /dev/null +++ b/crates/assistant2/src/context_picker.rs @@ -0,0 +1,197 @@ +use std::sync::Arc; + +use gpui::{DismissEvent, SharedString, Task, WeakView}; +use picker::{Picker, PickerDelegate, PickerEditorPosition}; +use ui::{prelude::*, ListItem, ListItemSpacing, PopoverMenu, PopoverTrigger, Tooltip}; + +use crate::message_editor::MessageEditor; + +#[derive(IntoElement)] +pub(super) struct ContextPicker { + message_editor: WeakView, + trigger: T, +} + +#[derive(Clone)] +struct ContextPickerEntry { + name: SharedString, + description: SharedString, + icon: IconName, +} + +pub(crate) struct ContextPickerDelegate { + all_entries: Vec, + filtered_entries: Vec, + message_editor: WeakView, + selected_ix: usize, +} + +impl ContextPicker { + pub(crate) fn new(message_editor: WeakView, trigger: T) -> Self { + ContextPicker { + message_editor, + trigger, + } + } +} + +impl PickerDelegate for ContextPickerDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.filtered_entries.len() + } + + fn selected_index(&self) -> usize { + self.selected_ix + } + + fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext>) { + self.selected_ix = ix.min(self.filtered_entries.len().saturating_sub(1)); + cx.notify(); + } + + fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc { + "Select a context source…".into() + } + + fn update_matches(&mut self, query: String, cx: &mut ViewContext>) -> Task<()> { + let all_commands = self.all_entries.clone(); + cx.spawn(|this, mut cx| async move { + let filtered_commands = cx + .background_executor() + .spawn(async move { + if query.is_empty() { + all_commands + } else { + all_commands + .into_iter() + .filter(|model_info| { + model_info + .name + .to_lowercase() + .contains(&query.to_lowercase()) + }) + .collect() + } + }) + .await; + + this.update(&mut cx, |this, cx| { + this.delegate.filtered_entries = filtered_commands; + this.delegate.set_selected_index(0, cx); + cx.notify(); + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, cx: &mut ViewContext>) { + if let Some(entry) = self.filtered_entries.get(self.selected_ix) { + self.message_editor + .update(cx, |_message_editor, _cx| { + println!("Insert context from {}", entry.name); + }) + .ok(); + cx.emit(DismissEvent); + } + } + + fn dismissed(&mut self, _cx: &mut ViewContext>) {} + + fn editor_position(&self) -> PickerEditorPosition { + PickerEditorPosition::End + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _cx: &mut ViewContext>, + ) -> Option { + let entry = self.filtered_entries.get(ix)?; + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Dense) + .selected(selected) + .tooltip({ + let description = entry.description.clone(); + move |cx| cx.new_view(|_cx| Tooltip::new(description.clone())).into() + }) + .child( + v_flex() + .group(format!("context-entry-label-{ix}")) + .w_full() + .py_0p5() + .min_w(px(250.)) + .max_w(px(400.)) + .child( + h_flex() + .gap_1p5() + .child(Icon::new(entry.icon).size(IconSize::XSmall)) + .child( + Label::new(entry.name.clone()) + .single_line() + .size(LabelSize::Small), + ), + ) + .child( + div().overflow_hidden().text_ellipsis().child( + Label::new(entry.description.clone()) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ), + ), + ) + } +} + +impl RenderOnce for ContextPicker { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let entries = vec![ + ContextPickerEntry { + name: "directory".into(), + description: "Insert any directory".into(), + icon: IconName::Folder, + }, + ContextPickerEntry { + name: "file".into(), + description: "Insert any file".into(), + icon: IconName::File, + }, + ContextPickerEntry { + name: "web".into(), + description: "Fetch content from URL".into(), + icon: IconName::Globe, + }, + ]; + + let delegate = ContextPickerDelegate { + all_entries: entries.clone(), + message_editor: self.message_editor.clone(), + filtered_entries: entries, + selected_ix: 0, + }; + + let picker = + cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(rems(20.).into()))); + + let handle = self + .message_editor + .update(cx, |this, _| this.context_picker_handle.clone()) + .ok(); + PopoverMenu::new("context-picker") + .menu(move |_cx| Some(picker.clone())) + .trigger(self.trigger) + .attach(gpui::AnchorCorner::TopLeft) + .anchor(gpui::AnchorCorner::BottomLeft) + .offset(gpui::Point { + x: px(0.0), + y: px(-16.0), + }) + .when_some(handle, |this, handle| this.with_handle(handle)) + } +} diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index d1b1cf55e4..f3e618067b 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -1,16 +1,22 @@ use editor::{Editor, EditorElement, EditorStyle}; use gpui::{AppContext, FocusableView, Model, TextStyle, View}; use language_model::{LanguageModelRegistry, LanguageModelRequestTool}; +use picker::Picker; use settings::Settings; use theme::ThemeSettings; -use ui::{prelude::*, ButtonLike, CheckboxWithLabel, ElevationIndex, KeyBinding}; +use ui::{ + prelude::*, ButtonLike, CheckboxWithLabel, ElevationIndex, IconButtonShape, KeyBinding, + PopoverMenuHandle, +}; +use crate::context_picker::{ContextPicker, ContextPickerDelegate}; use crate::thread::{RequestKind, Thread}; use crate::Chat; pub struct MessageEditor { thread: Model, editor: View, + pub(crate) context_picker_handle: PopoverMenuHandle>, use_tools: bool, } @@ -24,6 +30,7 @@ impl MessageEditor { editor }), + context_picker_handle: PopoverMenuHandle::default(), use_tools: false, } } @@ -98,6 +105,14 @@ impl Render for MessageEditor { .gap_2() .p_2() .bg(cx.theme().colors().editor_background) + .child( + h_flex().gap_2().child(ContextPicker::new( + cx.view().downgrade(), + IconButton::new("add-context", IconName::Plus) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small), + )), + ) .child({ let settings = ThemeSettings::get_global(cx); let text_style = TextStyle { @@ -123,26 +138,17 @@ impl Render for MessageEditor { .child( h_flex() .justify_between() - .child( - h_flex() - .child( - Button::new("add-context", "Add Context") - .style(ButtonStyle::Filled) - .icon(IconName::Plus) - .icon_position(IconPosition::Start), - ) - .child(CheckboxWithLabel::new( - "use-tools", - Label::new("Tools"), - self.use_tools.into(), - cx.listener(|this, selection, _cx| { - this.use_tools = match selection { - Selection::Selected => true, - Selection::Unselected | Selection::Indeterminate => false, - }; - }), - )), - ) + .child(h_flex().gap_2().child(CheckboxWithLabel::new( + "use-tools", + Label::new("Tools"), + self.use_tools.into(), + cx.listener(|this, selection, _cx| { + this.use_tools = match selection { + Selection::Selected => true, + Selection::Unselected | Selection::Indeterminate => false, + }; + }), + ))) .child( h_flex() .gap_2() From a2115e7242551aa4e3f64966ed20a96710fa09f2 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 4 Dec 2024 15:02:33 -0800 Subject: [PATCH 162/215] Restructure git diff state management to allow viewing buffers with different diff bases (#21258) This is a pure refactor of our Git diff state management. Buffers are no longer are associated with one single diff (the unstaged changes). Instead, there is an explicit project API for retrieving a buffer's unstaged changes, and the `Editor` view layer is responsible for choosing what diff to associate with a buffer. The reason for this change is that we'll soon want to add multiple "git diff views" to Zed, one of which will show the *uncommitted* changes for a buffer. But that view will need to co-exist with other views of the same buffer, which may want to show the unstaged changes. ### Todo * [x] Get git gutter and git hunks working with new structure * [x] Update editor tests to use new APIs * [x] Update buffer tests * [x] Restructure remoting/collab protocol * [x] Update assertions about staged text in `random_project_collaboration_tests` * [x] Move buffer tests for git diff management to a new spot, using the new APIs Release Notes: - N/A --------- Co-authored-by: Richard Co-authored-by: Cole Co-authored-by: Conrad --- Cargo.lock | 2 - Cargo.toml | 1 + crates/collab/src/rpc.rs | 1 + crates/collab/src/tests/integration_tests.rs | 127 ++-- .../random_project_collaboration_tests.rs | 22 +- crates/collab/src/tests/test_server.rs | 2 +- crates/editor/src/editor.rs | 242 ++++--- crates/editor/src/editor_tests.rs | 444 +++++------- crates/editor/src/element.rs | 65 +- crates/editor/src/git/project_diff.rs | 327 +++++---- crates/editor/src/hunk_diff.rs | 564 ++++++++------- crates/editor/src/items.rs | 2 +- crates/editor/src/proposed_changes_editor.rs | 74 +- .../src/test/editor_lsp_test_context.rs | 10 +- crates/editor/src/test/editor_test_context.rs | 51 +- crates/fs/src/fs.rs | 13 +- crates/git/Cargo.toml | 1 - crates/git/src/diff.rs | 37 +- crates/language/Cargo.toml | 1 - crates/language/src/buffer.rs | 174 +---- crates/language/src/buffer_tests.rs | 48 -- crates/multi_buffer/src/multi_buffer.rs | 343 ++++----- crates/project/src/buffer_store.rs | 666 +++++++++++++----- crates/project/src/project.rs | 71 +- crates/project/src/project_tests.rs | 93 +++ crates/proto/proto/zed.proto | 21 +- crates/proto/src/proto.rs | 4 + .../remote_server/src/remote_editing_tests.rs | 25 +- crates/worktree/src/worktree.rs | 52 +- 29 files changed, 1832 insertions(+), 1651 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c47c2fd126..820f52a150 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4995,7 +4995,6 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", - "clock", "collections", "derive_more", "git2", @@ -6534,7 +6533,6 @@ dependencies = [ "fs", "futures 0.3.31", "fuzzy", - "git", "globset", "gpui", "http_client", diff --git a/Cargo.toml b/Cargo.toml index ab1e9d8e1a..5bf65b3e14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -673,6 +673,7 @@ new_ret_no_self = { level = "allow" } # We have a few `next` functions that differ in lifetimes # compared to Iterator::next. Yet, clippy complains about those. should_implement_trait = { level = "allow" } +let_underscore_future = "allow" [workspace.metadata.cargo-machete] ignored = ["bindgen", "cbindgen", "prost_build", "serde"] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index a17d4924b7..0d9cb2f6c2 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -309,6 +309,7 @@ impl Server { .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_read_only_project_request::) + .add_request_handler(forward_read_only_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler(forward_mutating_project_request::) .add_request_handler( diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index b6a0247424..04b9a36fc7 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -2561,19 +2561,23 @@ async fn test_git_diff_base_change( .update(cx_a, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); + let change_set_local_a = project_local + .update(cx_a, |p, cx| { + p.open_unstaged_changes(buffer_local_a.clone(), cx) + }) + .await + .unwrap(); // Wait for it to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_local_a.read_with(cx_a, |buffer, _| { + change_set_local_a.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], @@ -2585,25 +2589,30 @@ async fn test_git_diff_base_change( .update(cx_b, |p, cx| p.open_buffer((worktree_id, "a.txt"), cx)) .await .unwrap(); + let change_set_remote_a = project_remote + .update(cx_b, |p, cx| { + p.open_unstaged_changes(buffer_remote_a.clone(), cx) + }) + .await + .unwrap(); // Wait remote buffer to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_remote_a.read_with(cx_b, |buffer, _| { + change_set_remote_a.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], ); }); + // Update the staged text of the open buffer client_a.fs().set_index_for_repo( Path::new("/dir/.git"), &[(Path::new("a.txt"), new_diff_base.clone())], @@ -2611,40 +2620,35 @@ async fn test_git_diff_base_change( // Wait for buffer_local_a to receive it executor.run_until_parked(); - - // Smoke test new diffing - - buffer_local_a.read_with(cx_a, |buffer, _| { + change_set_local_a.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); - git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); - // Smoke test B - - buffer_remote_a.read_with(cx_b, |buffer, _| { + change_set_remote_a.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_a.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); - //Nested git dir - + // Nested git dir let diff_base = " one three @@ -2667,19 +2671,23 @@ async fn test_git_diff_base_change( .update(cx_a, |p, cx| p.open_buffer((worktree_id, "sub/b.txt"), cx)) .await .unwrap(); + let change_set_local_b = project_local + .update(cx_a, |p, cx| { + p.open_unstaged_changes(buffer_local_b.clone(), cx) + }) + .await + .unwrap(); // Wait for it to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_local_b.read_with(cx_a, |buffer, _| { + change_set_local_b.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], @@ -2691,25 +2699,29 @@ async fn test_git_diff_base_change( .update(cx_b, |p, cx| p.open_buffer((worktree_id, "sub/b.txt"), cx)) .await .unwrap(); + let change_set_remote_b = project_remote + .update(cx_b, |p, cx| { + p.open_unstaged_changes(buffer_remote_b.clone(), cx) + }) + .await + .unwrap(); - // Wait remote buffer to catch up to the new diff executor.run_until_parked(); - - // Smoke test diffing - - buffer_remote_b.read_with(cx_b, |buffer, _| { + change_set_remote_b.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, &diff_base, &[(1..2, "", "two\n")], ); }); + // Update the staged text client_a.fs().set_index_for_repo( Path::new("/dir/sub/.git"), &[(Path::new("b.txt"), new_diff_base.clone())], @@ -2717,43 +2729,30 @@ async fn test_git_diff_base_change( // Wait for buffer_local_b to receive it executor.run_until_parked(); - - // Smoke test new diffing - - buffer_local_b.read_with(cx_a, |buffer, _| { + change_set_local_b.read_with(cx_a, |change_set, cx| { + let buffer = buffer_local_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); - println!("{:?}", buffer.as_rope().to_string()); - println!("{:?}", buffer.diff_base()); - println!( - "{:?}", - buffer - .snapshot() - .git_diff_hunks_in_row_range(0..4) - .collect::>() - ); - git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); - // Smoke test B - - buffer_remote_b.read_with(cx_b, |buffer, _| { + change_set_remote_b.read_with(cx_b, |change_set, cx| { + let buffer = buffer_remote_b.read(cx); assert_eq!( - buffer.diff_base().map(|rope| rope.to_string()).as_deref(), + change_set.base_text_string(cx).as_deref(), Some(new_diff_base.as_str()) ); git::diff::assert_hunks( - buffer.snapshot().git_diff_hunks_in_row_range(0..4), + change_set.diff_to_buffer.hunks_in_row_range(0..4, buffer), buffer, - &diff_base, + &new_diff_base, &[(2..3, "", "three\n")], ); }); diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 1f39190d75..351ae0cbe6 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -1336,10 +1336,24 @@ impl RandomizedTest for ProjectCollaborationTest { (_, None) => panic!("guest's file is None, hosts's isn't"), } - let host_diff_base = host_buffer - .read_with(host_cx, |b, _| b.diff_base().map(ToString::to_string)); - let guest_diff_base = guest_buffer - .read_with(client_cx, |b, _| b.diff_base().map(ToString::to_string)); + let host_diff_base = host_project.read_with(host_cx, |project, cx| { + project + .buffer_store() + .read(cx) + .get_unstaged_changes(host_buffer.read(cx).remote_id()) + .unwrap() + .read(cx) + .base_text_string(cx) + }); + let guest_diff_base = guest_project.read_with(client_cx, |project, cx| { + project + .buffer_store() + .read(cx) + .get_unstaged_changes(guest_buffer.read(cx).remote_id()) + .unwrap() + .read(cx) + .base_text_string(cx) + }); assert_eq!( guest_diff_base, host_diff_base, "guest {} diff base does not match host's for path {path:?} in project {project_id}", diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index c93cce9770..1528da2ff0 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -585,7 +585,7 @@ impl Deref for TestClient { } impl TestClient { - pub fn fs(&self) -> &FakeFs { + pub fn fs(&self) -> Arc { self.app_state.fs.as_fake() } diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 8af10cd0c9..c5d09ed1bf 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -83,7 +83,7 @@ use gpui::{ use highlight_matching_bracket::refresh_matching_bracket_highlights; use hover_popover::{hide_hover, HoverState}; pub(crate) use hunk_diff::HoveredHunk; -use hunk_diff::{diff_hunk_to_display, ExpandedHunks}; +use hunk_diff::{diff_hunk_to_display, DiffMap, DiffMapSnapshot}; use indent_guides::ActiveIndentGuidesState; use inlay_hint_cache::{InlayHintCache, InlaySplice, InvalidationStrategy}; pub use inline_completion::Direction; @@ -625,7 +625,7 @@ pub struct Editor { enable_inline_completions: bool, show_inline_completions_override: Option, inlay_hint_cache: InlayHintCache, - expanded_hunks: ExpandedHunks, + diff_map: DiffMap, next_inlay_id: usize, _subscriptions: Vec, pixel_position_of_newest_cursor: Option>, @@ -692,6 +692,7 @@ pub struct EditorSnapshot { git_blame_gutter_max_author_length: Option, pub display_snapshot: DisplaySnapshot, pub placeholder_text: Option>, + diff_map: DiffMapSnapshot, is_focused: bool, scroll_anchor: ScrollAnchor, ongoing_scroll: OngoingScroll, @@ -2002,11 +2003,10 @@ impl Editor { } } - let inlay_hint_settings = inlay_hint_settings( - selections.newest_anchor().head(), - &buffer.read(cx).snapshot(cx), - cx, - ); + let buffer_snapshot = buffer.read(cx).snapshot(cx); + + let inlay_hint_settings = + inlay_hint_settings(selections.newest_anchor().head(), &buffer_snapshot, cx); let focus_handle = cx.focus_handle(); cx.on_focus(&focus_handle, Self::handle_focus).detach(); cx.on_focus_in(&focus_handle, Self::handle_focus_in) @@ -2023,6 +2023,28 @@ impl Editor { let mut code_action_providers = Vec::new(); if let Some(project) = project.clone() { + let mut tasks = Vec::new(); + buffer.update(cx, |multibuffer, cx| { + project.update(cx, |project, cx| { + multibuffer.for_each_buffer(|buffer| { + tasks.push(project.open_unstaged_changes(buffer.clone(), cx)) + }); + }); + }); + + cx.spawn(|this, mut cx| async move { + let change_sets = futures::future::join_all(tasks).await; + this.update(&mut cx, |this, cx| { + for change_set in change_sets { + if let Some(change_set) = change_set.log_err() { + this.diff_map.add_change_set(change_set, cx); + } + } + }) + .ok(); + }) + .detach(); + code_action_providers.push(Arc::new(project) as Arc<_>); } @@ -2105,7 +2127,7 @@ impl Editor { inline_completion_provider: None, active_inline_completion: None, inlay_hint_cache: InlayHintCache::new(inlay_hint_settings), - expanded_hunks: ExpandedHunks::default(), + diff_map: DiffMap::default(), gutter_hovered: false, pixel_position_of_newest_cursor: None, last_bounds: None, @@ -2365,6 +2387,7 @@ impl Editor { scroll_anchor: self.scroll_manager.anchor(), ongoing_scroll: self.scroll_manager.ongoing_scroll(), placeholder_text: self.placeholder_text.clone(), + diff_map: self.diff_map.snapshot(), is_focused: self.focus_handle.is_focused(cx), current_line_highlight: self .current_line_highlight @@ -6503,12 +6526,12 @@ impl Editor { pub fn revert_file(&mut self, _: &RevertFile, cx: &mut ViewContext) { let mut revert_changes = HashMap::default(); - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - for hunk in hunks_for_rows( - Some(MultiBufferRow(0)..multi_buffer_snapshot.max_row()).into_iter(), - &multi_buffer_snapshot, + let snapshot = self.snapshot(cx); + for hunk in hunks_for_ranges( + Some(Point::zero()..snapshot.buffer_snapshot.max_point()).into_iter(), + &snapshot, ) { - Self::prepare_revert_change(&mut revert_changes, self.buffer(), &hunk, cx); + self.prepare_revert_change(&mut revert_changes, &hunk, cx); } if !revert_changes.is_empty() { self.transact(cx, |editor, cx| { @@ -6525,7 +6548,7 @@ impl Editor { } pub fn revert_selected_hunks(&mut self, _: &RevertSelectedHunks, cx: &mut ViewContext) { - let revert_changes = self.gather_revert_changes(&self.selections.disjoint_anchors(), cx); + let revert_changes = self.gather_revert_changes(&self.selections.all(cx), cx); if !revert_changes.is_empty() { self.transact(cx, |editor, cx| { editor.revert(revert_changes, cx); @@ -6533,6 +6556,18 @@ impl Editor { } } + fn revert_hunk(&mut self, hunk: HoveredHunk, cx: &mut ViewContext) { + let snapshot = self.buffer.read(cx).read(cx); + if let Some(hunk) = crate::hunk_diff::to_diff_hunk(&hunk, &snapshot) { + drop(snapshot); + let mut revert_changes = HashMap::default(); + self.prepare_revert_change(&mut revert_changes, &hunk, cx); + if !revert_changes.is_empty() { + self.revert(revert_changes, cx) + } + } + } + pub fn open_active_item_in_terminal(&mut self, _: &OpenInTerminal, cx: &mut ViewContext) { if let Some(working_directory) = self.active_excerpt(cx).and_then(|(_, buffer, _)| { let project_path = buffer.read(cx).project_path(cx)?; @@ -6552,26 +6587,33 @@ impl Editor { fn gather_revert_changes( &mut self, - selections: &[Selection], + selections: &[Selection], cx: &mut ViewContext<'_, Editor>, ) -> HashMap, Rope)>> { let mut revert_changes = HashMap::default(); - let multi_buffer_snapshot = self.buffer.read(cx).snapshot(cx); - for hunk in hunks_for_selections(&multi_buffer_snapshot, selections) { - Self::prepare_revert_change(&mut revert_changes, self.buffer(), &hunk, cx); + let snapshot = self.snapshot(cx); + for hunk in hunks_for_selections(&snapshot, selections) { + self.prepare_revert_change(&mut revert_changes, &hunk, cx); } revert_changes } pub fn prepare_revert_change( + &mut self, revert_changes: &mut HashMap, Rope)>>, - multi_buffer: &Model, hunk: &MultiBufferDiffHunk, cx: &AppContext, ) -> Option<()> { - let buffer = multi_buffer.read(cx).buffer(hunk.buffer_id)?; + let buffer = self.buffer.read(cx).buffer(hunk.buffer_id)?; let buffer = buffer.read(cx); - let original_text = buffer.diff_base()?.slice(hunk.diff_base_byte_range.clone()); + let change_set = &self.diff_map.diff_bases.get(&hunk.buffer_id)?.change_set; + let original_text = change_set + .read(cx) + .base_text + .as_ref()? + .read(cx) + .as_rope() + .slice(hunk.diff_base_byte_range.clone()); let buffer_snapshot = buffer.snapshot(); let buffer_revert_changes = revert_changes.entry(buffer.remote_id()).or_default(); if let Err(i) = buffer_revert_changes.binary_search_by(|probe| { @@ -9752,80 +9794,63 @@ impl Editor { } fn go_to_next_hunk(&mut self, _: &GoToHunk, cx: &mut ViewContext) { - let snapshot = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); + let snapshot = self.snapshot(cx); let selection = self.selections.newest::(cx); self.go_to_hunk_after_position(&snapshot, selection.head(), cx); } fn go_to_hunk_after_position( &mut self, - snapshot: &DisplaySnapshot, + snapshot: &EditorSnapshot, position: Point, cx: &mut ViewContext<'_, Editor>, ) -> Option { - if let Some(hunk) = self.go_to_next_hunk_in_direction( - snapshot, - position, - false, - snapshot - .buffer_snapshot - .git_diff_hunks_in_range(MultiBufferRow(position.row + 1)..MultiBufferRow::MAX), - cx, - ) { - return Some(hunk); + for (ix, position) in [position, Point::zero()].into_iter().enumerate() { + if let Some(hunk) = self.go_to_next_hunk_in_direction( + snapshot, + position, + ix > 0, + snapshot.diff_map.diff_hunks_in_range( + position + Point::new(1, 0)..snapshot.buffer_snapshot.max_point(), + &snapshot.buffer_snapshot, + ), + cx, + ) { + return Some(hunk); + } } - - let wrapped_point = Point::zero(); - self.go_to_next_hunk_in_direction( - snapshot, - wrapped_point, - true, - snapshot.buffer_snapshot.git_diff_hunks_in_range( - MultiBufferRow(wrapped_point.row + 1)..MultiBufferRow::MAX, - ), - cx, - ) + None } fn go_to_prev_hunk(&mut self, _: &GoToPrevHunk, cx: &mut ViewContext) { - let snapshot = self - .display_map - .update(cx, |display_map, cx| display_map.snapshot(cx)); + let snapshot = self.snapshot(cx); let selection = self.selections.newest::(cx); - self.go_to_hunk_before_position(&snapshot, selection.head(), cx); } fn go_to_hunk_before_position( &mut self, - snapshot: &DisplaySnapshot, + snapshot: &EditorSnapshot, position: Point, cx: &mut ViewContext<'_, Editor>, ) -> Option { - if let Some(hunk) = self.go_to_next_hunk_in_direction( - snapshot, - position, - false, - snapshot - .buffer_snapshot - .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(position.row)), - cx, - ) { - return Some(hunk); + for (ix, position) in [position, snapshot.buffer_snapshot.max_point()] + .into_iter() + .enumerate() + { + if let Some(hunk) = self.go_to_next_hunk_in_direction( + snapshot, + position, + ix > 0, + snapshot + .diff_map + .diff_hunks_in_range_rev(Point::zero()..position, &snapshot.buffer_snapshot), + cx, + ) { + return Some(hunk); + } } - - let wrapped_point = snapshot.buffer_snapshot.max_point(); - self.go_to_next_hunk_in_direction( - snapshot, - wrapped_point, - true, - snapshot - .buffer_snapshot - .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(wrapped_point.row)), - cx, - ) + None } fn go_to_next_hunk_in_direction( @@ -11270,13 +11295,13 @@ impl Editor { return; } - let mut buffers_affected = HashMap::default(); + let mut buffers_affected = HashSet::default(); let multi_buffer = self.buffer().read(cx); for crease in &creases { if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(crease.range().start.clone(), cx) { - buffers_affected.insert(buffer.read(cx).remote_id(), buffer); + buffers_affected.insert(buffer.read(cx).remote_id()); }; } @@ -11286,8 +11311,8 @@ impl Editor { self.request_autoscroll(Autoscroll::fit(), cx); } - for buffer in buffers_affected.into_values() { - self.sync_expanded_diff_hunks(buffer, cx); + for buffer_id in buffers_affected { + Self::sync_expanded_diff_hunks(&mut self.diff_map, buffer_id, cx); } cx.notify(); @@ -11344,11 +11369,11 @@ impl Editor { return; } - let mut buffers_affected = HashMap::default(); + let mut buffers_affected = HashSet::default(); let multi_buffer = self.buffer().read(cx); for range in ranges { if let Some((_, buffer, _)) = multi_buffer.excerpt_containing(range.start.clone(), cx) { - buffers_affected.insert(buffer.read(cx).remote_id(), buffer); + buffers_affected.insert(buffer.read(cx).remote_id()); }; } @@ -11358,8 +11383,8 @@ impl Editor { self.request_autoscroll(Autoscroll::fit(), cx); } - for buffer in buffers_affected.into_values() { - self.sync_expanded_diff_hunks(buffer, cx); + for buffer_id in buffers_affected { + Self::sync_expanded_diff_hunks(&mut self.diff_map, buffer_id, cx); } cx.notify(); @@ -12653,15 +12678,11 @@ impl Editor { multi_buffer::Event::FileHandleChanged | multi_buffer::Event::Reloaded => { cx.emit(EditorEvent::TitleChanged) } - multi_buffer::Event::DiffBaseChanged => { - self.scrollbar_marker_state.dirty = true; - cx.emit(EditorEvent::DiffBaseChanged); - cx.notify(); - } - multi_buffer::Event::DiffUpdated { buffer } => { - self.sync_expanded_diff_hunks(buffer.clone(), cx); - cx.notify(); - } + // multi_buffer::Event::DiffBaseChanged => { + // self.scrollbar_marker_state.dirty = true; + // cx.emit(EditorEvent::DiffBaseChanged); + // cx.notify(); + // } multi_buffer::Event::Closed => cx.emit(EditorEvent::Closed), multi_buffer::Event::DiagnosticsUpdated => { self.refresh_active_diagnostics(cx); @@ -12829,7 +12850,7 @@ impl Editor { // When editing branch buffers, jump to the corresponding location // in their base buffer. let buffer = buffer_handle.read(cx); - if let Some(base_buffer) = buffer.diff_base_buffer() { + if let Some(base_buffer) = buffer.base_buffer() { range = buffer.range_to_version(range, &base_buffer.read(cx).version()); buffer_handle = base_buffer; } @@ -13606,35 +13627,29 @@ fn test_wrap_with_prefix() { } fn hunks_for_selections( - multi_buffer_snapshot: &MultiBufferSnapshot, - selections: &[Selection], + snapshot: &EditorSnapshot, + selections: &[Selection], ) -> Vec { - let buffer_rows_for_selections = selections.iter().map(|selection| { - let head = selection.head(); - let tail = selection.tail(); - let start = MultiBufferRow(tail.to_point(multi_buffer_snapshot).row); - let end = MultiBufferRow(head.to_point(multi_buffer_snapshot).row); - if start > end { - end..start - } else { - start..end - } - }); - - hunks_for_rows(buffer_rows_for_selections, multi_buffer_snapshot) + hunks_for_ranges( + selections.iter().map(|selection| selection.range()), + snapshot, + ) } -pub fn hunks_for_rows( - rows: impl Iterator>, - multi_buffer_snapshot: &MultiBufferSnapshot, +pub fn hunks_for_ranges( + ranges: impl Iterator>, + snapshot: &EditorSnapshot, ) -> Vec { let mut hunks = Vec::new(); let mut processed_buffer_rows: HashMap>> = HashMap::default(); - for selected_multi_buffer_rows in rows { + for query_range in ranges { let query_rows = - selected_multi_buffer_rows.start..selected_multi_buffer_rows.end.next_row(); - for hunk in multi_buffer_snapshot.git_diff_hunks_in_range(query_rows.clone()) { + MultiBufferRow(query_range.start.row)..MultiBufferRow(query_range.end.row + 1); + for hunk in snapshot.diff_map.diff_hunks_in_range( + Point::new(query_rows.start.0, 0)..Point::new(query_rows.end.0, 0), + &snapshot.buffer_snapshot, + ) { // Deleted hunk is an empty row range, no caret can be placed there and Zed allows to revert it // when the caret is just above or just below the deleted hunk. let allow_adjacent = hunk_status(&hunk) == DiffHunkStatus::Removed; @@ -13643,10 +13658,7 @@ pub fn hunks_for_rows( || hunk.row_range.start == query_rows.end || hunk.row_range.end == query_rows.start } else { - // `selected_multi_buffer_rows` are inclusive (e.g. [2..2] means 2nd row is selected) - // `hunk.row_range` is exclusive (e.g. [2..3] means 2nd row is selected) - hunk.row_range.overlaps(&selected_multi_buffer_rows) - || selected_multi_buffer_rows.end == hunk.row_range.start + hunk.row_range.overlaps(&query_rows) }; if related_to_selection { if !processed_buffer_rows diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 136003dcc3..7f900e2c39 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -25,7 +25,7 @@ use language::{ use language_settings::{Formatter, FormatterList, IndentGuideSettings}; use multi_buffer::MultiBufferIndentGuide; use parking_lot::Mutex; -use project::FakeFs; +use project::{buffer_store::BufferChangeSet, FakeFs}; use project::{ lsp_command::SIGNATURE_HELP_HIGHLIGHT_CURRENT, project_settings::{LspSettings, ProjectSettings}, @@ -3313,7 +3313,7 @@ async fn test_join_lines_with_git_diff_base( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); // Join lines @@ -3353,16 +3353,15 @@ async fn test_custom_newlines_cause_no_false_positive_diffs( init_test(cx, |_| {}); let mut cx = EditorTestContext::new(cx).await; cx.set_state("Line 0\r\nLine 1\rˇ\nLine 2\r\nLine 3"); - cx.set_diff_base(Some("Line 0\r\nLine 1\r\nLine 2\r\nLine 3")); + cx.set_diff_base("Line 0\r\nLine 1\r\nLine 2\r\nLine 3"); executor.run_until_parked(); cx.update_editor(|editor, cx| { + let snapshot = editor.snapshot(cx); assert_eq!( - editor - .buffer() - .read(cx) - .snapshot(cx) - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + snapshot + .diff_map + .diff_hunks_in_range(0..snapshot.buffer_snapshot.len(), &snapshot.buffer_snapshot) .collect::>(), Vec::new(), "Should not have any diffs for files with custom newlines" @@ -10088,7 +10087,7 @@ async fn go_to_hunk(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -11125,17 +11124,18 @@ async fn test_document_format_with_prettier(cx: &mut gpui::TestAppContext) { async fn test_addition_reverts(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; - let base_text = indoc! {r#"struct Row; -struct Row1; -struct Row2; + let base_text = indoc! {r#" + struct Row; + struct Row1; + struct Row2; -struct Row4; -struct Row5; -struct Row6; + struct Row4; + struct Row5; + struct Row6; -struct Row8; -struct Row9; -struct Row10;"#}; + struct Row8; + struct Row9; + struct Row10;"#}; // When addition hunks are not adjacent to carets, no hunk revert is performed assert_hunk_revert( @@ -11266,17 +11266,18 @@ struct Row10;"#}; async fn test_modification_reverts(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let mut cx = EditorLspTestContext::new_rust(lsp::ServerCapabilities::default(), cx).await; - let base_text = indoc! {r#"struct Row; -struct Row1; -struct Row2; + let base_text = indoc! {r#" + struct Row; + struct Row1; + struct Row2; -struct Row4; -struct Row5; -struct Row6; + struct Row4; + struct Row5; + struct Row6; -struct Row8; -struct Row9; -struct Row10;"#}; + struct Row8; + struct Row9; + struct Row10;"#}; // Modification hunks behave the same as the addition ones. assert_hunk_revert( @@ -11494,54 +11495,18 @@ struct Row10;"#}; async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); - let cols = 4; - let rows = 10; - let sample_text_1 = sample_text(rows, cols, 'a'); - assert_eq!( - sample_text_1, - "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj" - ); - let sample_text_2 = sample_text(rows, cols, 'l'); - assert_eq!( - sample_text_2, - "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu" - ); - let sample_text_3 = sample_text(rows, cols, 'v'); - assert_eq!( - sample_text_3, - "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}" - ); + let base_text_1 = "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj"; + let base_text_2 = "llll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu"; + let base_text_3 = + "vvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}"; - fn diff_every_buffer_row( - buffer: &Model, - sample_text: String, - cols: usize, - cx: &mut gpui::TestAppContext, - ) { - // revert first character in each row, creating one large diff hunk per buffer - let is_first_char = |offset: usize| offset % cols == 0; - buffer.update(cx, |buffer, cx| { - buffer.set_text( - sample_text - .chars() - .enumerate() - .map(|(offset, c)| if is_first_char(offset) { 'X' } else { c }) - .collect::(), - cx, - ); - buffer.set_diff_base(Some(sample_text), cx); - }); - cx.executor().run_until_parked(); - } + let text_1 = edit_first_char_of_every_line(base_text_1); + let text_2 = edit_first_char_of_every_line(base_text_2); + let text_3 = edit_first_char_of_every_line(base_text_3); - let buffer_1 = cx.new_model(|cx| Buffer::local(sample_text_1.clone(), cx)); - diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx); - - let buffer_2 = cx.new_model(|cx| Buffer::local(sample_text_2.clone(), cx)); - diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx); - - let buffer_3 = cx.new_model(|cx| Buffer::local(sample_text_3.clone(), cx)); - diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx); + let buffer_1 = cx.new_model(|cx| Buffer::local(text_1.clone(), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(text_2.clone(), cx)); + let buffer_3 = cx.new_model(|cx| Buffer::local(text_3.clone(), cx)); let multibuffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); @@ -11604,57 +11569,85 @@ async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { let (editor, cx) = cx.add_window_view(|cx| build_editor(multibuffer, cx)); editor.update(cx, |editor, cx| { - assert_eq!(editor.text(cx), "XaaaXbbbX\nccXc\ndXdd\n\nhXhh\nXiiiXjjjX\n\nXlllXmmmX\nnnXn\noXoo\n\nsXss\nXtttXuuuX\n\nXvvvXwwwX\nxxXx\nyXyy\n\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n"); + for (buffer, diff_base) in [ + (buffer_1.clone(), base_text_1), + (buffer_2.clone(), base_text_2), + (buffer_3.clone(), base_text_3), + ] { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + diff_base.to_string(), + buffer.read(cx).text_snapshot(), + cx, + ) + }); + editor.diff_map.add_change_set(change_set, cx) + } + }); + cx.executor().run_until_parked(); + + editor.update(cx, |editor, cx| { + assert_eq!(editor.text(cx), "Xaaa\nXbbb\nXccc\n\nXfff\nXggg\n\nXjjj\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}"); editor.select_all(&SelectAll, cx); editor.revert_selected_hunks(&RevertSelectedHunks, cx); }); cx.executor().run_until_parked(); + // When all ranges are selected, all buffer hunks are reverted. editor.update(cx, |editor, cx| { assert_eq!(editor.text(cx), "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nllll\nmmmm\nnnnn\noooo\npppp\nqqqq\nrrrr\nssss\ntttt\nuuuu\n\n\nvvvv\nwwww\nxxxx\nyyyy\nzzzz\n{{{{\n||||\n}}}}\n~~~~\n\u{7f}\u{7f}\u{7f}\u{7f}\n\n"); }); buffer_1.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_1); + assert_eq!(buffer.text(), base_text_1); }); buffer_2.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_2); + assert_eq!(buffer.text(), base_text_2); }); buffer_3.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_3); + assert_eq!(buffer.text(), base_text_3); + }); + + editor.update(cx, |editor, cx| { + editor.undo(&Default::default(), cx); }); - diff_every_buffer_row(&buffer_1, sample_text_1.clone(), cols, cx); - diff_every_buffer_row(&buffer_2, sample_text_2.clone(), cols, cx); - diff_every_buffer_row(&buffer_3, sample_text_3.clone(), cols, cx); editor.update(cx, |editor, cx| { editor.change_selections(None, cx, |s| { s.select_ranges(Some(Point::new(0, 0)..Point::new(6, 0))); }); editor.revert_selected_hunks(&RevertSelectedHunks, cx); }); + // Now, when all ranges selected belong to buffer_1, the revert should succeed, // but not affect buffer_2 and its related excerpts. editor.update(cx, |editor, cx| { assert_eq!( editor.text(cx), - "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX\n\n\nXvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X\n\n" + "aaaa\nbbbb\ncccc\ndddd\neeee\nffff\ngggg\nhhhh\niiii\njjjj\n\n\nXlll\nXmmm\nXnnn\n\nXqqq\nXrrr\n\nXuuu\nXvvv\nXwww\nXxxx\n\nX{{{\nX|||\n\nX\u{7f}\u{7f}\u{7f}" ); }); buffer_1.update(cx, |buffer, _| { - assert_eq!(buffer.text(), sample_text_1); + assert_eq!(buffer.text(), base_text_1); }); buffer_2.update(cx, |buffer, _| { assert_eq!( buffer.text(), - "XlllXmmmX\nnnXn\noXoo\nXpppXqqqX\nrrXr\nsXss\nXtttXuuuX" + "Xlll\nXmmm\nXnnn\nXooo\nXppp\nXqqq\nXrrr\nXsss\nXttt\nXuuu" ); }); buffer_3.update(cx, |buffer, _| { assert_eq!( buffer.text(), - "XvvvXwwwX\nxxXx\nyXyy\nXzzzX{{{X\n||X|\n}X}}\nX~~~X\u{7f}\u{7f}\u{7f}X" + "Xvvv\nXwww\nXxxx\nXyyy\nXzzz\nX{{{\nX|||\nX}}}\nX~~~\nX\u{7f}\u{7f}\u{7f}" ); }); + + fn edit_first_char_of_every_line(text: &str) -> String { + text.split('\n') + .map(|line| format!("X{}", &line[1..])) + .collect::>() + .join("\n") + } } #[gpui::test] @@ -12049,7 +12042,7 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -12057,14 +12050,14 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test editor.toggle_hunk_diff(&ToggleHunkDiff, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::modified; fn main() { - println!("hello"); - + println!("hello there"); + + ˇ println!("hello there"); println!("around the"); println!("world"); @@ -12080,28 +12073,13 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test } }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::modified; - - ˇ - fn main() { - println!("hello there"); - - println!("around the"); - println!("world"); - } - "# - .unindent(), - ); - - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod; + use some::modified; - const A: u32 = 42; - + ˇ fn main() { - println!("hello"); + println!("hello there"); @@ -12117,11 +12095,11 @@ async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::Test editor.cancel(&Cancel, cx); }); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::modified; - + ˇ fn main() { println!("hello there"); @@ -12176,14 +12154,14 @@ async fn test_diff_base_change_with_expanded_diff_hunks( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod1; use some::mod2; @@ -12192,7 +12170,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks( - const B: u32 = 42; const C: u32 = 42; - fn main() { + fn main(ˇ) { - println!("hello"); + //println!("hello"); @@ -12204,16 +12182,16 @@ async fn test_diff_base_change_with_expanded_diff_hunks( .unindent(), ); - cx.set_diff_base(Some("new diff base!")); + cx.set_diff_base("new diff base!"); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod2; const A: u32 = 42; const C: u32 = 42; - fn main() { + fn main(ˇ) { //println!("hello"); println!("world"); @@ -12228,7 +12206,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks( editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - new diff base! + use some::mod2; @@ -12236,7 +12214,7 @@ async fn test_diff_base_change_with_expanded_diff_hunks( + const A: u32 = 42; + const C: u32 = 42; + - + fn main() { + + fn main(ˇ) { + //println!("hello"); + + println!("world"); @@ -12304,7 +12282,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -12312,10 +12290,10 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod1; - use some::mod2; + «use some::mod2; const A: u32 = 42; - const B: u32 = 42; @@ -12327,7 +12305,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: println!("world"); + // - + // + + //ˇ» } fn another() { @@ -12347,9 +12325,9 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: cx.executor().run_until_parked(); // Hunks are not shown if their position is within a fold - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod2; + «use some::mod2; const A: u32 = 42; const C: u32 = 42; @@ -12359,7 +12337,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: println!("world"); // - // + //ˇ» } fn another() { @@ -12381,10 +12359,10 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: cx.executor().run_until_parked(); // The deletions reappear when unfolding. - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" - use some::mod1; - use some::mod2; + «use some::mod2; const A: u32 = 42; - const B: u32 = 42; @@ -12407,7 +12385,7 @@ async fn test_fold_unfold_diff_hunk(executor: BackgroundExecutor, cx: &mut gpui: - fn another2() { println!("another2"); } - "# + ˇ»"# .unindent(), ); } @@ -12423,21 +12401,9 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) let file_3_old = "111\n222\n333\n444\n555\n777\n888\n999\n000\n!!!"; let file_3_new = "111\n222\n333\n444\n555\n666\n777\n888\n999\n000\n!!!"; - let buffer_1 = cx.new_model(|cx| { - let mut buffer = Buffer::local(file_1_new.to_string(), cx); - buffer.set_diff_base(Some(file_1_old.into()), cx); - buffer - }); - let buffer_2 = cx.new_model(|cx| { - let mut buffer = Buffer::local(file_2_new.to_string(), cx); - buffer.set_diff_base(Some(file_2_old.into()), cx); - buffer - }); - let buffer_3 = cx.new_model(|cx| { - let mut buffer = Buffer::local(file_3_new.to_string(), cx); - buffer.set_diff_base(Some(file_3_old.into()), cx); - buffer - }); + let buffer_1 = cx.new_model(|cx| Buffer::local(file_1_new.to_string(), cx)); + let buffer_2 = cx.new_model(|cx| Buffer::local(file_2_new.to_string(), cx)); + let buffer_3 = cx.new_model(|cx| Buffer::local(file_3_new.to_string(), cx)); let multi_buffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); @@ -12499,6 +12465,25 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) }); let editor = cx.add_window(|cx| Editor::new(EditorMode::Full, multi_buffer, None, true, cx)); + editor + .update(cx, |editor, cx| { + for (buffer, diff_base) in [ + (buffer_1.clone(), file_1_old), + (buffer_2.clone(), file_2_old), + (buffer_3.clone(), file_3_old), + ] { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + diff_base.to_string(), + buffer.read(cx).text_snapshot(), + cx, + ) + }); + editor.diff_map.add_change_set(change_set, cx) + } + }) + .unwrap(); + let mut cx = EditorTestContext::for_editor(editor, cx).await; cx.run_until_parked(); @@ -12538,9 +12523,9 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) }); cx.executor().run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( " - aaa + «aaa - bbb ccc ddd @@ -12566,8 +12551,8 @@ async fn test_toggle_diff_expand_in_multi_buffer(cx: &mut gpui::TestAppContext) 777 000 - !!!" - .unindent(), + !!!ˇ»" + .unindent(), ); } @@ -12578,12 +12563,7 @@ async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut gpui::TestAppContext let base = "aaa\nbbb\nccc\nddd\neee\nfff\nggg\n"; let text = "aaa\nBBB\nBB2\nccc\nDDD\nEEE\nfff\nggg\n"; - let buffer = cx.new_model(|cx| { - let mut buffer = Buffer::local(text.to_string(), cx); - buffer.set_diff_base(Some(base.into()), cx); - buffer - }); - + let buffer = cx.new_model(|cx| Buffer::local(text.to_string(), cx)); let multi_buffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); multibuffer.push_excerpts( @@ -12604,15 +12584,24 @@ async fn test_expand_diff_hunk_at_excerpt_boundary(cx: &mut gpui::TestAppContext }); let editor = cx.add_window(|cx| Editor::new(EditorMode::Full, multi_buffer, None, true, cx)); + editor + .update(cx, |editor, cx| { + let buffer = buffer.read(cx).text_snapshot(); + let change_set = cx + .new_model(|cx| BufferChangeSet::new_with_base_text(base.to_string(), buffer, cx)); + editor.diff_map.add_change_set(change_set, cx) + }) + .unwrap(); + let mut cx = EditorTestContext::for_editor(editor, cx).await; cx.run_until_parked(); cx.update_editor(|editor, cx| editor.expand_all_hunk_diffs(&Default::default(), cx)); cx.executor().run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( " - aaa + ˇaaa - bbb + BBB @@ -12667,7 +12656,7 @@ async fn test_edits_around_expanded_insertion_hunks( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -12675,7 +12664,7 @@ async fn test_edits_around_expanded_insertion_hunks( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12683,7 +12672,7 @@ async fn test_edits_around_expanded_insertion_hunks( const A: u32 = 42; + const B: u32 = 42; + const C: u32 = 42; - + + + ˇ fn main() { println!("hello"); @@ -12697,7 +12686,7 @@ async fn test_edits_around_expanded_insertion_hunks( cx.update_editor(|editor, cx| editor.handle_input("const D: u32 = 42;\n", cx)); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12706,7 +12695,7 @@ async fn test_edits_around_expanded_insertion_hunks( + const B: u32 = 42; + const C: u32 = 42; + const D: u32 = 42; - + + + ˇ fn main() { println!("hello"); @@ -12720,7 +12709,7 @@ async fn test_edits_around_expanded_insertion_hunks( cx.update_editor(|editor, cx| editor.handle_input("const E: u32 = 42;\n", cx)); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12730,7 +12719,7 @@ async fn test_edits_around_expanded_insertion_hunks( + const C: u32 = 42; + const D: u32 = 42; + const E: u32 = 42; - + + + ˇ fn main() { println!("hello"); @@ -12746,7 +12735,7 @@ async fn test_edits_around_expanded_insertion_hunks( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12756,32 +12745,6 @@ async fn test_edits_around_expanded_insertion_hunks( + const C: u32 = 42; + const D: u32 = 42; + const E: u32 = 42; - - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - - cx.update_editor(|editor, cx| { - editor.move_up(&MoveUp, cx); - editor.delete_line(&DeleteLine, cx); - editor.move_up(&MoveUp, cx); - editor.delete_line(&DeleteLine, cx); - editor.move_up(&MoveUp, cx); - editor.delete_line(&DeleteLine, cx); - }); - executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::mod1; - use some::mod2; - - const A: u32 = 42; - const B: u32 = 42; ˇ fn main() { println!("hello"); @@ -12792,14 +12755,23 @@ async fn test_edits_around_expanded_insertion_hunks( .unindent(), ); - cx.assert_diff_hunks( + cx.update_editor(|editor, cx| { + editor.move_up(&MoveUp, cx); + editor.delete_line(&DeleteLine, cx); + editor.move_up(&MoveUp, cx); + editor.delete_line(&DeleteLine, cx); + editor.move_up(&MoveUp, cx); + editor.delete_line(&DeleteLine, cx); + }); + executor.run_until_parked(); + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; const A: u32 = 42; + const B: u32 = 42; - + ˇ fn main() { println!("hello"); @@ -12814,13 +12786,13 @@ async fn test_edits_around_expanded_insertion_hunks( editor.delete_line(&DeleteLine, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; - use some::mod2; - - const A: u32 = 42; - + ˇ fn main() { println!("hello"); @@ -12875,7 +12847,7 @@ async fn test_edits_around_expanded_deletion_hunks( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { @@ -12883,13 +12855,13 @@ async fn test_edits_around_expanded_deletion_hunks( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; - const A: u32 = 42; - const B: u32 = 42; + ˇconst B: u32 = 42; const C: u32 = 42; @@ -12906,32 +12878,16 @@ async fn test_edits_around_expanded_deletion_hunks( editor.delete_line(&DeleteLine, cx); }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" + cx.assert_state_with_diff( + r#" use some::mod1; use some::mod2; + - const A: u32 = 42; + - const B: u32 = 42; ˇconst C: u32 = 42; - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - cx.assert_diff_hunks( - r#" - use some::mod1; - use some::mod2; - - - const A: u32 = 42; - - const B: u32 = 42; - const C: u32 = 42; - - fn main() { println!("hello"); @@ -12945,22 +12901,7 @@ async fn test_edits_around_expanded_deletion_hunks( editor.delete_line(&DeleteLine, cx); }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::mod1; - use some::mod2; - - ˇ - - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -12968,7 +12909,7 @@ async fn test_edits_around_expanded_deletion_hunks( - const A: u32 = 42; - const B: u32 = 42; - const C: u32 = 42; - + ˇ fn main() { println!("hello"); @@ -12983,22 +12924,7 @@ async fn test_edits_around_expanded_deletion_hunks( editor.handle_input("replacement", cx); }); executor.run_until_parked(); - cx.assert_editor_state( - &r#" - use some::mod1; - use some::mod2; - - replacementˇ - - fn main() { - println!("hello"); - - println!("world"); - } - "# - .unindent(), - ); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -13007,7 +12933,7 @@ async fn test_edits_around_expanded_deletion_hunks( - const B: u32 = 42; - const C: u32 = 42; - - + replacement + + replacementˇ fn main() { println!("hello"); @@ -13064,14 +12990,14 @@ async fn test_edit_after_expanded_modification_hunk( .unindent(), ); - cx.set_diff_base(Some(&diff_base)); + cx.set_diff_base(&diff_base); executor.run_until_parked(); cx.update_editor(|editor, cx| { editor.expand_all_hunk_diffs(&ExpandAllHunkDiffs, cx); }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -13079,7 +13005,7 @@ async fn test_edit_after_expanded_modification_hunk( const A: u32 = 42; const B: u32 = 42; - const C: u32 = 42; - + const C: u32 = 43 + + const C: u32 = 43ˇ const D: u32 = 42; @@ -13096,7 +13022,7 @@ async fn test_edit_after_expanded_modification_hunk( }); executor.run_until_parked(); - cx.assert_diff_hunks( + cx.assert_state_with_diff( r#" use some::mod1; use some::mod2; @@ -13106,7 +13032,7 @@ async fn test_edit_after_expanded_modification_hunk( - const C: u32 = 42; + const C: u32 = 43 + new_line - + + + ˇ const D: u32 = 42; @@ -14185,22 +14111,14 @@ fn assert_hunk_revert( cx: &mut EditorLspTestContext, ) { cx.set_state(not_reverted_text_with_selections); - cx.update_editor(|editor, cx| { - editor - .buffer() - .read(cx) - .as_singleton() - .unwrap() - .update(cx, |buffer, cx| { - buffer.set_diff_base(Some(base_text.into()), cx); - }); - }); + cx.set_diff_base(base_text); cx.executor().run_until_parked(); let reverted_hunk_statuses = cx.update_editor(|editor, cx| { - let snapshot = editor.buffer().read(cx).snapshot(cx); + let snapshot = editor.snapshot(cx); let reverted_hunk_statuses = snapshot - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + .diff_map + .diff_hunks_in_range(0..snapshot.buffer_snapshot.len(), &snapshot.buffer_snapshot) .map(|hunk| hunk_status(&hunk)) .collect::>(); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 47de2609f7..198ecf6826 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -1169,7 +1169,7 @@ impl EditorElement { let editor = self.editor.read(cx); let is_singleton = editor.is_singleton(cx); // Git - (is_singleton && scrollbar_settings.git_diff && snapshot.buffer_snapshot.has_git_diffs()) + (is_singleton && scrollbar_settings.git_diff && !snapshot.diff_map.is_empty()) || // Buffer Search Results (is_singleton && scrollbar_settings.search_results && editor.has_background_highlights::()) @@ -1320,17 +1320,8 @@ impl EditorElement { cx: &mut WindowContext, ) -> Vec<(DisplayDiffHunk, Option)> { let buffer_snapshot = &snapshot.buffer_snapshot; - - let buffer_start_row = MultiBufferRow( - DisplayPoint::new(display_rows.start, 0) - .to_point(snapshot) - .row, - ); - let buffer_end_row = MultiBufferRow( - DisplayPoint::new(display_rows.end, 0) - .to_point(snapshot) - .row, - ); + let buffer_start = DisplayPoint::new(display_rows.start, 0).to_point(snapshot); + let buffer_end = DisplayPoint::new(display_rows.end, 0).to_point(snapshot); let git_gutter_setting = ProjectSettings::get_global(cx) .git @@ -1338,7 +1329,7 @@ impl EditorElement { .unwrap_or_default(); self.editor.update(cx, |editor, cx| { - let expanded_hunks = &editor.expanded_hunks.hunks; + let expanded_hunks = &editor.diff_map.hunks; let expanded_hunks_start_ix = expanded_hunks .binary_search_by(|hunk| { hunk.hunk_range @@ -1349,8 +1340,10 @@ impl EditorElement { .unwrap_err(); let mut expanded_hunks = expanded_hunks[expanded_hunks_start_ix..].iter().peekable(); - let display_hunks = buffer_snapshot - .git_diff_hunks_in_range(buffer_start_row..buffer_end_row) + let mut display_hunks: Vec<(DisplayDiffHunk, Option)> = editor + .diff_map + .snapshot + .diff_hunks_in_range(buffer_start..buffer_end, &buffer_snapshot) .filter_map(|hunk| { let display_hunk = diff_hunk_to_display(&hunk, snapshot); @@ -1393,25 +1386,23 @@ impl EditorElement { Some(display_hunk) }) .dedup() - .map(|hunk| match git_gutter_setting { - GitGutterSetting::TrackedFiles => { - let hitbox = match hunk { - DisplayDiffHunk::Unfolded { .. } => { - let hunk_bounds = Self::diff_hunk_bounds( - snapshot, - line_height, - gutter_hitbox.bounds, - &hunk, - ); - Some(cx.insert_hitbox(hunk_bounds, true)) - } - DisplayDiffHunk::Folded { .. } => None, - }; - (hunk, hitbox) - } - GitGutterSetting::Hide => (hunk, None), - }) + .map(|hunk| (hunk, None)) .collect(); + + if let GitGutterSetting::TrackedFiles = git_gutter_setting { + for (hunk, hitbox) in &mut display_hunks { + if let DisplayDiffHunk::Unfolded { .. } = hunk { + let hunk_bounds = Self::diff_hunk_bounds( + snapshot, + line_height, + gutter_hitbox.bounds, + &hunk, + ); + *hitbox = Some(cx.insert_hitbox(hunk_bounds, true)); + }; + } + } + display_hunks }) } @@ -3755,10 +3746,8 @@ impl EditorElement { let mut marker_quads = Vec::new(); if scrollbar_settings.git_diff { let marker_row_ranges = snapshot - .buffer_snapshot - .git_diff_hunks_in_range( - MultiBufferRow::MIN..MultiBufferRow::MAX, - ) + .diff_map + .diff_hunks(&snapshot.buffer_snapshot) .map(|hunk| { let start_display_row = MultiBufferPoint::new(hunk.row_range.start.0, 0) @@ -5440,7 +5429,7 @@ impl Element for EditorElement { let expanded_add_hunks_by_rows = self.editor.update(cx, |editor, _| { editor - .expanded_hunks + .diff_map .hunks(false) .filter(|hunk| hunk.status == DiffHunkStatus::Added) .map(|expanded_hunk| { diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index 3e28e28a18..2c60ae4204 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -9,13 +9,15 @@ use std::{ use anyhow::Context as _; use collections::{BTreeMap, HashMap}; use feature_flags::FeatureFlagAppExt; -use futures::{stream::FuturesUnordered, StreamExt}; -use git::{diff::DiffHunk, repository::GitFileStatus}; +use git::{ + diff::{BufferDiff, DiffHunk}, + repository::GitFileStatus, +}; use gpui::{ actions, AnyElement, AnyView, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, Model, Render, Subscription, Task, View, WeakView, }; -use language::{Buffer, BufferRow, BufferSnapshot}; +use language::{Buffer, BufferRow}; use multi_buffer::{ExcerptId, ExcerptRange, ExpandExcerptDirection, MultiBuffer}; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; use text::{OffsetRangeExt, ToPoint}; @@ -215,54 +217,56 @@ impl ProjectDiffEditor { .ok() .flatten() .unwrap_or_default(); - let buffers_with_git_diff = cx - .background_executor() - .spawn(async move { - let mut open_tasks = open_tasks - .into_iter() - .map(|(status, entry_id, entry_path, open_task)| async move { - let (_, opened_model) = open_task.await.with_context(|| { - format!( - "loading buffer {} for git diff", - entry_path.path.display() - ) - })?; - let buffer = match opened_model.downcast::() { - Ok(buffer) => buffer, - Err(_model) => anyhow::bail!( - "Could not load {} as a buffer for git diff", - entry_path.path.display() - ), - }; - anyhow::Ok((status, entry_id, entry_path, buffer)) - }) - .collect::>(); - let mut buffers_with_git_diff = Vec::new(); - while let Some(opened_buffer) = open_tasks.next().await { - if let Some(opened_buffer) = opened_buffer.log_err() { - buffers_with_git_diff.push(opened_buffer); - } - } - buffers_with_git_diff - }) - .await; - - let Some((buffers, mut new_entries)) = cx - .update(|cx| { + let Some((buffers, mut new_entries, change_sets)) = cx + .spawn(|mut cx| async move { + let mut new_entries = Vec::new(); let mut buffers = HashMap::< ProjectEntryId, - (GitFileStatus, Model, BufferSnapshot), + ( + GitFileStatus, + text::BufferSnapshot, + Model, + BufferDiff, + ), >::default(); - let mut new_entries = Vec::new(); - for (status, entry_id, entry_path, buffer) in buffers_with_git_diff { - let buffer_snapshot = buffer.read(cx).snapshot(); - buffers.insert(entry_id, (status, buffer, buffer_snapshot)); + let mut change_sets = Vec::new(); + for (status, entry_id, entry_path, open_task) in open_tasks { + let (_, opened_model) = open_task.await.with_context(|| { + format!("loading buffer {} for git diff", entry_path.path.display()) + })?; + let buffer = match opened_model.downcast::() { + Ok(buffer) => buffer, + Err(_model) => anyhow::bail!( + "Could not load {} as a buffer for git diff", + entry_path.path.display() + ), + }; + let change_set = project + .update(&mut cx, |project, cx| { + project.open_unstaged_changes(buffer.clone(), cx) + })? + .await?; + + cx.update(|cx| { + buffers.insert( + entry_id, + ( + status, + buffer.read(cx).text_snapshot(), + buffer, + change_set.read(cx).diff_to_buffer.clone(), + ), + ); + })?; + change_sets.push(change_set); new_entries.push((entry_path, entry_id)); } - (buffers, new_entries) + + Ok((buffers, new_entries, change_sets)) }) - .ok() + .await + .log_err() else { return; }; @@ -271,14 +275,14 @@ impl ProjectDiffEditor { .background_executor() .spawn(async move { let mut new_changes = HashMap::::default(); - for (entry_id, (status, buffer, buffer_snapshot)) in buffers { + for (entry_id, (status, buffer_snapshot, buffer, buffer_diff)) in buffers { new_changes.insert( entry_id, Changes { _status: status, buffer, - hunks: buffer_snapshot - .git_diff_hunks_in_row_range(0..BufferRow::MAX) + hunks: buffer_diff + .hunks_in_row_range(0..BufferRow::MAX, &buffer_snapshot) .collect::>(), }, ); @@ -294,33 +298,16 @@ impl ProjectDiffEditor { }) .await; - let mut diff_recalculations = FuturesUnordered::new(); project_diff_editor .update(&mut cx, |project_diff_editor, cx| { project_diff_editor.update_excerpts(id, new_changes, new_entry_order, cx); - for buffer in project_diff_editor - .editor - .read(cx) - .buffer() - .read(cx) - .all_buffers() - { - buffer.update(cx, |buffer, cx| { - if let Some(diff_recalculation) = buffer.recalculate_diff(cx) { - diff_recalculations.push(diff_recalculation); - } + for change_set in change_sets { + project_diff_editor.editor.update(cx, |editor, cx| { + editor.diff_map.add_change_set(change_set, cx) }); } }) .ok(); - - cx.background_executor() - .spawn(async move { - while let Some(()) = diff_recalculations.next().await { - // another diff is calculated - } - }) - .await; }), ); } @@ -1100,13 +1087,13 @@ impl Render for ProjectDiffEditor { #[cfg(test)] mod tests { - use std::{ops::Deref as _, path::Path, sync::Arc}; + // use std::{ops::Deref as _, path::Path, sync::Arc}; - use fs::RealFs; - use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; - use settings::SettingsStore; + // use fs::RealFs; + // use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; + // use settings::SettingsStore; - use super::*; + // use super::*; // TODO finish // #[gpui::test] @@ -1122,114 +1109,114 @@ mod tests { // // Apply randomized changes to the project: select a random file, random change and apply to buffers // } - #[gpui::test] - async fn simple_edit_test(cx: &mut TestAppContext) { - cx.executor().allow_parking(); - init_test(cx); + // #[gpui::test] + // async fn simple_edit_test(cx: &mut TestAppContext) { + // cx.executor().allow_parking(); + // init_test(cx); - let dir = tempfile::tempdir().unwrap(); - let dst = dir.path(); + // let dir = tempfile::tempdir().unwrap(); + // let dst = dir.path(); - std::fs::write(dst.join("file_a"), "This is file_a").unwrap(); - std::fs::write(dst.join("file_b"), "This is file_b").unwrap(); + // std::fs::write(dst.join("file_a"), "This is file_a").unwrap(); + // std::fs::write(dst.join("file_b"), "This is file_b").unwrap(); - run_git(dst, &["init"]); - run_git(dst, &["add", "*"]); - run_git(dst, &["commit", "-m", "Initial commit"]); + // run_git(dst, &["init"]); + // run_git(dst, &["add", "*"]); + // run_git(dst, &["commit", "-m", "Initial commit"]); - let project = Project::test(Arc::new(RealFs::default()), [dst], cx).await; - let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + // let project = Project::test(Arc::new(RealFs::default()), [dst], cx).await; + // let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + // let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - let file_a_editor = workspace - .update(cx, |workspace, cx| { - let file_a_editor = workspace.open_abs_path(dst.join("file_a"), true, cx); - ProjectDiffEditor::deploy(workspace, &Deploy, cx); - file_a_editor - }) - .unwrap() - .await - .expect("did not open an item at all") - .downcast::() - .expect("did not open an editor for file_a"); + // let file_a_editor = workspace + // .update(cx, |workspace, cx| { + // let file_a_editor = workspace.open_abs_path(dst.join("file_a"), true, cx); + // ProjectDiffEditor::deploy(workspace, &Deploy, cx); + // file_a_editor + // }) + // .unwrap() + // .await + // .expect("did not open an item at all") + // .downcast::() + // .expect("did not open an editor for file_a"); - let project_diff_editor = workspace - .update(cx, |workspace, cx| { - workspace - .active_pane() - .read(cx) - .items() - .find_map(|item| item.downcast::()) - }) - .unwrap() - .expect("did not find a ProjectDiffEditor"); - project_diff_editor.update(cx, |project_diff_editor, cx| { - assert!( - project_diff_editor.editor.read(cx).text(cx).is_empty(), - "Should have no changes after opening the diff on no git changes" - ); - }); + // let project_diff_editor = workspace + // .update(cx, |workspace, cx| { + // workspace + // .active_pane() + // .read(cx) + // .items() + // .find_map(|item| item.downcast::()) + // }) + // .unwrap() + // .expect("did not find a ProjectDiffEditor"); + // project_diff_editor.update(cx, |project_diff_editor, cx| { + // assert!( + // project_diff_editor.editor.read(cx).text(cx).is_empty(), + // "Should have no changes after opening the diff on no git changes" + // ); + // }); - let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx)); - let change = "an edit after git add"; - file_a_editor - .update(cx, |file_a_editor, cx| { - file_a_editor.insert(change, cx); - file_a_editor.save(false, project.clone(), cx) - }) - .await - .expect("failed to save a file"); - cx.executor().advance_clock(Duration::from_secs(1)); - cx.run_until_parked(); + // let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx)); + // let change = "an edit after git add"; + // file_a_editor + // .update(cx, |file_a_editor, cx| { + // file_a_editor.insert(change, cx); + // file_a_editor.save(false, project.clone(), cx) + // }) + // .await + // .expect("failed to save a file"); + // cx.executor().advance_clock(Duration::from_secs(1)); + // cx.run_until_parked(); - // TODO does not work on Linux for some reason, returning a blank line - // hence disable the last check for now, and do some fiddling to avoid the warnings. - #[cfg(target_os = "linux")] - { - if true { - return; - } - } - project_diff_editor.update(cx, |project_diff_editor, cx| { - // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added) - assert_eq!( - project_diff_editor.editor.read(cx).text(cx), - format!("{change}{old_text}"), - "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards" - ); - }); - } + // // TODO does not work on Linux for some reason, returning a blank line + // // hence disable the last check for now, and do some fiddling to avoid the warnings. + // #[cfg(target_os = "linux")] + // { + // if true { + // return; + // } + // } + // project_diff_editor.update(cx, |project_diff_editor, cx| { + // // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added) + // assert_eq!( + // project_diff_editor.editor.read(cx).text(cx), + // format!("{change}{old_text}"), + // "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards" + // ); + // }); + // } - fn run_git(path: &Path, args: &[&str]) -> String { - let output = std::process::Command::new("git") - .args(args) - .current_dir(path) - .output() - .expect("git commit failed"); + // fn run_git(path: &Path, args: &[&str]) -> String { + // let output = std::process::Command::new("git") + // .args(args) + // .current_dir(path) + // .output() + // .expect("git commit failed"); - format!( - "Stdout: {}; stderr: {}", - String::from_utf8(output.stdout).unwrap(), - String::from_utf8(output.stderr).unwrap() - ) - } + // format!( + // "Stdout: {}; stderr: {}", + // String::from_utf8(output.stdout).unwrap(), + // String::from_utf8(output.stderr).unwrap() + // ) + // } - fn init_test(cx: &mut gpui::TestAppContext) { - if std::env::var("RUST_LOG").is_ok() { - env_logger::try_init().ok(); - } + // fn init_test(cx: &mut gpui::TestAppContext) { + // if std::env::var("RUST_LOG").is_ok() { + // env_logger::try_init().ok(); + // } - cx.update(|cx| { - assets::Assets.load_test_fonts(cx); - let settings_store = SettingsStore::test(cx); - cx.set_global(settings_store); - theme::init(theme::LoadThemes::JustBase, cx); - release_channel::init(SemanticVersion::default(), cx); - client::init_settings(cx); - language::init(cx); - Project::init_settings(cx); - workspace::init_settings(cx); - crate::init(cx); - }); - } + // cx.update(|cx| { + // assets::Assets.load_test_fonts(cx); + // let settings_store = SettingsStore::test(cx); + // cx.set_global(settings_store); + // theme::init(theme::LoadThemes::JustBase, cx); + // release_channel::init(SemanticVersion::default(), cx); + // client::init_settings(cx); + // language::init(cx); + // Project::init_settings(cx); + // workspace::init_settings(cx); + // crate::init(cx); + // }); + // } } diff --git a/crates/editor/src/hunk_diff.rs b/crates/editor/src/hunk_diff.rs index 3da005cd2c..3f798eaa58 100644 --- a/crates/editor/src/hunk_diff.rs +++ b/crates/editor/src/hunk_diff.rs @@ -1,12 +1,17 @@ -use collections::{hash_map, HashMap, HashSet}; +use collections::{HashMap, HashSet}; use git::diff::DiffHunkStatus; -use gpui::{Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Task, View}; +use gpui::{ + Action, AnchorCorner, AppContext, CursorStyle, Hsla, Model, MouseButton, Subscription, Task, + View, +}; use language::{Buffer, BufferId, Point}; use multi_buffer::{ Anchor, AnchorRangeExt, ExcerptRange, MultiBuffer, MultiBufferDiffHunk, MultiBufferRow, - MultiBufferSnapshot, ToPoint, + MultiBufferSnapshot, ToOffset, ToPoint, }; +use project::buffer_store::BufferChangeSet; use std::{ops::Range, sync::Arc}; +use sum_tree::TreeMap; use text::OffsetRangeExt; use ui::{ prelude::*, ActiveTheme, ContextMenu, IconButtonShape, InteractiveElement, IntoElement, @@ -29,10 +34,11 @@ pub(super) struct HoveredHunk { pub diff_base_byte_range: Range, } -#[derive(Debug, Default)] -pub(super) struct ExpandedHunks { +#[derive(Default)] +pub(super) struct DiffMap { pub(crate) hunks: Vec, - diff_base: HashMap, + pub(crate) diff_bases: HashMap, + pub(crate) snapshot: DiffMapSnapshot, hunk_update_tasks: HashMap, Task<()>>, expand_all: bool, } @@ -46,10 +52,13 @@ pub(super) struct ExpandedHunk { pub folded: bool, } -#[derive(Debug)] -struct DiffBaseBuffer { - buffer: Model, - diff_base_version: usize, +#[derive(Clone, Debug, Default)] +pub(crate) struct DiffMapSnapshot(TreeMap); + +pub(crate) struct DiffBaseState { + pub(crate) change_set: Model, + pub(crate) last_version: Option, + _subscription: Subscription, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -66,7 +75,38 @@ pub enum DisplayDiffHunk { }, } -impl ExpandedHunks { +impl DiffMap { + pub fn snapshot(&self) -> DiffMapSnapshot { + self.snapshot.clone() + } + + pub fn add_change_set( + &mut self, + change_set: Model, + cx: &mut ViewContext, + ) { + let buffer_id = change_set.read(cx).buffer_id; + self.snapshot + .0 + .insert(buffer_id, change_set.read(cx).diff_to_buffer.clone()); + Editor::sync_expanded_diff_hunks(self, buffer_id, cx); + self.diff_bases.insert( + buffer_id, + DiffBaseState { + last_version: None, + _subscription: cx.observe(&change_set, move |editor, change_set, cx| { + editor + .diff_map + .snapshot + .0 + .insert(buffer_id, change_set.read(cx).diff_to_buffer.clone()); + Editor::sync_expanded_diff_hunks(&mut editor.diff_map, buffer_id, cx); + }), + change_set, + }, + ); + } + pub fn hunks(&self, include_folded: bool) -> impl Iterator { self.hunks .iter() @@ -74,9 +114,92 @@ impl ExpandedHunks { } } +impl DiffMapSnapshot { + pub fn is_empty(&self) -> bool { + self.0.values().all(|diff| diff.is_empty()) + } + + pub fn diff_hunks<'a>( + &'a self, + buffer_snapshot: &'a MultiBufferSnapshot, + ) -> impl Iterator + 'a { + self.diff_hunks_in_range(0..buffer_snapshot.len(), buffer_snapshot) + } + + pub fn diff_hunks_in_range<'a, T: ToOffset>( + &'a self, + range: Range, + buffer_snapshot: &'a MultiBufferSnapshot, + ) -> impl Iterator + 'a { + let range = range.start.to_offset(buffer_snapshot)..range.end.to_offset(buffer_snapshot); + buffer_snapshot + .excerpts_for_range(range.clone()) + .filter_map(move |excerpt| { + let buffer = excerpt.buffer(); + let buffer_id = buffer.remote_id(); + let diff = self.0.get(&buffer_id)?; + let buffer_range = excerpt.map_range_to_buffer(range.clone()); + let buffer_range = + buffer.anchor_before(buffer_range.start)..buffer.anchor_after(buffer_range.end); + Some( + diff.hunks_intersecting_range(buffer_range, excerpt.buffer()) + .map(move |hunk| { + let start = + excerpt.map_point_from_buffer(Point::new(hunk.row_range.start, 0)); + let end = + excerpt.map_point_from_buffer(Point::new(hunk.row_range.end, 0)); + MultiBufferDiffHunk { + row_range: MultiBufferRow(start.row)..MultiBufferRow(end.row), + buffer_id, + buffer_range: hunk.buffer_range.clone(), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + } + }), + ) + }) + .flatten() + } + + pub fn diff_hunks_in_range_rev<'a, T: ToOffset>( + &'a self, + range: Range, + buffer_snapshot: &'a MultiBufferSnapshot, + ) -> impl Iterator + 'a { + let range = range.start.to_offset(buffer_snapshot)..range.end.to_offset(buffer_snapshot); + buffer_snapshot + .excerpts_for_range_rev(range.clone()) + .filter_map(move |excerpt| { + let buffer = excerpt.buffer(); + let buffer_id = buffer.remote_id(); + let diff = self.0.get(&buffer_id)?; + let buffer_range = excerpt.map_range_to_buffer(range.clone()); + let buffer_range = + buffer.anchor_before(buffer_range.start)..buffer.anchor_after(buffer_range.end); + Some( + diff.hunks_intersecting_range_rev(buffer_range, excerpt.buffer()) + .map(move |hunk| { + let start_row = excerpt + .map_point_from_buffer(Point::new(hunk.row_range.start, 0)) + .row; + let end_row = excerpt + .map_point_from_buffer(Point::new(hunk.row_range.end, 0)) + .row; + MultiBufferDiffHunk { + row_range: MultiBufferRow(start_row)..MultiBufferRow(end_row), + buffer_id, + buffer_range: hunk.buffer_range.clone(), + diff_base_byte_range: hunk.diff_base_byte_range.clone(), + } + }), + ) + }) + .flatten() + } +} + impl Editor { pub fn set_expand_all_diff_hunks(&mut self) { - self.expanded_hunks.expand_all = true; + self.diff_map.expand_all = true; } pub(super) fn toggle_hovered_hunk( @@ -92,18 +215,15 @@ impl Editor { } pub fn toggle_hunk_diff(&mut self, _: &ToggleHunkDiff, cx: &mut ViewContext) { - let multi_buffer_snapshot = self.buffer().read(cx).snapshot(cx); - let selections = self.selections.disjoint_anchors(); - self.toggle_hunks_expanded( - hunks_for_selections(&multi_buffer_snapshot, &selections), - cx, - ); + let snapshot = self.snapshot(cx); + let selections = self.selections.all(cx); + self.toggle_hunks_expanded(hunks_for_selections(&snapshot, &selections), cx); } pub fn expand_all_hunk_diffs(&mut self, _: &ExpandAllHunkDiffs, cx: &mut ViewContext) { let snapshot = self.snapshot(cx); let display_rows_with_expanded_hunks = self - .expanded_hunks + .diff_map .hunks(false) .map(|hunk| &hunk.hunk_range) .map(|anchor_range| { @@ -119,10 +239,10 @@ impl Editor { ) }) .collect::>(); - let hunks = snapshot - .display_snapshot - .buffer_snapshot - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + let hunks = self + .diff_map + .snapshot + .diff_hunks(&snapshot.display_snapshot.buffer_snapshot) .filter(|hunk| { let hunk_display_row_range = Point::new(hunk.row_range.start.0, 0) .to_display_point(&snapshot.display_snapshot) @@ -140,11 +260,11 @@ impl Editor { hunks_to_toggle: Vec, cx: &mut ViewContext, ) { - if self.expanded_hunks.expand_all { + if self.diff_map.expand_all { return; } - let previous_toggle_task = self.expanded_hunks.hunk_update_tasks.remove(&None); + let previous_toggle_task = self.diff_map.hunk_update_tasks.remove(&None); let new_toggle_task = cx.spawn(move |editor, mut cx| async move { if let Some(task) = previous_toggle_task { task.await; @@ -154,11 +274,10 @@ impl Editor { .update(&mut cx, |editor, cx| { let snapshot = editor.snapshot(cx); let mut hunks_to_toggle = hunks_to_toggle.into_iter().fuse().peekable(); - let mut highlights_to_remove = - Vec::with_capacity(editor.expanded_hunks.hunks.len()); + let mut highlights_to_remove = Vec::with_capacity(editor.diff_map.hunks.len()); let mut blocks_to_remove = HashSet::default(); let mut hunks_to_expand = Vec::new(); - editor.expanded_hunks.hunks.retain(|expanded_hunk| { + editor.diff_map.hunks.retain(|expanded_hunk| { if expanded_hunk.folded { return true; } @@ -238,7 +357,7 @@ impl Editor { .ok(); }); - self.expanded_hunks + self.diff_map .hunk_update_tasks .insert(None, cx.background_executor().spawn(new_toggle_task)); } @@ -252,30 +371,34 @@ impl Editor { let buffer = self.buffer.clone(); let multi_buffer_snapshot = buffer.read(cx).snapshot(cx); let hunk_range = hunk.multi_buffer_range.clone(); - let (diff_base_buffer, deleted_text_lines) = buffer.update(cx, |buffer, cx| { - let buffer = buffer.buffer(hunk_range.start.buffer_id?)?; - let diff_base_buffer = diff_base_buffer - .or_else(|| self.current_diff_base_buffer(&buffer, cx)) - .or_else(|| create_diff_base_buffer(&buffer, cx))?; - let deleted_text_lines = buffer.read(cx).diff_base().map(|diff_base| { - let diff_start_row = diff_base - .offset_to_point(hunk.diff_base_byte_range.start) - .row; - let diff_end_row = diff_base.offset_to_point(hunk.diff_base_byte_range.end).row; - diff_end_row - diff_start_row - })?; - Some((diff_base_buffer, deleted_text_lines)) + let buffer_id = hunk_range.start.buffer_id?; + let diff_base_buffer = diff_base_buffer.or_else(|| { + self.diff_map + .diff_bases + .get(&buffer_id)? + .change_set + .read(cx) + .base_text + .clone() })?; - let block_insert_index = match self.expanded_hunks.hunks.binary_search_by(|probe| { - probe - .hunk_range - .start - .cmp(&hunk_range.start, &multi_buffer_snapshot) - }) { - Ok(_already_present) => return None, - Err(ix) => ix, - }; + let diff_base = diff_base_buffer.read(cx); + let diff_start_row = diff_base + .offset_to_point(hunk.diff_base_byte_range.start) + .row; + let diff_end_row = diff_base.offset_to_point(hunk.diff_base_byte_range.end).row; + let deleted_text_lines = diff_end_row - diff_start_row; + + let block_insert_index = self + .diff_map + .hunks + .binary_search_by(|probe| { + probe + .hunk_range + .start + .cmp(&hunk_range.start, &multi_buffer_snapshot) + }) + .err()?; let blocks; match hunk.status { @@ -315,7 +438,7 @@ impl Editor { ); } }; - self.expanded_hunks.hunks.insert( + self.diff_map.hunks.insert( block_insert_index, ExpandedHunk { blocks, @@ -374,8 +497,8 @@ impl Editor { _: &ApplyDiffHunk, cx: &mut ViewContext, ) { - let snapshot = self.buffer.read(cx).snapshot(cx); - let hunks = hunks_for_selections(&snapshot, &self.selections.disjoint_anchors()); + let snapshot = self.snapshot(cx); + let hunks = hunks_for_selections(&snapshot, &self.selections.all(cx)); let mut ranges_by_buffer = HashMap::default(); self.transact(cx, |editor, cx| { for hunk in hunks { @@ -401,7 +524,7 @@ impl Editor { fn has_multiple_hunks(&self, cx: &AppContext) -> bool { let snapshot = self.buffer.read(cx).snapshot(cx); - let mut hunks = snapshot.git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX); + let mut hunks = self.diff_map.snapshot.diff_hunks(&snapshot); hunks.nth(1).is_some() } @@ -415,7 +538,7 @@ impl Editor { .read(cx) .point_to_buffer_offset(hunk.multi_buffer_range.start, cx) .map_or(false, |(buffer, _, _)| { - buffer.read(cx).diff_base_buffer().is_some() + buffer.read(cx).base_buffer().is_some() }); let border_color = cx.theme().colors().border_variant; @@ -552,29 +675,9 @@ impl Editor { let editor = editor.clone(); let hunk = hunk.clone(); move |_event, cx| { - let multi_buffer = - editor.read(cx).buffer().clone(); - let multi_buffer_snapshot = - multi_buffer.read(cx).snapshot(cx); - let mut revert_changes = HashMap::default(); - if let Some(hunk) = - crate::hunk_diff::to_diff_hunk( - &hunk, - &multi_buffer_snapshot, - ) - { - Editor::prepare_revert_change( - &mut revert_changes, - &multi_buffer, - &hunk, - cx, - ); - } - if !revert_changes.is_empty() { - editor.update(cx, |editor, cx| { - editor.revert(revert_changes, cx) - }); - } + editor.update(cx, |editor, cx| { + editor.revert_hunk(hunk.clone(), cx); + }); } }), ) @@ -763,13 +866,13 @@ impl Editor { } pub(super) fn clear_expanded_diff_hunks(&mut self, cx: &mut ViewContext<'_, Editor>) -> bool { - if self.expanded_hunks.expand_all { + if self.diff_map.expand_all { return false; } - self.expanded_hunks.hunk_update_tasks.clear(); + self.diff_map.hunk_update_tasks.clear(); self.clear_row_highlights::(); let to_remove = self - .expanded_hunks + .diff_map .hunks .drain(..) .flat_map(|expanded_hunk| expanded_hunk.blocks.into_iter()) @@ -783,48 +886,39 @@ impl Editor { } pub(super) fn sync_expanded_diff_hunks( - &mut self, - buffer: Model, + diff_map: &mut DiffMap, + buffer_id: BufferId, cx: &mut ViewContext<'_, Self>, ) { - let buffer_id = buffer.read(cx).remote_id(); - let buffer_diff_base_version = buffer.read(cx).diff_base_version(); - self.expanded_hunks - .hunk_update_tasks - .remove(&Some(buffer_id)); - let diff_base_buffer = self.current_diff_base_buffer(&buffer, cx); + let diff_base_state = diff_map.diff_bases.get_mut(&buffer_id); + let mut diff_base_buffer = None; + let mut diff_base_buffer_unchanged = true; + if let Some(diff_base_state) = diff_base_state { + diff_base_state.change_set.update(cx, |change_set, _| { + if diff_base_state.last_version != Some(change_set.base_text_version) { + diff_base_state.last_version = Some(change_set.base_text_version); + diff_base_buffer_unchanged = false; + } + diff_base_buffer = change_set.base_text.clone(); + }) + } + + diff_map.hunk_update_tasks.remove(&Some(buffer_id)); + let new_sync_task = cx.spawn(move |editor, mut cx| async move { - let diff_base_buffer_unchanged = diff_base_buffer.is_some(); - let Ok(diff_base_buffer) = - cx.update(|cx| diff_base_buffer.or_else(|| create_diff_base_buffer(&buffer, cx))) - else { - return; - }; editor .update(&mut cx, |editor, cx| { - if let Some(diff_base_buffer) = &diff_base_buffer { - editor.expanded_hunks.diff_base.insert( - buffer_id, - DiffBaseBuffer { - buffer: diff_base_buffer.clone(), - diff_base_version: buffer_diff_base_version, - }, - ); - } - let snapshot = editor.snapshot(cx); let mut recalculated_hunks = snapshot - .buffer_snapshot - .git_diff_hunks_in_range(MultiBufferRow::MIN..MultiBufferRow::MAX) + .diff_map + .diff_hunks(&snapshot.buffer_snapshot) .filter(|hunk| hunk.buffer_id == buffer_id) .fuse() .peekable(); - let mut highlights_to_remove = - Vec::with_capacity(editor.expanded_hunks.hunks.len()); + let mut highlights_to_remove = Vec::with_capacity(editor.diff_map.hunks.len()); let mut blocks_to_remove = HashSet::default(); - let mut hunks_to_reexpand = - Vec::with_capacity(editor.expanded_hunks.hunks.len()); - editor.expanded_hunks.hunks.retain_mut(|expanded_hunk| { + let mut hunks_to_reexpand = Vec::with_capacity(editor.diff_map.hunks.len()); + editor.diff_map.hunks.retain_mut(|expanded_hunk| { if expanded_hunk.hunk_range.start.buffer_id != Some(buffer_id) { return true; }; @@ -874,7 +968,7 @@ impl Editor { > hunk_display_range.end { recalculated_hunks.next(); - if editor.expanded_hunks.expand_all { + if editor.diff_map.expand_all { hunks_to_reexpand.push(HoveredHunk { status, multi_buffer_range, @@ -917,7 +1011,7 @@ impl Editor { retain }); - if editor.expanded_hunks.expand_all { + if editor.diff_map.expand_all { for hunk in recalculated_hunks { match diff_hunk_to_display(&hunk, &snapshot) { DisplayDiffHunk::Folded { .. } => {} @@ -935,6 +1029,8 @@ impl Editor { } } } + } else { + drop(recalculated_hunks); } editor.remove_highlighted_rows::(highlights_to_remove, cx); @@ -949,32 +1045,12 @@ impl Editor { .ok(); }); - self.expanded_hunks.hunk_update_tasks.insert( + diff_map.hunk_update_tasks.insert( Some(buffer_id), cx.background_executor().spawn(new_sync_task), ); } - fn current_diff_base_buffer( - &mut self, - buffer: &Model, - cx: &mut AppContext, - ) -> Option> { - buffer.update(cx, |buffer, _| { - match self.expanded_hunks.diff_base.entry(buffer.remote_id()) { - hash_map::Entry::Occupied(o) => { - if o.get().diff_base_version != buffer.diff_base_version() { - o.remove(); - None - } else { - Some(o.get().buffer.clone()) - } - } - hash_map::Entry::Vacant(_) => None, - } - }) - } - fn go_to_subsequent_hunk(&mut self, position: Anchor, cx: &mut ViewContext) { let snapshot = self.snapshot(cx); let position = position.to_point(&snapshot.buffer_snapshot); @@ -1021,7 +1097,7 @@ impl Editor { } } -fn to_diff_hunk( +pub(crate) fn to_diff_hunk( hovered_hunk: &HoveredHunk, multi_buffer_snapshot: &MultiBufferSnapshot, ) -> Option { @@ -1043,24 +1119,6 @@ fn to_diff_hunk( }) } -fn create_diff_base_buffer(buffer: &Model, cx: &mut AppContext) -> Option> { - buffer - .update(cx, |buffer, _| { - let language = buffer.language().cloned(); - let diff_base = buffer.diff_base()?.clone(); - Some((buffer.line_ending(), diff_base, language)) - }) - .map(|(line_ending, diff_base, language)| { - cx.new_model(|cx| { - let buffer = Buffer::local_normalized(diff_base, line_ending, cx); - match language { - Some(language) => buffer.with_language(language, cx), - None => buffer, - } - }) - }) -} - fn added_hunk_color(cx: &AppContext) -> Hsla { let mut created_color = cx.theme().status().git().created; created_color.fade_out(0.7); @@ -1118,51 +1176,27 @@ fn editor_with_deleted_text( }); })]); - let original_multi_buffer_range = hunk.multi_buffer_range.clone(); - let diff_base_range = hunk.diff_base_byte_range.clone(); editor .register_action::({ + let hunk = hunk.clone(); let parent_editor = parent_editor.clone(); move |_, cx| { parent_editor - .update(cx, |editor, cx| { - let Some((buffer, original_text)) = - editor.buffer().update(cx, |buffer, cx| { - let (_, buffer, _) = buffer.excerpt_containing( - original_multi_buffer_range.start, - cx, - )?; - let original_text = - buffer.read(cx).diff_base()?.slice(diff_base_range.clone()); - Some((buffer, Arc::from(original_text.to_string()))) - }) - else { - return; - }; - buffer.update(cx, |buffer, cx| { - buffer.edit( - Some(( - original_multi_buffer_range.start.text_anchor - ..original_multi_buffer_range.end.text_anchor, - original_text, - )), - None, - cx, - ) - }); - }) + .update(cx, |editor, cx| editor.revert_hunk(hunk.clone(), cx)) .ok(); } }) .detach(); - let hunk = hunk.clone(); editor - .register_action::(move |_, cx| { - parent_editor - .update(cx, |editor, cx| { - editor.toggle_hovered_hunk(&hunk, cx); - }) - .ok(); + .register_action::({ + let hunk = hunk.clone(); + move |_, cx| { + parent_editor + .update(cx, |editor, cx| { + editor.toggle_hovered_hunk(&hunk, cx); + }) + .ok(); + } }) .detach(); editor @@ -1272,78 +1306,57 @@ mod tests { 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_local_buffer( - " - 1.zero - 1.ONE - 1.TWO - 1.three - 1.FOUR - 1.FIVE - 1.six - " - .unindent() - .as_str(), - None, - cx, - ) - }); - 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, - ); - }); + let diff_base_1 = " + 1.zero + 1.one + 1.two + 1.three + 1.four + 1.five + 1.six + " + .unindent(); + + let text_1 = " + 1.zero + 1.ONE + 1.TWO + 1.three + 1.FOUR + 1.FIVE + 1.six + " + .unindent(); // buffer has a deletion hunk and an insertion hunk - let buffer_2 = project.update(cx, |project, cx| { - project.create_local_buffer( - " - 2.zero - 2.one - 2.two - 2.three - 2.four - 2.five - 2.six - " - .unindent() - .as_str(), - None, - cx, - ) - }); - 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, - ); - }); + let diff_base_2 = " + 2.zero + 2.one + 2.one-and-a-half + 2.two + 2.three + 2.four + 2.six + " + .unindent(); - cx.background_executor.run_until_parked(); + let text_2 = " + 2.zero + 2.one + 2.two + 2.three + 2.four + 2.five + 2.six + " + .unindent(); + + let buffer_1 = project.update(cx, |project, cx| { + project.create_local_buffer(text_1.as_str(), None, cx) + }); + let buffer_2 = project.update(cx, |project, cx| { + project.create_local_buffer(text_2.as_str(), None, cx) + }); let multibuffer = cx.new_model(|cx| { let mut multibuffer = MultiBuffer::new(ReadWrite); @@ -1392,10 +1405,30 @@ mod tests { multibuffer }); - let snapshot = multibuffer.read_with(cx, |b, cx| b.snapshot(cx)); + let editor = cx.add_window(|cx| Editor::for_multibuffer(multibuffer, None, false, cx)); + editor + .update(cx, |editor, cx| { + for (buffer, diff_base) in [ + (buffer_1.clone(), diff_base_1), + (buffer_2.clone(), diff_base_2), + ] { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + diff_base.to_string(), + buffer.read(cx).text_snapshot(), + cx, + ) + }); + editor.diff_map.add_change_set(change_set, cx) + } + }) + .unwrap(); + cx.background_executor.run_until_parked(); + + let snapshot = editor.update(cx, |editor, cx| editor.snapshot(cx)).unwrap(); assert_eq!( - snapshot.text(), + snapshot.buffer_snapshot.text(), " 1.zero 1.ONE @@ -1438,7 +1471,8 @@ mod tests { assert_eq!( snapshot - .git_diff_hunks_in_range(MultiBufferRow(0)..MultiBufferRow(12)) + .diff_map + .diff_hunks_in_range(Point::zero()..Point::new(12, 0), &snapshot.buffer_snapshot) .map(|hunk| (hunk_status(&hunk), hunk.row_range)) .collect::>(), &expected, @@ -1446,7 +1480,11 @@ mod tests { assert_eq!( snapshot - .git_diff_hunks_in_range_rev(MultiBufferRow(0)..MultiBufferRow(12)) + .diff_map + .diff_hunks_in_range_rev( + Point::zero()..Point::new(12, 0), + &snapshot.buffer_snapshot + ) .map(|hunk| (hunk_status(&hunk), hunk.row_range)) .collect::>(), expected diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 2f2eb493bb..298ef5a3f0 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -737,7 +737,7 @@ impl Item for Editor { let buffers = self.buffer().clone().read(cx).all_buffers(); let buffers = buffers .into_iter() - .map(|handle| handle.read(cx).diff_base_buffer().unwrap_or(handle.clone())) + .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone())) .collect::>(); cx.spawn(|this, mut cx| async move { if format { diff --git a/crates/editor/src/proposed_changes_editor.rs b/crates/editor/src/proposed_changes_editor.rs index ac97fe18da..f4934c32b0 100644 --- a/crates/editor/src/proposed_changes_editor.rs +++ b/crates/editor/src/proposed_changes_editor.rs @@ -4,7 +4,7 @@ use futures::{channel::mpsc, future::join_all}; use gpui::{AppContext, EventEmitter, FocusableView, Model, Render, Subscription, Task, View}; use language::{Buffer, BufferEvent, Capability}; use multi_buffer::{ExcerptRange, MultiBuffer}; -use project::Project; +use project::{buffer_store::BufferChangeSet, Project}; use smol::stream::StreamExt; use std::{any::TypeId, ops::Range, rc::Rc, time::Duration}; use text::ToOffset; @@ -75,7 +75,7 @@ impl ProposedChangesEditor { title: title.into(), buffer_entries: Vec::new(), recalculate_diffs_tx, - _recalculate_diffs_task: cx.spawn(|_, mut cx| async move { + _recalculate_diffs_task: cx.spawn(|this, mut cx| async move { let mut buffers_to_diff = HashSet::default(); while let Some(mut recalculate_diff) = recalculate_diffs_rx.next().await { buffers_to_diff.insert(recalculate_diff.buffer); @@ -96,12 +96,37 @@ impl ProposedChangesEditor { } } - join_all(buffers_to_diff.drain().filter_map(|buffer| { - buffer - .update(&mut cx, |buffer, cx| buffer.recalculate_diff(cx)) - .ok()? - })) - .await; + let recalculate_diff_futures = this + .update(&mut cx, |this, cx| { + buffers_to_diff + .drain() + .filter_map(|buffer| { + let buffer = buffer.read(cx); + let base_buffer = buffer.base_buffer()?; + let buffer = buffer.text_snapshot(); + let change_set = this.editor.update(cx, |editor, _| { + Some( + editor + .diff_map + .diff_bases + .get(&buffer.remote_id())? + .change_set + .clone(), + ) + })?; + Some(change_set.update(cx, |change_set, cx| { + change_set.set_base_text( + base_buffer.read(cx).text(), + buffer, + cx, + ) + })) + }) + .collect::>() + }) + .ok()?; + + join_all(recalculate_diff_futures).await; } None }), @@ -154,6 +179,7 @@ impl ProposedChangesEditor { }); let mut buffer_entries = Vec::new(); + let mut new_change_sets = Vec::new(); for location in locations { let branch_buffer; if let Some(ix) = self @@ -166,6 +192,15 @@ impl ProposedChangesEditor { buffer_entries.push(entry); } else { branch_buffer = location.buffer.update(cx, |buffer, cx| buffer.branch(cx)); + new_change_sets.push(cx.new_model(|cx| { + let mut change_set = BufferChangeSet::new(branch_buffer.read(cx)); + let _ = change_set.set_base_text( + location.buffer.read(cx).text(), + branch_buffer.read(cx).text_snapshot(), + cx, + ); + change_set + })); buffer_entries.push(BufferEntry { branch: branch_buffer.clone(), base: location.buffer.clone(), @@ -187,7 +222,10 @@ impl ProposedChangesEditor { self.buffer_entries = buffer_entries; self.editor.update(cx, |editor, cx| { - editor.change_selections(None, cx, |selections| selections.refresh()) + editor.change_selections(None, cx, |selections| selections.refresh()); + for change_set in new_change_sets { + editor.diff_map.add_change_set(change_set, cx) + } }); } @@ -217,14 +255,14 @@ impl ProposedChangesEditor { }) .ok(); } - BufferEvent::DiffBaseChanged => { - self.recalculate_diffs_tx - .unbounded_send(RecalculateDiff { - buffer, - debounce: false, - }) - .ok(); - } + // BufferEvent::DiffBaseChanged => { + // self.recalculate_diffs_tx + // .unbounded_send(RecalculateDiff { + // buffer, + // debounce: false, + // }) + // .ok(); + // } _ => (), } } @@ -373,7 +411,7 @@ impl BranchBufferSemanticsProvider { positions: &[text::Anchor], cx: &AppContext, ) -> Option> { - let base_buffer = buffer.read(cx).diff_base_buffer()?; + let base_buffer = buffer.read(cx).base_buffer()?; let version = base_buffer.read(cx).version(); if positions .iter() diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index b43d78bc99..fd890b839d 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -113,7 +113,15 @@ impl EditorLspTestContext { app_state .fs .as_fake() - .insert_tree(root, json!({ "dir": { file_name.clone(): "" }})) + .insert_tree( + root, + json!({ + ".git": {}, + "dir": { + file_name.clone(): "" + } + }), + ) .await; let window = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); diff --git a/crates/editor/src/test/editor_test_context.rs b/crates/editor/src/test/editor_test_context.rs index de5065d265..11b14e8122 100644 --- a/crates/editor/src/test/editor_test_context.rs +++ b/crates/editor/src/test/editor_test_context.rs @@ -42,16 +42,16 @@ pub struct EditorTestContext { impl EditorTestContext { pub async fn new(cx: &mut gpui::TestAppContext) -> EditorTestContext { let fs = FakeFs::new(cx.executor()); - // fs.insert_file("/file", "".to_owned()).await; let root = Self::root_path(); fs.insert_tree( root, serde_json::json!({ + ".git": {}, "file": "", }), ) .await; - let project = Project::test(fs, [root], cx).await; + let project = Project::test(fs.clone(), [root], cx).await; let buffer = project .update(cx, |project, cx| { project.open_local_buffer(root.join("file"), cx) @@ -65,6 +65,8 @@ impl EditorTestContext { editor }); let editor_view = editor.root_view(cx).unwrap(); + + cx.run_until_parked(); Self { cx: VisualTestContext::from_window(*editor.deref(), cx), window: editor.into(), @@ -276,8 +278,16 @@ impl EditorTestContext { snapshot.anchor_before(ranges[0].start)..snapshot.anchor_after(ranges[0].end) } - pub fn set_diff_base(&mut self, diff_base: Option<&str>) { - self.update_buffer(|buffer, cx| buffer.set_diff_base(diff_base.map(ToOwned::to_owned), cx)); + pub fn set_diff_base(&mut self, diff_base: &str) { + self.cx.run_until_parked(); + let fs = self + .update_editor(|editor, cx| editor.project.as_ref().unwrap().read(cx).fs().as_fake()); + let path = self.update_buffer(|buffer, _| buffer.file().unwrap().path().clone()); + fs.set_index_for_repo( + &Self::root_path().join(".git"), + &[(path.as_ref(), diff_base.to_string())], + ); + self.cx.run_until_parked(); } /// Change the editor's text and selections using a string containing @@ -319,10 +329,12 @@ impl EditorTestContext { state_context } + /// Assert about the text of the editor, the selections, and the expanded + /// diff hunks. + /// + /// Diff hunks are indicated by lines starting with `+` and `-`. #[track_caller] - pub fn assert_diff_hunks(&mut self, expected_diff: String) { - // Normalize the expected diff. If it has no diff markers, then insert blank markers - // before each line. Strip any whitespace-only lines. + pub fn assert_state_with_diff(&mut self, expected_diff: String) { let has_diff_markers = expected_diff .lines() .any(|line| line.starts_with("+") || line.starts_with("-")); @@ -340,11 +352,14 @@ impl EditorTestContext { }) .join("\n"); + let actual_selections = self.editor_selections(); + let actual_marked_text = + generate_marked_text(&self.buffer_text(), &actual_selections, true); + // Read the actual diff from the editor's row highlights and block // decorations. let actual_diff = self.editor.update(&mut self.cx, |editor, cx| { let snapshot = editor.snapshot(cx); - let text = editor.text(cx); let insertions = editor .highlighted_rows::() .map(|(range, _)| { @@ -354,7 +369,7 @@ impl EditorTestContext { }) .collect::>(); let deletions = editor - .expanded_hunks + .diff_map .hunks .iter() .filter_map(|hunk| { @@ -371,10 +386,20 @@ impl EditorTestContext { .read(cx) .excerpt_containing(hunk.hunk_range.start, cx) .expect("no excerpt for expanded buffer's hunk start"); - let deleted_text = buffer - .read(cx) - .diff_base() + let buffer_id = buffer.read(cx).remote_id(); + let change_set = &editor + .diff_map + .diff_bases + .get(&buffer_id) .expect("should have a diff base for expanded hunk") + .change_set; + let deleted_text = change_set + .read(cx) + .base_text + .as_ref() + .expect("no base text for expanded hunk") + .read(cx) + .as_rope() .slice(hunk.diff_base_byte_range.clone()) .to_string(); if let DiffHunkStatus::Modified | DiffHunkStatus::Removed = hunk.status { @@ -384,7 +409,7 @@ impl EditorTestContext { } }) .collect::>(); - format_diff(text, deletions, insertions) + format_diff(actual_marked_text, deletions, insertions) }); pretty_assertions::assert_eq!(actual_diff, expected_diff_text, "unexpected diff state"); diff --git a/crates/fs/src/fs.rs b/crates/fs/src/fs.rs index 37525db7d9..17571de76b 100644 --- a/crates/fs/src/fs.rs +++ b/crates/fs/src/fs.rs @@ -132,7 +132,7 @@ pub trait Fs: Send + Sync { async fn is_case_sensitive(&self) -> Result; #[cfg(any(test, feature = "test-support"))] - fn as_fake(&self) -> &FakeFs { + fn as_fake(&self) -> Arc { panic!("called as_fake on a real fs"); } } @@ -840,6 +840,7 @@ impl Watcher for RealWatcher { #[cfg(any(test, feature = "test-support"))] pub struct FakeFs { + this: std::sync::Weak, // Use an unfair lock to ensure tests are deterministic. state: Mutex, executor: gpui::BackgroundExecutor, @@ -1022,7 +1023,8 @@ impl FakeFs { pub fn new(executor: gpui::BackgroundExecutor) -> Arc { let (tx, mut rx) = smol::channel::bounded::(10); - let this = Arc::new(Self { + let this = Arc::new_cyclic(|this| Self { + this: this.clone(), executor: executor.clone(), state: Mutex::new(FakeFsState { root: Arc::new(Mutex::new(FakeFsEntry::Dir { @@ -1474,7 +1476,8 @@ struct FakeHandle { #[cfg(any(test, feature = "test-support"))] impl FileHandle for FakeHandle { fn current_path(&self, fs: &Arc) -> Result { - let state = fs.as_fake().state.lock(); + let fs = fs.as_fake(); + let state = fs.state.lock(); let Some(target) = state.moves.get(&self.inode) else { anyhow::bail!("fake fd not moved") }; @@ -1970,8 +1973,8 @@ impl Fs for FakeFs { } #[cfg(any(test, feature = "test-support"))] - fn as_fake(&self) -> &FakeFs { - self + fn as_fake(&self) -> Arc { + self.this.upgrade().unwrap() } } diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index 8723e41ce4..c0f43e08a8 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -14,7 +14,6 @@ path = "src/git.rs" [dependencies] anyhow.workspace = true async-trait.workspace = true -clock.workspace = true collections.workspace = true derive_more.workspace = true git2.workspace = true diff --git a/crates/git/src/diff.rs b/crates/git/src/diff.rs index 23e9388a28..d468603663 100644 --- a/crates/git/src/diff.rs +++ b/crates/git/src/diff.rs @@ -64,18 +64,33 @@ impl sum_tree::Summary for DiffHunkSummary { #[derive(Debug, Clone)] pub struct BufferDiff { - last_buffer_version: Option, tree: SumTree, } impl BufferDiff { pub fn new(buffer: &BufferSnapshot) -> BufferDiff { BufferDiff { - last_buffer_version: None, tree: SumTree::new(buffer), } } + pub async fn build(diff_base: &str, buffer: &text::BufferSnapshot) -> Self { + let mut tree = SumTree::new(buffer); + + let buffer_text = buffer.as_rope().to_string(); + let patch = Self::diff(diff_base, &buffer_text); + + if let Some(patch) = patch { + let mut divergence = 0; + for hunk_index in 0..patch.num_hunks() { + let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence); + tree.push(hunk, buffer); + } + } + + Self { tree } + } + pub fn is_empty(&self) -> bool { self.tree.is_empty() } @@ -168,27 +183,11 @@ impl BufferDiff { #[cfg(test)] fn clear(&mut self, buffer: &text::BufferSnapshot) { - self.last_buffer_version = Some(buffer.version().clone()); self.tree = SumTree::new(buffer); } pub async fn update(&mut self, diff_base: &Rope, buffer: &text::BufferSnapshot) { - let mut tree = SumTree::new(buffer); - - let diff_base_text = diff_base.to_string(); - let buffer_text = buffer.as_rope().to_string(); - let patch = Self::diff(&diff_base_text, &buffer_text); - - if let Some(patch) = patch { - let mut divergence = 0; - for hunk_index in 0..patch.num_hunks() { - let hunk = Self::process_patch_hunk(&patch, hunk_index, buffer, &mut divergence); - tree.push(hunk, buffer); - } - } - - self.tree = tree; - self.last_buffer_version = Some(buffer.version().clone()); + *self = Self::build(&diff_base.to_string(), buffer).await; } #[cfg(test)] diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index 8b97d4a95f..d3cb1cfda2 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -34,7 +34,6 @@ ec4rs.workspace = true fs.workspace = true futures.workspace = true fuzzy.workspace = true -git.workspace = true globset.workspace = true gpui.workspace = true http_client.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index e39d4523d7..833a71c899 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -90,22 +90,11 @@ pub enum Capability { pub type BufferRow = u32; -#[derive(Clone)] -enum BufferDiffBase { - Git(Rope), - PastBufferVersion { - buffer: Model, - rope: Rope, - merged_operations: Vec, - }, -} - /// An in-memory representation of a source code file, including its text, /// syntax trees, git status, and diagnostics. pub struct Buffer { text: TextBuffer, - diff_base: Option, - git_diff: git::diff::BufferDiff, + branch_state: Option, /// Filesystem state, `None` when there is no path. file: Option>, /// The mtime of the file when this buffer was last loaded from @@ -135,7 +124,6 @@ pub struct Buffer { deferred_ops: OperationQueue, capability: Capability, has_conflict: bool, - diff_base_version: usize, /// Memoize calls to has_changes_since(saved_version). /// The contents of a cell are (self.version, has_changes) at the time of a last call. has_unsaved_edits: Cell<(clock::Global, bool)>, @@ -148,11 +136,15 @@ pub enum ParseStatus { Parsing, } +struct BufferBranchState { + base_buffer: Model, + merged_operations: Vec, +} + /// An immutable, cheaply cloneable representation of a fixed /// state of a buffer. pub struct BufferSnapshot { text: text::BufferSnapshot, - git_diff: git::diff::BufferDiff, pub(crate) syntax: SyntaxSnapshot, file: Option>, diagnostics: SmallVec<[(LanguageServerId, DiagnosticSet); 2]>, @@ -345,10 +337,6 @@ pub enum BufferEvent { Reloaded, /// The buffer is in need of a reload ReloadNeeded, - /// The buffer's diff_base changed. - DiffBaseChanged, - /// Buffer's excerpts for a certain diff base were recalculated. - DiffUpdated, /// The buffer's language was changed. LanguageChanged, /// The buffer's syntax trees were updated. @@ -626,7 +614,6 @@ impl Buffer { Self::build( TextBuffer::new(0, cx.entity_id().as_non_zero_u64().into(), base_text.into()), None, - None, Capability::ReadWrite, ) } @@ -645,7 +632,6 @@ impl Buffer { base_text_normalized, ), None, - None, Capability::ReadWrite, ) } @@ -660,7 +646,6 @@ impl Buffer { Self::build( TextBuffer::new(replica_id, remote_id, base_text.into()), None, - None, capability, ) } @@ -676,7 +661,7 @@ impl Buffer { let buffer_id = BufferId::new(message.id) .with_context(|| anyhow!("Could not deserialize buffer_id"))?; let buffer = TextBuffer::new(replica_id, buffer_id, message.base_text); - let mut this = Self::build(buffer, message.diff_base, file, capability); + let mut this = Self::build(buffer, file, capability); this.text.set_line_ending(proto::deserialize_line_ending( rpc::proto::LineEnding::from_i32(message.line_ending) .ok_or_else(|| anyhow!("missing line_ending"))?, @@ -692,7 +677,6 @@ impl Buffer { id: self.remote_id().into(), file: self.file.as_ref().map(|f| f.to_proto(cx)), base_text: self.base_text().to_string(), - diff_base: self.diff_base().as_ref().map(|h| h.to_string()), line_ending: proto::serialize_line_ending(self.line_ending()) as i32, saved_version: proto::serialize_version(&self.saved_version), saved_mtime: self.saved_mtime.map(|time| time.into()), @@ -766,15 +750,9 @@ impl Buffer { } /// Builds a [`Buffer`] with the given underlying [`TextBuffer`], diff base, [`File`] and [`Capability`]. - pub fn build( - buffer: TextBuffer, - diff_base: Option, - file: Option>, - capability: Capability, - ) -> Self { + pub fn build(buffer: TextBuffer, file: Option>, capability: Capability) -> Self { let saved_mtime = file.as_ref().and_then(|file| file.disk_state().mtime()); let snapshot = buffer.snapshot(); - let git_diff = git::diff::BufferDiff::new(&snapshot); let syntax_map = Mutex::new(SyntaxMap::new(&snapshot)); Self { saved_mtime, @@ -785,12 +763,7 @@ impl Buffer { was_dirty_before_starting_transaction: None, has_unsaved_edits: Cell::new((buffer.version(), false)), text: buffer, - diff_base: diff_base.map(|mut raw_diff_base| { - LineEnding::normalize(&mut raw_diff_base); - BufferDiffBase::Git(Rope::from(raw_diff_base)) - }), - diff_base_version: 0, - git_diff, + branch_state: None, file, capability, syntax_map, @@ -824,7 +797,6 @@ impl Buffer { BufferSnapshot { text, syntax, - git_diff: self.git_diff.clone(), file: self.file.clone(), remote_selections: self.remote_selections.clone(), diagnostics: self.diagnostics.clone(), @@ -837,21 +809,15 @@ impl Buffer { let this = cx.handle(); cx.new_model(|cx| { let mut branch = Self { - diff_base: Some(BufferDiffBase::PastBufferVersion { - buffer: this.clone(), - rope: self.as_rope().clone(), + branch_state: Some(BufferBranchState { + base_buffer: this.clone(), merged_operations: Default::default(), }), language: self.language.clone(), has_conflict: self.has_conflict, has_unsaved_edits: Cell::new(self.has_unsaved_edits.get_mut().clone()), _subscriptions: vec![cx.subscribe(&this, Self::on_base_buffer_event)], - ..Self::build( - self.text.branch(), - None, - self.file.clone(), - self.capability(), - ) + ..Self::build(self.text.branch(), self.file.clone(), self.capability()) }; if let Some(language_registry) = self.language_registry() { branch.set_language_registry(language_registry); @@ -870,7 +836,7 @@ impl Buffer { /// If `ranges` is empty, then all changes will be applied. This buffer must /// be a branch buffer to call this method. pub fn merge_into_base(&mut self, ranges: Vec>, cx: &mut ModelContext) { - let Some(base_buffer) = self.diff_base_buffer() else { + let Some(base_buffer) = self.base_buffer() else { debug_panic!("not a branch buffer"); return; }; @@ -906,14 +872,14 @@ impl Buffer { } let operation = base_buffer.update(cx, |base_buffer, cx| { - cx.emit(BufferEvent::DiffBaseChanged); + // cx.emit(BufferEvent::DiffBaseChanged); base_buffer.edit(edits, None, cx) }); if let Some(operation) = operation { - if let Some(BufferDiffBase::PastBufferVersion { + if let Some(BufferBranchState { merged_operations, .. - }) = &mut self.diff_base + }) = &mut self.branch_state { merged_operations.push(operation); } @@ -929,9 +895,9 @@ impl Buffer { let BufferEvent::Operation { operation, .. } = event else { return; }; - let Some(BufferDiffBase::PastBufferVersion { + let Some(BufferBranchState { merged_operations, .. - }) = &mut self.diff_base + }) = &mut self.branch_state else { return; }; @@ -950,8 +916,6 @@ impl Buffer { let counts = [(timestamp, u32::MAX)].into_iter().collect(); self.undo_operations(counts, cx); } - - self.diff_base_version += 1; } #[cfg(test)] @@ -1123,74 +1087,8 @@ impl Buffer { } } - /// Returns the current diff base, see [`Buffer::set_diff_base`]. - pub fn diff_base(&self) -> Option<&Rope> { - match self.diff_base.as_ref()? { - BufferDiffBase::Git(rope) | BufferDiffBase::PastBufferVersion { rope, .. } => { - Some(rope) - } - } - } - - /// Sets the text that will be used to compute a Git diff - /// against the buffer text. - pub fn set_diff_base(&mut self, diff_base: Option, cx: &ModelContext) { - self.diff_base = diff_base.map(|mut raw_diff_base| { - LineEnding::normalize(&mut raw_diff_base); - BufferDiffBase::Git(Rope::from(raw_diff_base)) - }); - self.diff_base_version += 1; - if let Some(recalc_task) = self.recalculate_diff(cx) { - cx.spawn(|buffer, mut cx| async move { - recalc_task.await; - buffer - .update(&mut cx, |_, cx| { - cx.emit(BufferEvent::DiffBaseChanged); - }) - .ok(); - }) - .detach(); - } - } - - /// Returns a number, unique per diff base set to the buffer. - pub fn diff_base_version(&self) -> usize { - self.diff_base_version - } - - pub fn diff_base_buffer(&self) -> Option> { - match self.diff_base.as_ref()? { - BufferDiffBase::Git(_) => None, - BufferDiffBase::PastBufferVersion { buffer, .. } => Some(buffer.clone()), - } - } - - /// Recomputes the diff. - pub fn recalculate_diff(&self, cx: &ModelContext) -> Option> { - let diff_base_rope = match self.diff_base.as_ref()? { - BufferDiffBase::Git(rope) => rope.clone(), - BufferDiffBase::PastBufferVersion { buffer, .. } => buffer.read(cx).as_rope().clone(), - }; - - let snapshot = self.snapshot(); - let mut diff = self.git_diff.clone(); - let diff = cx.background_executor().spawn(async move { - diff.update(&diff_base_rope, &snapshot).await; - (diff, diff_base_rope) - }); - - Some(cx.spawn(|this, mut cx| async move { - let (buffer_diff, diff_base_rope) = diff.await; - this.update(&mut cx, |this, cx| { - this.git_diff = buffer_diff; - this.non_text_state_update_count += 1; - if let Some(BufferDiffBase::PastBufferVersion { rope, .. }) = &mut this.diff_base { - *rope = diff_base_rope; - } - cx.emit(BufferEvent::DiffUpdated); - }) - .ok(); - })) + pub fn base_buffer(&self) -> Option> { + Some(self.branch_state.as_ref()?.base_buffer.clone()) } /// Returns the primary [`Language`] assigned to this [`Buffer`]. @@ -3992,37 +3890,6 @@ impl BufferSnapshot { }) } - /// Whether the buffer contains any Git changes. - pub fn has_git_diff(&self) -> bool { - !self.git_diff.is_empty() - } - - /// Returns all the Git diff hunks intersecting the given row range. - pub fn git_diff_hunks_in_row_range( - &self, - range: Range, - ) -> impl '_ + Iterator { - self.git_diff.hunks_in_row_range(range, self) - } - - /// Returns all the Git diff hunks intersecting the given - /// range. - pub fn git_diff_hunks_intersecting_range( - &self, - range: Range, - ) -> impl '_ + Iterator { - self.git_diff.hunks_intersecting_range(range, self) - } - - /// Returns all the Git diff hunks intersecting the given - /// range, in reverse order. - pub fn git_diff_hunks_intersecting_range_rev( - &self, - range: Range, - ) -> impl '_ + Iterator { - self.git_diff.hunks_intersecting_range_rev(range, self) - } - /// Returns if the buffer contains any diagnostics. pub fn has_diagnostics(&self) -> bool { !self.diagnostics.is_empty() @@ -4167,7 +4034,6 @@ impl Clone for BufferSnapshot { fn clone(&self) -> Self { Self { text: self.text.clone(), - git_diff: self.git_diff.clone(), syntax: self.syntax.clone(), file: self.file.clone(), remote_selections: self.remote_selections.clone(), diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 3eab3aaed7..a1d1a57f13 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -6,7 +6,6 @@ use crate::Buffer; use clock::ReplicaId; use collections::BTreeMap; use futures::FutureExt as _; -use git::diff::assert_hunks; use gpui::{AppContext, BorrowAppContext, Model}; use gpui::{Context, TestAppContext}; use indoc::indoc; @@ -2608,15 +2607,6 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { ); }); - // The branch buffer maintains a diff with respect to its base buffer. - start_recalculating_diff(&branch, cx); - cx.run_until_parked(); - assert_diff_hunks( - &branch, - cx, - &[(1..2, "", "1.5\n"), (3..4, "three\n", "THREE\n")], - ); - // Edits to the base are applied to the branch. base.update(cx, |buffer, cx| { buffer.edit([(Point::new(0, 0)..Point::new(0, 0), "ZERO\n")], None, cx) @@ -2626,21 +2616,6 @@ fn test_branch_and_merge(cx: &mut TestAppContext) { assert_eq!(buffer.text(), "ZERO\none\n1.5\ntwo\nTHREE\n"); }); - // Until the git diff recalculation is complete, the git diff references - // the previous content of the base buffer, so that it stays in sync. - start_recalculating_diff(&branch, cx); - assert_diff_hunks( - &branch, - cx, - &[(2..3, "", "1.5\n"), (4..5, "three\n", "THREE\n")], - ); - cx.run_until_parked(); - assert_diff_hunks( - &branch, - cx, - &[(2..3, "", "1.5\n"), (4..5, "three\n", "THREE\n")], - ); - // Edits to any replica of the base are applied to the branch. base_replica.update(cx, |buffer, cx| { buffer.edit([(Point::new(2, 0)..Point::new(2, 0), "2.5\n")], None, cx) @@ -2731,29 +2706,6 @@ fn test_undo_after_merge_into_base(cx: &mut TestAppContext) { branch.read_with(cx, |branch, _| assert_eq!(branch.text(), "ABCdefgHIjk")); } -fn start_recalculating_diff(buffer: &Model, cx: &mut TestAppContext) { - buffer - .update(cx, |buffer, cx| buffer.recalculate_diff(cx).unwrap()) - .detach(); -} - -#[track_caller] -fn assert_diff_hunks( - buffer: &Model, - cx: &mut TestAppContext, - expected_hunks: &[(Range, &str, &str)], -) { - let (snapshot, diff_base) = buffer.read_with(cx, |buffer, _| { - (buffer.snapshot(), buffer.diff_base().unwrap().to_string()) - }); - assert_hunks( - snapshot.git_diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX), - &snapshot, - &diff_base, - expected_hunks, - ); -} - #[gpui::test(iterations = 100)] fn test_random_collaboration(cx: &mut AppContext, mut rng: StdRng) { let min_peers = env::var("MIN_PEERS") diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index d52d65bca2..60b01bc65f 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -95,10 +95,7 @@ pub enum Event { }, Reloaded, ReloadNeeded, - DiffBaseChanged, - DiffUpdated { - buffer: Model, - }, + LanguageChanged(BufferId), CapabilityChanged, Reparsed(BufferId), @@ -257,6 +254,7 @@ struct Excerpt { pub struct MultiBufferExcerpt<'a> { excerpt: &'a Excerpt, excerpt_offset: usize, + excerpt_position: Point, } #[derive(Clone, Debug)] @@ -1824,8 +1822,6 @@ impl MultiBuffer { language::BufferEvent::FileHandleChanged => Event::FileHandleChanged, language::BufferEvent::Reloaded => Event::Reloaded, language::BufferEvent::ReloadNeeded => Event::ReloadNeeded, - language::BufferEvent::DiffBaseChanged => Event::DiffBaseChanged, - language::BufferEvent::DiffUpdated => Event::DiffUpdated { buffer }, language::BufferEvent::LanguageChanged => { Event::LanguageChanged(buffer.read(cx).remote_id()) } @@ -3424,47 +3420,86 @@ impl MultiBufferSnapshot { .map(|excerpt| (excerpt.id, &excerpt.buffer, excerpt.range.clone())) } - fn excerpts_for_range( + pub fn all_excerpts(&self) -> impl Iterator { + let mut cursor = self.excerpts.cursor::<(usize, Point)>(&()); + cursor.next(&()); + std::iter::from_fn(move || { + let excerpt = cursor.item()?; + let excerpt = MultiBufferExcerpt::new(excerpt, *cursor.start()); + cursor.next(&()); + Some(excerpt) + }) + } + + pub fn excerpts_for_range( &self, range: Range, - ) -> impl Iterator + '_ { + ) -> impl Iterator + '_ { let range = range.start.to_offset(self)..range.end.to_offset(self); - let mut cursor = self.excerpts.cursor::(&()); + let mut cursor = self.excerpts.cursor::<(usize, Point)>(&()); cursor.seek(&range.start, Bias::Right, &()); cursor.prev(&()); iter::from_fn(move || { cursor.next(&()); - if cursor.start() < &range.end { - cursor.item().map(|item| (item, *cursor.start())) + if cursor.start().0 < range.end { + cursor + .item() + .map(|item| MultiBufferExcerpt::new(item, *cursor.start())) } else { None } }) } + pub fn excerpts_for_range_rev( + &self, + range: Range, + ) -> impl Iterator + '_ { + let range = range.start.to_offset(self)..range.end.to_offset(self); + + let mut cursor = self.excerpts.cursor::<(usize, Point)>(&()); + cursor.seek(&range.end, Bias::Left, &()); + if cursor.item().is_none() { + cursor.prev(&()); + } + + std::iter::from_fn(move || { + let excerpt = cursor.item()?; + let excerpt = MultiBufferExcerpt::new(excerpt, *cursor.start()); + cursor.prev(&()); + Some(excerpt) + }) + } + pub fn excerpt_before(&self, id: ExcerptId) -> Option> { let start_locator = self.excerpt_locator_for_id(id); - let mut cursor = self.excerpts.cursor::>(&()); - cursor.seek(&Some(start_locator), Bias::Left, &()); + let mut cursor = self.excerpts.cursor::(&()); + cursor.seek(start_locator, Bias::Left, &()); cursor.prev(&()); let excerpt = cursor.item()?; + let excerpt_offset = cursor.start().text.len; + let excerpt_position = cursor.start().text.lines; Some(MultiBufferExcerpt { excerpt, - excerpt_offset: 0, + excerpt_offset, + excerpt_position, }) } pub fn excerpt_after(&self, id: ExcerptId) -> Option> { let start_locator = self.excerpt_locator_for_id(id); - let mut cursor = self.excerpts.cursor::>(&()); - cursor.seek(&Some(start_locator), Bias::Left, &()); + let mut cursor = self.excerpts.cursor::(&()); + cursor.seek(start_locator, Bias::Left, &()); cursor.next(&()); let excerpt = cursor.item()?; + let excerpt_offset = cursor.start().text.len; + let excerpt_position = cursor.start().text.lines; Some(MultiBufferExcerpt { excerpt, - excerpt_offset: 0, + excerpt_offset, + excerpt_position, }) } @@ -3647,22 +3682,12 @@ impl MultiBufferSnapshot { ) -> impl Iterator> + 'a { let range = range.start.to_offset(self)..range.end.to_offset(self); self.excerpts_for_range(range.clone()) - .filter(move |&(excerpt, _)| redaction_enabled(excerpt.buffer.file())) - .flat_map(move |(excerpt, excerpt_offset)| { - let excerpt_buffer_start = excerpt.range.context.start.to_offset(&excerpt.buffer); - + .filter(move |excerpt| redaction_enabled(excerpt.buffer().file())) + .flat_map(move |excerpt| { excerpt - .buffer - .redacted_ranges(excerpt.range.context.clone()) - .map(move |mut redacted_range| { - // Re-base onto the excerpts coordinates in the multibuffer - redacted_range.start = excerpt_offset - + redacted_range.start.saturating_sub(excerpt_buffer_start); - redacted_range.end = excerpt_offset - + redacted_range.end.saturating_sub(excerpt_buffer_start); - - redacted_range - }) + .buffer() + .redacted_ranges(excerpt.buffer_range().clone()) + .map(move |redacted_range| excerpt.map_range_from_buffer(redacted_range)) .skip_while(move |redacted_range| redacted_range.end < range.start) .take_while(move |redacted_range| redacted_range.start < range.end) }) @@ -3674,12 +3699,13 @@ impl MultiBufferSnapshot { ) -> impl Iterator + '_ { let range = range.start.to_offset(self)..range.end.to_offset(self); self.excerpts_for_range(range.clone()) - .flat_map(move |(excerpt, excerpt_offset)| { - let excerpt_buffer_start = excerpt.range.context.start.to_offset(&excerpt.buffer); + .flat_map(move |excerpt| { + let excerpt_buffer_start = + excerpt.buffer_range().start.to_offset(&excerpt.buffer()); excerpt - .buffer - .runnable_ranges(excerpt.range.context.clone()) + .buffer() + .runnable_ranges(excerpt.buffer_range()) .filter_map(move |mut runnable| { // Re-base onto the excerpts coordinates in the multibuffer // @@ -3688,15 +3714,14 @@ impl MultiBufferSnapshot { if runnable.run_range.start < excerpt_buffer_start { return None; } - if language::ToPoint::to_point(&runnable.run_range.end, &excerpt.buffer).row - > excerpt.max_buffer_row + if language::ToPoint::to_point(&runnable.run_range.end, &excerpt.buffer()) + .row + > excerpt.max_buffer_row() { return None; } - runnable.run_range.start = - excerpt_offset + runnable.run_range.start - excerpt_buffer_start; - runnable.run_range.end = - excerpt_offset + runnable.run_range.end - excerpt_buffer_start; + runnable.run_range = excerpt.map_range_from_buffer(runnable.run_range); + Some(runnable) }) .skip_while(move |runnable| runnable.run_range.end < range.start) @@ -3730,15 +3755,15 @@ impl MultiBufferSnapshot { let range = range.start.to_offset(self)..range.end.to_offset(self); self.excerpts_for_range(range.clone()) - .flat_map(move |(excerpt, excerpt_offset)| { + .flat_map(move |excerpt| { let excerpt_buffer_start_row = - excerpt.range.context.start.to_point(&excerpt.buffer).row; - let excerpt_offset_row = crate::ToPoint::to_point(&excerpt_offset, self).row; + excerpt.buffer_range().start.to_point(&excerpt.buffer()).row; + let excerpt_offset_row = excerpt.start_point().row; excerpt - .buffer + .buffer() .indent_guides_in_range( - excerpt.range.context.clone(), + excerpt.buffer_range(), ignore_disabled_for_language, cx, ) @@ -3856,151 +3881,6 @@ impl MultiBufferSnapshot { }) } - pub fn has_git_diffs(&self) -> bool { - for excerpt in self.excerpts.iter() { - if excerpt.buffer.has_git_diff() { - return true; - } - } - false - } - - pub fn git_diff_hunks_in_range_rev( - &self, - row_range: Range, - ) -> impl Iterator + '_ { - let mut cursor = self.excerpts.cursor::(&()); - - cursor.seek(&Point::new(row_range.end.0, 0), Bias::Left, &()); - if cursor.item().is_none() { - cursor.prev(&()); - } - - std::iter::from_fn(move || { - let excerpt = cursor.item()?; - let multibuffer_start = *cursor.start(); - let multibuffer_end = multibuffer_start + excerpt.text_summary.lines; - if multibuffer_start.row >= row_range.end.0 { - return None; - } - - let mut buffer_start = excerpt.range.context.start; - let mut buffer_end = excerpt.range.context.end; - let excerpt_start_point = buffer_start.to_point(&excerpt.buffer); - let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines; - - if row_range.start.0 > multibuffer_start.row { - let buffer_start_point = - excerpt_start_point + Point::new(row_range.start.0 - multibuffer_start.row, 0); - buffer_start = excerpt.buffer.anchor_before(buffer_start_point); - } - - if row_range.end.0 < multibuffer_end.row { - let buffer_end_point = - excerpt_start_point + Point::new(row_range.end.0 - multibuffer_start.row, 0); - buffer_end = excerpt.buffer.anchor_before(buffer_end_point); - } - - let buffer_hunks = excerpt - .buffer - .git_diff_hunks_intersecting_range_rev(buffer_start..buffer_end) - .map(move |hunk| { - let start = multibuffer_start.row - + hunk.row_range.start.saturating_sub(excerpt_start_point.row); - let end = multibuffer_start.row - + hunk - .row_range - .end - .min(excerpt_end_point.row + 1) - .saturating_sub(excerpt_start_point.row); - - MultiBufferDiffHunk { - row_range: MultiBufferRow(start)..MultiBufferRow(end), - diff_base_byte_range: hunk.diff_base_byte_range.clone(), - buffer_range: hunk.buffer_range.clone(), - buffer_id: excerpt.buffer_id, - } - }); - - cursor.prev(&()); - - Some(buffer_hunks) - }) - .flatten() - } - - pub fn git_diff_hunks_in_range( - &self, - row_range: Range, - ) -> impl Iterator + '_ { - let mut cursor = self.excerpts.cursor::(&()); - - cursor.seek(&Point::new(row_range.start.0, 0), Bias::Left, &()); - - std::iter::from_fn(move || { - let excerpt = cursor.item()?; - let multibuffer_start = *cursor.start(); - let multibuffer_end = multibuffer_start + excerpt.text_summary.lines; - let mut buffer_start = excerpt.range.context.start; - let mut buffer_end = excerpt.range.context.end; - - let excerpt_rows = match multibuffer_start.row.cmp(&row_range.end.0) { - cmp::Ordering::Less => { - let excerpt_start_point = buffer_start.to_point(&excerpt.buffer); - let excerpt_end_point = excerpt_start_point + excerpt.text_summary.lines; - - if row_range.start.0 > multibuffer_start.row { - let buffer_start_point = excerpt_start_point - + Point::new(row_range.start.0 - multibuffer_start.row, 0); - buffer_start = excerpt.buffer.anchor_before(buffer_start_point); - } - - if row_range.end.0 < multibuffer_end.row { - let buffer_end_point = excerpt_start_point - + Point::new(row_range.end.0 - multibuffer_start.row, 0); - buffer_end = excerpt.buffer.anchor_before(buffer_end_point); - } - excerpt_start_point.row..excerpt_end_point.row - } - cmp::Ordering::Equal if row_range.end.0 == 0 => { - buffer_end = buffer_start; - 0..0 - } - cmp::Ordering::Greater | cmp::Ordering::Equal => return None, - }; - - let buffer_hunks = excerpt - .buffer - .git_diff_hunks_intersecting_range(buffer_start..buffer_end) - .map(move |hunk| { - let buffer_range = if excerpt_rows.start == 0 && excerpt_rows.end == 0 { - MultiBufferRow(0)..MultiBufferRow(1) - } else { - let start = multibuffer_start.row - + hunk.row_range.start.saturating_sub(excerpt_rows.start); - let end = multibuffer_start.row - + hunk - .row_range - .end - .min(excerpt_rows.end + 1) - .saturating_sub(excerpt_rows.start); - MultiBufferRow(start)..MultiBufferRow(end) - }; - MultiBufferDiffHunk { - row_range: buffer_range, - diff_base_byte_range: hunk.diff_base_byte_range.clone(), - buffer_range: hunk.buffer_range.clone(), - buffer_id: excerpt.buffer_id, - } - }); - - cursor.next(&()); - - Some(buffer_hunks) - }) - .flatten() - } - pub fn range_for_syntax_ancestor(&self, range: Range) -> Option> { let range = range.start.to_offset(self)..range.end.to_offset(self); let excerpt = self.excerpt_containing(range.clone())?; @@ -4179,7 +4059,7 @@ impl MultiBufferSnapshot { pub fn excerpt_containing(&self, range: Range) -> Option { let range = range.start.to_offset(self)..range.end.to_offset(self); - let mut cursor = self.excerpts.cursor::(&()); + let mut cursor = self.excerpts.cursor::<(usize, Point)>(&()); cursor.seek(&range.start, Bias::Right, &()); let start_excerpt = cursor.item()?; @@ -4204,12 +4084,12 @@ impl MultiBufferSnapshot { I: IntoIterator> + 'a, { let mut ranges = ranges.into_iter().map(|range| range.to_offset(self)); - let mut cursor = self.excerpts.cursor::(&()); + let mut cursor = self.excerpts.cursor::<(usize, Point)>(&()); cursor.next(&()); let mut current_range = ranges.next(); iter::from_fn(move || { let range = current_range.clone()?; - if range.start >= cursor.end(&()) { + if range.start >= cursor.end(&()).0 { cursor.seek_forward(&range.start, Bias::Right, &()); if range.start == self.len() { cursor.prev(&()); @@ -4217,11 +4097,11 @@ impl MultiBufferSnapshot { } let excerpt = cursor.item()?; - let range_start_in_excerpt = cmp::max(range.start, *cursor.start()); + let range_start_in_excerpt = cmp::max(range.start, cursor.start().0); let range_end_in_excerpt = if excerpt.has_trailing_newline { - cmp::min(range.end, cursor.end(&()) - 1) + cmp::min(range.end, cursor.end(&()).0 - 1) } else { - cmp::min(range.end, cursor.end(&())) + cmp::min(range.end, cursor.end(&()).0) }; let buffer_range = MultiBufferExcerpt::new(excerpt, *cursor.start()) .map_range_to_buffer(range_start_in_excerpt..range_end_in_excerpt); @@ -4237,7 +4117,7 @@ impl MultiBufferSnapshot { text_anchor: excerpt.buffer.anchor_after(buffer_range.end), }; - if range.end > cursor.end(&()) { + if range.end > cursor.end(&()).0 { cursor.next(&()); } else { current_range = ranges.next(); @@ -4256,12 +4136,12 @@ impl MultiBufferSnapshot { ranges: impl IntoIterator>, ) -> impl Iterator)> { let mut ranges = ranges.into_iter().map(|range| range.to_offset(self)); - let mut cursor = self.excerpts.cursor::(&()); + let mut cursor = self.excerpts.cursor::<(usize, Point)>(&()); cursor.next(&()); let mut current_range = ranges.next(); iter::from_fn(move || { let range = current_range.clone()?; - if range.start >= cursor.end(&()) { + if range.start >= cursor.end(&()).0 { cursor.seek_forward(&range.start, Bias::Right, &()); if range.start == self.len() { cursor.prev(&()); @@ -4269,16 +4149,16 @@ impl MultiBufferSnapshot { } let excerpt = cursor.item()?; - let range_start_in_excerpt = cmp::max(range.start, *cursor.start()); + let range_start_in_excerpt = cmp::max(range.start, cursor.start().0); let range_end_in_excerpt = if excerpt.has_trailing_newline { - cmp::min(range.end, cursor.end(&()) - 1) + cmp::min(range.end, cursor.end(&()).0 - 1) } else { - cmp::min(range.end, cursor.end(&())) + cmp::min(range.end, cursor.end(&()).0) }; let buffer_range = MultiBufferExcerpt::new(excerpt, *cursor.start()) .map_range_to_buffer(range_start_in_excerpt..range_end_in_excerpt); - if range.end > cursor.end(&()) { + if range.end > cursor.end(&()).0 { cursor.next(&()); } else { current_range = ranges.next(); @@ -4702,6 +4582,11 @@ impl Excerpt { self.range.context.start.to_offset(&self.buffer) } + /// The [`Excerpt`]'s start point in its [`Buffer`] + fn buffer_start_point(&self) -> Point { + self.range.context.start.to_point(&self.buffer) + } + /// The [`Excerpt`]'s end offset in its [`Buffer`] fn buffer_end_offset(&self) -> usize { self.buffer_start_offset() + self.text_summary.len @@ -4709,10 +4594,11 @@ impl Excerpt { } impl<'a> MultiBufferExcerpt<'a> { - fn new(excerpt: &'a Excerpt, excerpt_offset: usize) -> Self { + fn new(excerpt: &'a Excerpt, (excerpt_offset, excerpt_position): (usize, Point)) -> Self { MultiBufferExcerpt { excerpt, excerpt_offset, + excerpt_position, } } @@ -4740,9 +4626,32 @@ impl<'a> MultiBufferExcerpt<'a> { &self.excerpt.buffer } + pub fn buffer_range(&self) -> Range { + self.excerpt.range.context.clone() + } + + pub fn start_offset(&self) -> usize { + self.excerpt_offset + } + + pub fn start_point(&self) -> Point { + self.excerpt_position + } + /// Maps an offset within the [`MultiBuffer`] to an offset within the [`Buffer`] pub fn map_offset_to_buffer(&self, offset: usize) -> usize { - self.excerpt.buffer_start_offset() + offset.saturating_sub(self.excerpt_offset) + self.excerpt.buffer_start_offset() + + offset + .saturating_sub(self.excerpt_offset) + .min(self.excerpt.text_summary.len) + } + + /// Maps a point within the [`MultiBuffer`] to a point within the [`Buffer`] + pub fn map_point_to_buffer(&self, point: Point) -> Point { + self.excerpt.buffer_start_point() + + point + .saturating_sub(self.excerpt_position) + .min(self.excerpt.text_summary.lines) } /// Maps a range within the [`MultiBuffer`] to a range within the [`Buffer`] @@ -4752,14 +4661,20 @@ impl<'a> MultiBufferExcerpt<'a> { /// Map an offset within the [`Buffer`] to an offset within the [`MultiBuffer`] pub fn map_offset_from_buffer(&self, buffer_offset: usize) -> usize { - let mut buffer_offset_in_excerpt = - buffer_offset.saturating_sub(self.excerpt.buffer_start_offset()); - buffer_offset_in_excerpt = - cmp::min(buffer_offset_in_excerpt, self.excerpt.text_summary.len); - + let buffer_offset_in_excerpt = buffer_offset + .saturating_sub(self.excerpt.buffer_start_offset()) + .min(self.excerpt.text_summary.len); self.excerpt_offset + buffer_offset_in_excerpt } + /// Map a point within the [`Buffer`] to a point within the [`MultiBuffer`] + pub fn map_point_from_buffer(&self, buffer_position: Point) -> Point { + let position_in_excerpt = buffer_position.saturating_sub(self.excerpt.buffer_start_point()); + let position_in_excerpt = + position_in_excerpt.min(self.excerpt.text_summary.lines + Point::new(1, 0)); + self.excerpt_position + position_in_excerpt + } + /// Map a range within the [`Buffer`] to a range within the [`MultiBuffer`] pub fn map_range_from_buffer(&self, buffer_range: Range) -> Range { self.map_offset_from_buffer(buffer_range.start) @@ -4771,6 +4686,10 @@ impl<'a> MultiBufferExcerpt<'a> { range.start >= self.excerpt.buffer_start_offset() && range.end <= self.excerpt.buffer_end_offset() } + + pub fn max_buffer_row(&self) -> u32 { + self.excerpt.max_buffer_row + } } impl ExcerptId { diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 7a54f7cc47..a4c6231206 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -8,8 +8,8 @@ use anyhow::{anyhow, Context as _, Result}; use client::Client; use collections::{hash_map, HashMap, HashSet}; use fs::Fs; -use futures::{channel::oneshot, stream::FuturesUnordered, StreamExt}; -use git::blame::Blame; +use futures::{channel::oneshot, future::Shared, Future, FutureExt as _, StreamExt}; +use git::{blame::Blame, diff::BufferDiff}; use gpui::{ AppContext, AsyncAppContext, Context as _, EventEmitter, Model, ModelContext, Subscription, Task, WeakModel, @@ -25,7 +25,7 @@ use language::{ use rpc::{proto, AnyProtoClient, ErrorExt as _, TypedEnvelope}; use smol::channel::Receiver; use std::{io, ops::Range, path::Path, str::FromStr as _, sync::Arc, time::Instant}; -use text::BufferId; +use text::{BufferId, LineEnding, Rope}; use util::{debug_panic, maybe, ResultExt as _, TryFutureExt}; use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Worktree, WorktreeId}; @@ -33,14 +33,29 @@ use worktree::{File, PathChange, ProjectEntryId, UpdatedGitRepositoriesSet, Work pub struct BufferStore { state: BufferStoreState, #[allow(clippy::type_complexity)] - loading_buffers_by_path: HashMap< - ProjectPath, - postage::watch::Receiver, Arc>>>, - >, + loading_buffers: HashMap, Arc>>>>, + #[allow(clippy::type_complexity)] + loading_change_sets: + HashMap, Arc>>>>, worktree_store: Model, opened_buffers: HashMap, downstream_client: Option<(AnyProtoClient, u64)>, - shared_buffers: HashMap>>, + shared_buffers: HashMap>, +} + +#[derive(Hash, Eq, PartialEq, Clone)] +struct SharedBuffer { + buffer: Model, + unstaged_changes: Option>, +} + +pub struct BufferChangeSet { + pub buffer_id: BufferId, + pub base_text: Option>, + pub diff_to_buffer: git::diff::BufferDiff, + pub recalculate_diff_task: Option>>, + pub diff_updated_futures: Vec>, + pub base_text_version: usize, } enum BufferStoreState { @@ -66,7 +81,10 @@ struct LocalBufferStore { } enum OpenBuffer { - Buffer(WeakModel), + Complete { + buffer: WeakModel, + unstaged_changes: Option>, + }, Operations(Vec), } @@ -85,6 +103,23 @@ pub struct ProjectTransaction(pub HashMap, language::Transaction>) impl EventEmitter for BufferStore {} impl RemoteBufferStore { + fn load_staged_text( + &self, + buffer_id: BufferId, + cx: &AppContext, + ) -> Task>> { + let project_id = self.project_id; + let client = self.upstream_client.clone(); + cx.background_executor().spawn(async move { + Ok(client + .request(proto::GetStagedText { + project_id, + buffer_id: buffer_id.to_proto(), + }) + .await? + .staged_text) + }) + } pub fn wait_for_remote_buffer( &mut self, id: BufferId, @@ -352,6 +387,27 @@ impl RemoteBufferStore { } impl LocalBufferStore { + fn load_staged_text( + &self, + buffer: &Model, + cx: &AppContext, + ) -> Task>> { + let Some(file) = buffer.read(cx).file() else { + return Task::ready(Err(anyhow!("buffer has no file"))); + }; + let worktree_id = file.worktree_id(cx); + let path = file.path().clone(); + let Some(worktree) = self + .worktree_store + .read(cx) + .worktree_for_id(worktree_id, cx) + else { + return Task::ready(Err(anyhow!("no such worktree"))); + }; + + worktree.read(cx).load_staged_file(path.as_ref(), cx) + } + fn save_local_buffer( &self, buffer_handle: Model, @@ -463,94 +519,71 @@ impl LocalBufferStore { ) { debug_assert!(worktree_handle.read(cx).is_local()); - // Identify the loading buffers whose containing repository that has changed. - let future_buffers = this - .loading_buffers() - .filter_map(|(project_path, receiver)| { - if project_path.worktree_id != worktree_handle.read(cx).id() { - return None; - } - let path = &project_path.path; - changed_repos - .iter() - .find(|(work_dir, _)| path.starts_with(work_dir))?; - let path = path.clone(); - Some(async move { - BufferStore::wait_for_loading_buffer(receiver) - .await - .ok() - .map(|buffer| (buffer, path)) - }) - }) - .collect::>(); - - // Identify the current buffers whose containing repository has changed. - let current_buffers = this - .buffers() + let buffer_change_sets = this + .opened_buffers + .values() .filter_map(|buffer| { - let file = File::from_dyn(buffer.read(cx).file())?; - if file.worktree != worktree_handle { - return None; + if let OpenBuffer::Complete { + buffer, + unstaged_changes, + } = buffer + { + let buffer = buffer.upgrade()?.read(cx); + let file = File::from_dyn(buffer.file())?; + if file.worktree != worktree_handle { + return None; + } + changed_repos + .iter() + .find(|(work_dir, _)| file.path.starts_with(work_dir))?; + let unstaged_changes = unstaged_changes.as_ref()?.upgrade()?; + let snapshot = buffer.text_snapshot(); + Some((unstaged_changes, snapshot, file.path.clone())) + } else { + None } - changed_repos - .iter() - .find(|(work_dir, _)| file.path.starts_with(work_dir))?; - Some((buffer, file.path.clone())) }) .collect::>(); - if future_buffers.len() + current_buffers.len() == 0 { + if buffer_change_sets.is_empty() { return; } cx.spawn(move |this, mut cx| async move { - // Wait for all of the buffers to load. - let future_buffers = future_buffers.collect::>().await; - - // Reload the diff base for every buffer whose containing git repository has changed. let snapshot = worktree_handle.update(&mut cx, |tree, _| tree.as_local().unwrap().snapshot())?; let diff_bases_by_buffer = cx .background_executor() .spawn(async move { - let mut diff_base_tasks = future_buffers + buffer_change_sets .into_iter() - .flatten() - .chain(current_buffers) - .filter_map(|(buffer, path)| { + .filter_map(|(change_set, buffer_snapshot, path)| { let (repo_entry, local_repo_entry) = snapshot.repo_for_path(&path)?; let relative_path = repo_entry.relativize(&snapshot, &path).ok()?; - Some(async move { - let base_text = - local_repo_entry.repo().load_index_text(&relative_path); - Some((buffer, base_text)) - }) + let base_text = local_repo_entry.repo().load_index_text(&relative_path); + Some((change_set, buffer_snapshot, base_text)) }) - .collect::>(); - - let mut diff_bases = Vec::with_capacity(diff_base_tasks.len()); - while let Some(diff_base) = diff_base_tasks.next().await { - if let Some(diff_base) = diff_base { - diff_bases.push(diff_base); - } - } - diff_bases + .collect::>() }) .await; this.update(&mut cx, |this, cx| { - // Assign the new diff bases on all of the buffers. - for (buffer, diff_base) in diff_bases_by_buffer { - let buffer_id = buffer.update(cx, |buffer, cx| { - buffer.set_diff_base(diff_base.clone(), cx); - buffer.remote_id().to_proto() + for (change_set, buffer_snapshot, staged_text) in diff_bases_by_buffer { + change_set.update(cx, |change_set, cx| { + if let Some(staged_text) = staged_text.clone() { + let _ = + change_set.set_base_text(staged_text, buffer_snapshot.clone(), cx); + } else { + change_set.unset_base_text(buffer_snapshot.clone(), cx); + } }); + if let Some((client, project_id)) = &this.downstream_client.clone() { client .send(proto::UpdateDiffBase { project_id: *project_id, - buffer_id, - diff_base, + buffer_id: buffer_snapshot.remote_id().to_proto(), + staged_text, }) .log_err(); } @@ -759,12 +792,7 @@ impl LocalBufferStore { .spawn(async move { text::Buffer::new(0, buffer_id, loaded.text) }) .await; cx.insert_model(reservation, |_| { - Buffer::build( - text_buffer, - loaded.diff_base, - Some(loaded.file), - Capability::ReadWrite, - ) + Buffer::build(text_buffer, Some(loaded.file), Capability::ReadWrite) }) }) }); @@ -777,7 +805,6 @@ impl LocalBufferStore { let text_buffer = text::Buffer::new(0, buffer_id, "".into()); Buffer::build( text_buffer, - None, Some(Arc::new(File { worktree, path, @@ -861,11 +888,12 @@ impl BufferStore { client.add_model_message_handler(Self::handle_buffer_reloaded); client.add_model_message_handler(Self::handle_buffer_saved); client.add_model_message_handler(Self::handle_update_buffer_file); - client.add_model_message_handler(Self::handle_update_diff_base); client.add_model_request_handler(Self::handle_save_buffer); client.add_model_request_handler(Self::handle_blame_buffer); client.add_model_request_handler(Self::handle_reload_buffers); client.add_model_request_handler(Self::handle_get_permalink_to_line); + client.add_model_request_handler(Self::handle_get_staged_text); + client.add_model_message_handler(Self::handle_update_diff_base); } /// Creates a buffer store, optionally retaining its buffers. @@ -885,7 +913,8 @@ impl BufferStore { downstream_client: None, opened_buffers: Default::default(), shared_buffers: Default::default(), - loading_buffers_by_path: Default::default(), + loading_buffers: Default::default(), + loading_change_sets: Default::default(), worktree_store, } } @@ -907,7 +936,8 @@ impl BufferStore { }), downstream_client: None, opened_buffers: Default::default(), - loading_buffers_by_path: Default::default(), + loading_buffers: Default::default(), + loading_change_sets: Default::default(), shared_buffers: Default::default(), worktree_store, } @@ -939,55 +969,125 @@ impl BufferStore { project_path: ProjectPath, cx: &mut ModelContext, ) -> Task>> { - let existing_buffer = self.get_by_path(&project_path, cx); - if let Some(existing_buffer) = existing_buffer { - return Task::ready(Ok(existing_buffer)); + if let Some(buffer) = self.get_by_path(&project_path, cx) { + return Task::ready(Ok(buffer)); } - let Some(worktree) = self - .worktree_store - .read(cx) - .worktree_for_id(project_path.worktree_id, cx) - else { - return Task::ready(Err(anyhow!("no such worktree"))); - }; - - let loading_watch = match self.loading_buffers_by_path.entry(project_path.clone()) { - // If the given path is already being loaded, then wait for that existing - // task to complete and return the same buffer. + let task = match self.loading_buffers.entry(project_path.clone()) { hash_map::Entry::Occupied(e) => e.get().clone(), - - // Otherwise, record the fact that this path is now being loaded. hash_map::Entry::Vacant(entry) => { - let (mut tx, rx) = postage::watch::channel(); - entry.insert(rx.clone()); - let path = project_path.path.clone(); + let Some(worktree) = self + .worktree_store + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + else { + return Task::ready(Err(anyhow!("no such worktree"))); + }; let load_buffer = match &self.state { BufferStoreState::Local(this) => this.open_buffer(path, worktree, cx), BufferStoreState::Remote(this) => this.open_buffer(path, worktree, cx), }; - cx.spawn(move |this, mut cx| async move { - let load_result = load_buffer.await; - *tx.borrow_mut() = Some(this.update(&mut cx, |this, _cx| { - // Record the fact that the buffer is no longer loading. - this.loading_buffers_by_path.remove(&project_path); - let buffer = load_result.map_err(Arc::new)?; - Ok(buffer) - })?); - anyhow::Ok(()) - }) - .detach(); - rx + entry + .insert( + cx.spawn(move |this, mut cx| async move { + let load_result = load_buffer.await; + this.update(&mut cx, |this, _cx| { + // Record the fact that the buffer is no longer loading. + this.loading_buffers.remove(&project_path); + }) + .ok(); + load_result.map_err(Arc::new) + }) + .shared(), + ) + .clone() } }; - cx.background_executor().spawn(async move { - Self::wait_for_loading_buffer(loading_watch) + cx.background_executor() + .spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) + } + + pub fn open_unstaged_changes( + &mut self, + buffer: Model, + cx: &mut ModelContext, + ) -> Task>> { + let buffer_id = buffer.read(cx).remote_id(); + if let Some(change_set) = self.get_unstaged_changes(buffer_id) { + return Task::ready(Ok(change_set)); + } + + let task = match self.loading_change_sets.entry(buffer_id) { + hash_map::Entry::Occupied(e) => e.get().clone(), + hash_map::Entry::Vacant(entry) => { + let load = match &self.state { + BufferStoreState::Local(this) => this.load_staged_text(&buffer, cx), + BufferStoreState::Remote(this) => this.load_staged_text(buffer_id, cx), + }; + + entry + .insert( + cx.spawn(move |this, cx| async move { + Self::open_unstaged_changes_internal(this, load.await, buffer, cx) + .await + .map_err(Arc::new) + }) + .shared(), + ) + .clone() + } + }; + + cx.background_executor() + .spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) + } + + pub async fn open_unstaged_changes_internal( + this: WeakModel, + text: Result>, + buffer: Model, + mut cx: AsyncAppContext, + ) -> Result> { + let text = match text { + Err(e) => { + this.update(&mut cx, |this, cx| { + let buffer_id = buffer.read(cx).remote_id(); + this.loading_change_sets.remove(&buffer_id); + })?; + return Err(e); + } + Ok(text) => text, + }; + + let change_set = buffer.update(&mut cx, |buffer, cx| { + cx.new_model(|_| BufferChangeSet::new(buffer)) + })?; + + if let Some(text) = text { + change_set + .update(&mut cx, |change_set, cx| { + let snapshot = buffer.read(cx).text_snapshot(); + change_set.set_base_text(text, snapshot, cx) + })? .await - .map_err(|e| e.cloned()) - }) + .ok(); + } + + this.update(&mut cx, |this, cx| { + let buffer_id = buffer.read(cx).remote_id(); + this.loading_change_sets.remove(&buffer_id); + if let Some(OpenBuffer::Complete { + unstaged_changes, .. + }) = this.opened_buffers.get_mut(&buffer.read(cx).remote_id()) + { + *unstaged_changes = Some(change_set.downgrade()); + } + })?; + + Ok(change_set) } pub fn create_buffer(&mut self, cx: &mut ModelContext) -> Task>> { @@ -1166,7 +1266,10 @@ impl BufferStore { fn add_buffer(&mut self, buffer: Model, cx: &mut ModelContext) -> Result<()> { let remote_id = buffer.read(cx).remote_id(); let is_remote = buffer.read(cx).replica_id() != 0; - let open_buffer = OpenBuffer::Buffer(buffer.downgrade()); + let open_buffer = OpenBuffer::Complete { + buffer: buffer.downgrade(), + unstaged_changes: None, + }; let handle = cx.handle().downgrade(); buffer.update(cx, move |_, cx| { @@ -1212,15 +1315,11 @@ impl BufferStore { pub fn loading_buffers( &self, - ) -> impl Iterator< - Item = ( - &ProjectPath, - postage::watch::Receiver, Arc>>>, - ), - > { - self.loading_buffers_by_path - .iter() - .map(|(path, rx)| (path, rx.clone())) + ) -> impl Iterator>>)> { + self.loading_buffers.iter().map(|(path, task)| { + let task = task.clone(); + (path, async move { task.await.map_err(|e| anyhow!("{e}")) }) + }) } pub fn get_by_path(&self, path: &ProjectPath, cx: &AppContext) -> Option> { @@ -1235,9 +1334,7 @@ impl BufferStore { } pub fn get(&self, buffer_id: BufferId) -> Option> { - self.opened_buffers - .get(&buffer_id) - .and_then(|buffer| buffer.upgrade()) + self.opened_buffers.get(&buffer_id)?.upgrade() } pub fn get_existing(&self, buffer_id: BufferId) -> Result> { @@ -1252,6 +1349,17 @@ impl BufferStore { }) } + pub fn get_unstaged_changes(&self, buffer_id: BufferId) -> Option> { + if let OpenBuffer::Complete { + unstaged_changes, .. + } = self.opened_buffers.get(&buffer_id)? + { + unstaged_changes.as_ref()?.upgrade() + } else { + None + } + } + pub fn buffer_version_info( &self, cx: &AppContext, @@ -1366,6 +1474,35 @@ impl BufferStore { rx } + pub fn recalculate_buffer_diffs( + &mut self, + buffers: Vec>, + cx: &mut ModelContext, + ) -> impl Future { + let mut futures = Vec::new(); + for buffer in buffers { + let buffer = buffer.read(cx).text_snapshot(); + if let Some(OpenBuffer::Complete { + unstaged_changes, .. + }) = self.opened_buffers.get_mut(&buffer.remote_id()) + { + if let Some(unstaged_changes) = unstaged_changes + .as_ref() + .and_then(|changes| changes.upgrade()) + { + unstaged_changes.update(cx, |unstaged_changes, cx| { + futures.push(unstaged_changes.recalculate_diff(buffer.clone(), cx)); + }); + } else { + unstaged_changes.take(); + } + } + } + async move { + futures::future::join_all(futures).await; + } + } + fn on_buffer_event( &mut self, buffer: Model, @@ -1413,7 +1550,7 @@ impl BufferStore { match this.opened_buffers.entry(buffer_id) { hash_map::Entry::Occupied(mut e) => match e.get_mut() { OpenBuffer::Operations(operations) => operations.extend_from_slice(&ops), - OpenBuffer::Buffer(buffer) => { + OpenBuffer::Complete { buffer, .. } => { if let Some(buffer) = buffer.upgrade() { buffer.update(cx, |buffer, cx| buffer.apply_ops(ops, cx)); } @@ -1449,7 +1586,11 @@ impl BufferStore { self.shared_buffers .entry(guest_id) .or_default() - .insert(buffer.clone()); + .entry(buffer_id) + .or_insert_with(|| SharedBuffer { + buffer: buffer.clone(), + unstaged_changes: None, + }); let buffer = buffer.read(cx); response.buffers.push(proto::BufferVersion { @@ -1469,13 +1610,14 @@ impl BufferStore { .log_err(); } - client - .send(proto::UpdateDiffBase { - project_id, - buffer_id: buffer_id.into(), - diff_base: buffer.diff_base().map(ToString::to_string), - }) - .log_err(); + // todo!(max): do something + // client + // .send(proto::UpdateStagedText { + // project_id, + // buffer_id: buffer_id.into(), + // diff_base: buffer.diff_base().map(ToString::to_string), + // }) + // .log_err(); client .send(proto::BufferReloaded { @@ -1579,32 +1721,6 @@ impl BufferStore { })? } - pub async fn handle_update_diff_base( - this: Model, - envelope: TypedEnvelope, - mut cx: AsyncAppContext, - ) -> Result<()> { - this.update(&mut cx, |this, cx| { - let buffer_id = envelope.payload.buffer_id; - let buffer_id = BufferId::new(buffer_id)?; - if let Some(buffer) = this.get_possibly_incomplete(buffer_id) { - buffer.update(cx, |buffer, cx| { - buffer.set_diff_base(envelope.payload.diff_base.clone(), cx) - }); - } - if let Some((downstream_client, project_id)) = this.downstream_client.as_ref() { - downstream_client - .send(proto::UpdateDiffBase { - project_id: *project_id, - buffer_id: buffer_id.into(), - diff_base: envelope.payload.diff_base, - }) - .log_err(); - } - Ok(()) - })? - } - pub async fn handle_save_buffer( this: Model, envelope: TypedEnvelope, @@ -1654,16 +1770,14 @@ impl BufferStore { let peer_id = envelope.sender_id; let buffer_id = BufferId::new(envelope.payload.buffer_id)?; this.update(&mut cx, |this, _| { - if let Some(buffer) = this.get(buffer_id) { - if let Some(shared) = this.shared_buffers.get_mut(&peer_id) { - if shared.remove(&buffer) { - if shared.is_empty() { - this.shared_buffers.remove(&peer_id); - } - return; + if let Some(shared) = this.shared_buffers.get_mut(&peer_id) { + if shared.remove(&buffer_id).is_some() { + if shared.is_empty() { + this.shared_buffers.remove(&peer_id); } + return; } - }; + } debug_panic!( "peer_id {} closed buffer_id {} which was either not open or already closed", peer_id, @@ -1779,18 +1893,66 @@ impl BufferStore { }) } - pub async fn wait_for_loading_buffer( - mut receiver: postage::watch::Receiver, Arc>>>, - ) -> Result, Arc> { - loop { - if let Some(result) = receiver.borrow().as_ref() { - match result { - Ok(buffer) => return Ok(buffer.to_owned()), - Err(e) => return Err(e.to_owned()), - } + pub async fn handle_get_staged_text( + this: Model, + request: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let buffer_id = BufferId::new(request.payload.buffer_id)?; + let change_set = this + .update(&mut cx, |this, cx| { + let buffer = this.get(buffer_id)?; + Some(this.open_unstaged_changes(buffer, cx)) + })? + .ok_or_else(|| anyhow!("no such buffer"))? + .await?; + this.update(&mut cx, |this, _| { + let shared_buffers = this + .shared_buffers + .entry(request.original_sender_id.unwrap_or(request.sender_id)) + .or_default(); + debug_assert!(shared_buffers.contains_key(&buffer_id)); + if let Some(shared) = shared_buffers.get_mut(&buffer_id) { + shared.unstaged_changes = Some(change_set.clone()); } - receiver.next().await; - } + })?; + let staged_text = change_set.read_with(&cx, |change_set, cx| { + change_set + .base_text + .as_ref() + .map(|buffer| buffer.read(cx).text()) + })?; + Ok(proto::GetStagedTextResponse { staged_text }) + } + + pub async fn handle_update_diff_base( + this: Model, + request: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + let buffer_id = BufferId::new(request.payload.buffer_id)?; + let Some((buffer, change_set)) = this.update(&mut cx, |this, _| { + if let OpenBuffer::Complete { + unstaged_changes, + buffer, + } = this.opened_buffers.get(&buffer_id)? + { + Some((buffer.upgrade()?, unstaged_changes.as_ref()?.upgrade()?)) + } else { + None + } + })? + else { + return Ok(()); + }; + change_set.update(&mut cx, |change_set, cx| { + if let Some(staged_text) = request.payload.staged_text { + let _ = change_set.set_base_text(staged_text, buffer.read(cx).text_snapshot(), cx); + } else { + change_set.unset_base_text(buffer.read(cx).text_snapshot(), cx) + } + })?; + Ok(()) } pub fn reload_buffers( @@ -1839,14 +2001,17 @@ impl BufferStore { cx: &mut ModelContext, ) -> Task> { let buffer_id = buffer.read(cx).remote_id(); - if !self - .shared_buffers - .entry(peer_id) - .or_default() - .insert(buffer.clone()) - { + let shared_buffers = self.shared_buffers.entry(peer_id).or_default(); + if shared_buffers.contains_key(&buffer_id) { return Task::ready(Ok(())); } + shared_buffers.insert( + buffer_id, + SharedBuffer { + buffer: buffer.clone(), + unstaged_changes: None, + }, + ); let Some((client, project_id)) = self.downstream_client.clone() else { return Task::ready(Ok(())); @@ -1909,8 +2074,8 @@ impl BufferStore { } } - pub fn shared_buffers(&self) -> &HashMap>> { - &self.shared_buffers + pub fn has_shared_buffers(&self) -> bool { + !self.shared_buffers.is_empty() } pub fn create_local_buffer( @@ -1998,10 +2163,129 @@ impl BufferStore { } } +impl BufferChangeSet { + pub fn new(buffer: &text::BufferSnapshot) -> Self { + Self { + buffer_id: buffer.remote_id(), + base_text: None, + diff_to_buffer: git::diff::BufferDiff::new(buffer), + recalculate_diff_task: None, + diff_updated_futures: Vec::new(), + base_text_version: 0, + } + } + + #[cfg(any(test, feature = "test-support"))] + pub fn new_with_base_text( + base_text: String, + buffer: text::BufferSnapshot, + cx: &mut ModelContext, + ) -> Self { + let mut this = Self::new(&buffer); + let _ = this.set_base_text(base_text, buffer, cx); + this + } + + pub fn diff_hunks_intersecting_range<'a>( + &'a self, + range: Range, + buffer_snapshot: &'a text::BufferSnapshot, + ) -> impl 'a + Iterator { + self.diff_to_buffer + .hunks_intersecting_range(range, buffer_snapshot) + } + + pub fn diff_hunks_intersecting_range_rev<'a>( + &'a self, + range: Range, + buffer_snapshot: &'a text::BufferSnapshot, + ) -> impl 'a + Iterator { + self.diff_to_buffer + .hunks_intersecting_range_rev(range, buffer_snapshot) + } + + #[cfg(any(test, feature = "test-support"))] + pub fn base_text_string(&self, cx: &AppContext) -> Option { + self.base_text.as_ref().map(|buffer| buffer.read(cx).text()) + } + + pub fn set_base_text( + &mut self, + mut base_text: String, + buffer_snapshot: text::BufferSnapshot, + cx: &mut ModelContext, + ) -> oneshot::Receiver<()> { + LineEnding::normalize(&mut base_text); + self.recalculate_diff_internal(base_text, buffer_snapshot, true, cx) + } + + pub fn unset_base_text( + &mut self, + buffer_snapshot: text::BufferSnapshot, + cx: &mut ModelContext, + ) { + if self.base_text.is_some() { + self.base_text = None; + self.diff_to_buffer = BufferDiff::new(&buffer_snapshot); + self.recalculate_diff_task.take(); + self.base_text_version += 1; + cx.notify(); + } + } + + pub fn recalculate_diff( + &mut self, + buffer_snapshot: text::BufferSnapshot, + cx: &mut ModelContext, + ) -> oneshot::Receiver<()> { + if let Some(base_text) = self.base_text.clone() { + self.recalculate_diff_internal(base_text.read(cx).text(), buffer_snapshot, false, cx) + } else { + oneshot::channel().1 + } + } + + fn recalculate_diff_internal( + &mut self, + base_text: String, + buffer_snapshot: text::BufferSnapshot, + base_text_changed: bool, + cx: &mut ModelContext, + ) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + self.diff_updated_futures.push(tx); + self.recalculate_diff_task = Some(cx.spawn(|this, mut cx| async move { + let (base_text, diff) = cx + .background_executor() + .spawn(async move { + let diff = BufferDiff::build(&base_text, &buffer_snapshot).await; + (base_text, diff) + }) + .await; + this.update(&mut cx, |this, cx| { + if base_text_changed { + this.base_text_version += 1; + this.base_text = Some(cx.new_model(|cx| { + Buffer::local_normalized(Rope::from(base_text), LineEnding::default(), cx) + })); + } + this.diff_to_buffer = diff; + this.recalculate_diff_task.take(); + for tx in this.diff_updated_futures.drain(..) { + tx.send(()).ok(); + } + cx.notify(); + })?; + Ok(()) + })); + rx + } +} + impl OpenBuffer { fn upgrade(&self) -> Option> { match self { - OpenBuffer::Buffer(handle) => handle.upgrade(), + OpenBuffer::Complete { buffer, .. } => buffer.upgrade(), OpenBuffer::Operations(_) => None, } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 74bd065c32..84aedab92b 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -25,7 +25,7 @@ pub mod search_history; mod yarn; use anyhow::{anyhow, Context as _, Result}; -use buffer_store::{BufferStore, BufferStoreEvent}; +use buffer_store::{BufferChangeSet, BufferStore, BufferStoreEvent}; use client::{proto, Client, Collaborator, PendingEntitySubscription, TypedEnvelope, UserStore}; use clock::ReplicaId; use collections::{BTreeSet, HashMap, HashSet}; @@ -1821,6 +1821,20 @@ impl Project { }) } + pub fn open_unstaged_changes( + &mut self, + buffer: Model, + cx: &mut ModelContext, + ) -> Task>> { + if self.is_disconnected(cx) { + return Task::ready(Err(anyhow!(ErrorCode::Disconnected))); + } + + self.buffer_store.update(cx, |buffer_store, cx| { + buffer_store.open_unstaged_changes(buffer, cx) + }) + } + pub fn open_buffer_by_id( &mut self, id: BufferId, @@ -2269,10 +2283,7 @@ impl Project { event: &BufferEvent, cx: &mut ModelContext, ) -> Option<()> { - if matches!( - event, - BufferEvent::Edited { .. } | BufferEvent::Reloaded | BufferEvent::DiffBaseChanged - ) { + if matches!(event, BufferEvent::Edited { .. } | BufferEvent::Reloaded) { self.request_buffer_diff_recalculation(&buffer, cx); } @@ -2369,34 +2380,32 @@ impl Project { } fn recalculate_buffer_diffs(&mut self, cx: &mut ModelContext) -> Task<()> { - let buffers = self.buffers_needing_diff.drain().collect::>(); cx.spawn(move |this, mut cx| async move { - let tasks: Vec<_> = buffers - .iter() - .filter_map(|buffer| { - let buffer = buffer.upgrade()?; - buffer - .update(&mut cx, |buffer, cx| buffer.recalculate_diff(cx)) - .ok() - .flatten() - }) - .collect(); - - futures::future::join_all(tasks).await; - - this.update(&mut cx, |this, cx| { - if this.buffers_needing_diff.is_empty() { - // TODO: Would a `ModelContext.notify()` suffice here? - for buffer in buffers { - if let Some(buffer) = buffer.upgrade() { - buffer.update(cx, |_, cx| cx.notify()); + loop { + let task = this + .update(&mut cx, |this, cx| { + let buffers = this + .buffers_needing_diff + .drain() + .filter_map(|buffer| buffer.upgrade()) + .collect::>(); + if buffers.is_empty() { + None + } else { + Some(this.buffer_store.update(cx, |buffer_store, cx| { + buffer_store.recalculate_buffer_diffs(buffers, cx) + })) } - } + }) + .ok() + .flatten(); + + if let Some(task) = task { + task.await; } else { - this.recalculate_buffer_diffs(cx).detach(); + break; } - }) - .ok(); + } }) } @@ -4149,6 +4158,10 @@ impl Project { .read(cx) .language_servers_for_buffer(buffer, cx) } + + pub fn buffer_store(&self) -> &Model { + &self.buffer_store + } } fn deserialize_code_actions(code_actions: &HashMap) -> Vec { diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 2704259306..26537503dc 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -1,6 +1,7 @@ use crate::{Event, *}; use fs::FakeFs; use futures::{future, StreamExt}; +use git::diff::assert_hunks; use gpui::{AppContext, SemanticVersion, UpdateGlobal}; use http_client::Url; use language::{ @@ -5396,6 +5397,98 @@ async fn test_reordering_worktrees(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_unstaged_changes_for_buffer(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let staged_contents = r#" + fn main() { + println!("hello world"); + } + "# + .unindent(); + let file_contents = r#" + // print goodbye + fn main() { + println!("goodbye world"); + } + "# + .unindent(); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/dir", + json!({ + ".git": {}, + "src": { + "main.rs": file_contents, + } + }), + ) + .await; + + fs.set_index_for_repo( + Path::new("/dir/.git"), + &[(Path::new("src/main.rs"), staged_contents)], + ); + + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + let buffer = project + .update(cx, |project, cx| { + project.open_local_buffer("/dir/src/main.rs", cx) + }) + .await + .unwrap(); + let unstaged_changes = project + .update(cx, |project, cx| { + project.open_unstaged_changes(buffer.clone(), cx) + }) + .await + .unwrap(); + + cx.run_until_parked(); + unstaged_changes.update(cx, |unstaged_changes, cx| { + let snapshot = buffer.read(cx).snapshot(); + assert_hunks( + unstaged_changes.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot), + &snapshot, + &unstaged_changes.base_text.as_ref().unwrap().read(cx).text(), + &[ + (0..1, "", "// print goodbye\n"), + ( + 2..3, + " println!(\"hello world\");\n", + " println!(\"goodbye world\");\n", + ), + ], + ); + }); + + let staged_contents = r#" + // print goodbye + fn main() { + } + "# + .unindent(); + + fs.set_index_for_repo( + Path::new("/dir/.git"), + &[(Path::new("src/main.rs"), staged_contents)], + ); + + cx.run_until_parked(); + unstaged_changes.update(cx, |unstaged_changes, cx| { + let snapshot = buffer.read(cx).snapshot(); + assert_hunks( + unstaged_changes.diff_hunks_intersecting_range(Anchor::MIN..Anchor::MAX, &snapshot), + &snapshot, + &unstaged_changes.base_text.as_ref().unwrap().read(cx).text(), + &[(2..3, "", " println!(\"goodbye world\");\n")], + ); + }); +} + async fn search( project: &Model, query: SearchQuery, diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index 178d88ad26..f0d8f27131 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -301,7 +301,10 @@ message Envelope { SyncExtensions sync_extensions = 285; SyncExtensionsResponse sync_extensions_response = 286; - InstallExtension install_extension = 287; // current max + InstallExtension install_extension = 287; + + GetStagedText get_staged_text = 288; + GetStagedTextResponse get_staged_text_response = 289; // current max } reserved 87 to 88; @@ -1788,11 +1791,12 @@ message BufferState { uint64 id = 1; optional File file = 2; string base_text = 3; - optional string diff_base = 4; LineEnding line_ending = 5; repeated VectorClockEntry saved_version = 6; - reserved 7; Timestamp saved_mtime = 8; + + reserved 7; + reserved 4; } message BufferChunk { @@ -1983,7 +1987,16 @@ message WorktreeMetadata { message UpdateDiffBase { uint64 project_id = 1; uint64 buffer_id = 2; - optional string diff_base = 3; + optional string staged_text = 3; +} + +message GetStagedText { + uint64 project_id = 1; + uint64 buffer_id = 2; +} + +message GetStagedTextResponse { + optional string staged_text = 1; } message GetNotifications { diff --git a/crates/proto/src/proto.rs b/crates/proto/src/proto.rs index 0810a561b9..6a417e6b2a 100644 --- a/crates/proto/src/proto.rs +++ b/crates/proto/src/proto.rs @@ -216,6 +216,8 @@ messages!( (GetImplementationResponse, Background), (GetLlmToken, Background), (GetLlmTokenResponse, Background), + (GetStagedText, Foreground), + (GetStagedTextResponse, Foreground), (GetUsers, Foreground), (Hello, Foreground), (IncomingCall, Foreground), @@ -411,6 +413,7 @@ request_messages!( (GetProjectSymbols, GetProjectSymbolsResponse), (GetReferences, GetReferencesResponse), (GetSignatureHelp, GetSignatureHelpResponse), + (GetStagedText, GetStagedTextResponse), (GetSupermavenApiKey, GetSupermavenApiKeyResponse), (GetTypeDefinition, GetTypeDefinitionResponse), (LinkedEditingRange, LinkedEditingRangeResponse), @@ -525,6 +528,7 @@ entity_messages!( GetProjectSymbols, GetReferences, GetSignatureHelp, + GetStagedText, GetTypeDefinition, InlayHints, JoinProject, diff --git a/crates/remote_server/src/remote_editing_tests.rs b/crates/remote_server/src/remote_editing_tests.rs index bdb862c5af..711b3c29bd 100644 --- a/crates/remote_server/src/remote_editing_tests.rs +++ b/crates/remote_server/src/remote_editing_tests.rs @@ -78,13 +78,22 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test }) .await .unwrap(); + let change_set = project + .update(cx, |project, cx| { + project.open_unstaged_changes(buffer.clone(), cx) + }) + .await + .unwrap(); + + change_set.update(cx, |change_set, cx| { + assert_eq!( + change_set.base_text_string(cx).unwrap(), + "fn one() -> usize { 0 }" + ); + }); buffer.update(cx, |buffer, cx| { assert_eq!(buffer.text(), "fn one() -> usize { 1 }"); - assert_eq!( - buffer.diff_base().unwrap().to_string(), - "fn one() -> usize { 0 }" - ); let ix = buffer.text().find('1').unwrap(); buffer.edit([(ix..ix + 1, "100")], None, cx); }); @@ -140,9 +149,9 @@ async fn test_basic_remote_editing(cx: &mut TestAppContext, server_cx: &mut Test &[(Path::new("src/lib2.rs"), "fn one() -> usize { 100 }".into())], ); cx.executor().run_until_parked(); - buffer.update(cx, |buffer, _| { + change_set.update(cx, |change_set, cx| { assert_eq!( - buffer.diff_base().unwrap().to_string(), + change_set.base_text_string(cx).unwrap(), "fn one() -> usize { 100 }" ); }); @@ -213,7 +222,7 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes // test that the headless server is tracking which buffers we have open correctly. cx.run_until_parked(); headless.update(server_cx, |headless, cx| { - assert!(!headless.buffer_store.read(cx).shared_buffers().is_empty()) + assert!(headless.buffer_store.read(cx).has_shared_buffers()) }); do_search(&project, cx.clone()).await; @@ -222,7 +231,7 @@ async fn test_remote_project_search(cx: &mut TestAppContext, server_cx: &mut Tes }); cx.run_until_parked(); headless.update(server_cx, |headless, cx| { - assert!(headless.buffer_store.read(cx).shared_buffers().is_empty()) + assert!(!headless.buffer_store.read(cx).has_shared_buffers()) }); do_search(&project, cx.clone()).await; diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index e856bbf7de..a9762b942b 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -104,7 +104,6 @@ pub enum CreatedEntry { pub struct LoadedFile { pub file: Arc, pub text: String, - pub diff_base: Option, } pub struct LoadedBinaryFile { @@ -707,6 +706,30 @@ impl Worktree { } } + pub fn load_staged_file(&self, path: &Path, cx: &AppContext) -> Task>> { + match self { + Worktree::Local(this) => { + let path = Arc::from(path); + let snapshot = this.snapshot(); + cx.background_executor().spawn(async move { + if let Some(repo) = snapshot.repository_for_path(&path) { + if let Some(repo_path) = repo.relativize(&snapshot, &path).log_err() { + if let Some(git_repo) = + snapshot.git_repositories.get(&*repo.work_directory) + { + return Ok(git_repo.repo_ptr.load_index_text(&repo_path)); + } + } + } + Ok(None) + }) + } + Worktree::Remote(_) => { + Task::ready(Err(anyhow!("remote worktrees can't yet load staged files"))) + } + } + } + pub fn load_binary_file( &self, path: &Path, @@ -1362,28 +1385,9 @@ impl LocalWorktree { let entry = self.refresh_entry(path.clone(), None, cx); let is_private = self.is_path_private(path.as_ref()); - cx.spawn(|this, mut cx| async move { + cx.spawn(|this, _cx| async move { let abs_path = abs_path?; let text = fs.load(&abs_path).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) { - if let Some(repo_path) = repo.relativize(&snapshot, &path).log_err() { - if let Some(git_repo) = snapshot.git_repositories.get(&*repo.work_directory) { - let git_repo = git_repo.repo_ptr.clone(); - index_task = Some( - cx.background_executor() - .spawn(async move { git_repo.load_index_text(&repo_path) }), - ); - } - } - } - - let diff_base = if let Some(index_task) = index_task { - index_task.await - } else { - None - }; let worktree = this .upgrade() @@ -1413,11 +1417,7 @@ impl LocalWorktree { } }; - Ok(LoadedFile { - file, - text, - diff_base, - }) + Ok(LoadedFile { file, text }) }) } From 5b169fa535efe798f0e5b76f0fd8a87dfa83f912 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:04:24 -0500 Subject: [PATCH 163/215] Update Rust crate anyhow to v1.0.94 (#21552) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [anyhow](https://redirect.github.com/dtolnay/anyhow) | workspace.dependencies | patch | `1.0.93` -> `1.0.94` | --- ### Release Notes
dtolnay/anyhow (anyhow) ### [`v1.0.94`](https://redirect.github.com/dtolnay/anyhow/releases/tag/1.0.94) [Compare Source](https://redirect.github.com/dtolnay/anyhow/compare/1.0.93...1.0.94) - Documentation improvements
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 820f52a150..c148c5c7a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "activity_indicator" @@ -257,9 +257,9 @@ checksum = "34cd60c5e3152cef0a592f1b296f1cc93715d89d2551d85315828c3a09575ff4" [[package]] name = "anyhow" -version = "1.0.93" +version = "1.0.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c95c10ba0b00a02636238b814946408b1322d5ac4760326e6fb8ec956d85775" +checksum = "c1fd03a028ef38ba2276dce7e33fcd6369c158a1bca17946c4b1b701891c1ff7" [[package]] name = "approx" From 31c976d8d9988fac0607040e8f135ed55f14ab00 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 4 Dec 2024 18:07:45 -0500 Subject: [PATCH 164/215] Update Rust crate cargo_metadata to v0.19.1 (#21556) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [cargo_metadata](https://redirect.github.com/oli-obk/cargo_metadata) | workspace.dependencies | patch | `0.19.0` -> `0.19.1` | --- ### Release Notes
oli-obk/cargo_metadata (cargo_metadata) ### [`v0.19.1`](https://redirect.github.com/oli-obk/cargo_metadata/compare/0.19.0...0.19.1) [Compare Source](https://redirect.github.com/oli-obk/cargo_metadata/compare/0.19.0...0.19.1)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c148c5c7a1..6a6bbc36aa 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2159,16 +2159,16 @@ dependencies = [ [[package]] name = "cargo_metadata" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc309ed89476c8957c50fb818f56fe894db857866c3e163335faa91dc34eb85" +checksum = "8769706aad5d996120af43197bf46ef6ad0fda35216b4505f926a365a232d924" dependencies = [ "camino", "cargo-platform", "semver", "serde", "serde_json", - "thiserror 1.0.69", + "thiserror 2.0.3", ] [[package]] From b9c390c22e196ac06fac8bdd1d32aef519f233c1 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Wed, 4 Dec 2024 19:26:09 -0500 Subject: [PATCH 165/215] Revert "Open folds containing selections when jumping from multibuffer (#21433)" (#21566) This reverts commit dc32ab25a0f76280ff0f1485333a729523840e27. This has been causing panics, backing it out while figuring out what's up. Release Notes: - N/A --- crates/editor/src/editor.rs | 1 - crates/editor/src/editor_tests.rs | 70 +------------------------------ 2 files changed, 1 insertion(+), 70 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c5d09ed1bf..c20282fa02 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12925,7 +12925,6 @@ impl Editor { None => Autoscroll::newest(), }; let nav_history = editor.nav_history.take(); - editor.unfold_ranges(&ranges, false, true, cx); editor.change_selections(Some(autoscroll), cx, |s| { s.select_ranges(ranges); }); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 7f900e2c39..7561c31f13 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11651,7 +11651,7 @@ async fn test_multibuffer_reverts(cx: &mut gpui::TestAppContext) { } #[gpui::test] -async fn test_multibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { +async fn test_mutlibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); let cols = 4; @@ -11940,74 +11940,6 @@ async fn test_multibuffer_in_navigation_history(cx: &mut gpui::TestAppContext) { .unwrap(); } -#[gpui::test] -async fn test_multibuffer_unfold_on_jump(cx: &mut gpui::TestAppContext) { - init_test(cx, |_| {}); - - let texts = ["{\n\tx\n}".to_owned(), "y".to_owned()]; - let buffers = texts - .clone() - .map(|txt| cx.new_model(|cx| Buffer::local(txt, cx))); - let multi_buffer = cx.new_model(|cx| { - let mut multi_buffer = MultiBuffer::new(ReadWrite); - for i in 0..2 { - multi_buffer.push_excerpts( - buffers[i].clone(), - [ExcerptRange { - context: 0..texts[i].len(), - primary: None, - }], - cx, - ); - } - multi_buffer - }); - - let fs = FakeFs::new(cx.executor()); - fs.insert_tree( - "/project", - json!({ - "x": &texts[0], - "y": &texts[1], - }), - ) - .await; - let project = Project::test(fs, ["/project".as_ref()], cx).await; - let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); - let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - - let multi_buffer_editor = cx.new_view(|cx| { - Editor::for_multibuffer(multi_buffer.clone(), Some(project.clone()), true, cx) - }); - let buffer_editor = - cx.new_view(|cx| Editor::for_buffer(buffers[0].clone(), Some(project.clone()), cx)); - workspace - .update(cx, |workspace, cx| { - workspace.add_item_to_active_pane( - Box::new(multi_buffer_editor.clone()), - None, - true, - cx, - ); - workspace.add_item_to_active_pane(Box::new(buffer_editor.clone()), None, false, cx); - }) - .unwrap(); - cx.executor().run_until_parked(); - buffer_editor.update(cx, |buffer_editor, cx| { - buffer_editor.fold_at_level(&FoldAtLevel { level: 1 }, cx); - assert!(buffer_editor.snapshot(cx).fold_count() == 1); - }); - cx.executor().run_until_parked(); - multi_buffer_editor.update(cx, |multi_buffer_editor, cx| { - multi_buffer_editor.change_selections(None, cx, |s| s.select_ranges([3..4])); - multi_buffer_editor.open_excerpts(&OpenExcerpts, cx); - }); - cx.executor().run_until_parked(); - buffer_editor.update(cx, |buffer_editor, cx| { - assert!(buffer_editor.snapshot(cx).fold_count() == 0); - }); -} - #[gpui::test] async fn test_toggle_hunk_diff(executor: BackgroundExecutor, cx: &mut gpui::TestAppContext) { init_test(cx, |_| {}); From 9487fffc55f47562b361c4f66273c73b8ef8041e Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Thu, 5 Dec 2024 13:31:35 +0530 Subject: [PATCH 166/215] Fix snippet completion will be trigger, when certain symbols are pressed (#21578) Closes #21576 This issue is caused by the fuzzy matching for snippets I added [here](https://github.com/zed-industries/zed/pull/21524). When encountering symbols such as `:`, `(`, `.`, etc., the `last_word` becomes empty, which results in an empty string being passed to `fuzzy_match`, leading to the return of all templates. This fix adds an early return when `last_word` is empty. Release Notes: - N/A --- crates/editor/src/editor.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c20282fa02..bb4a2788a7 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -13873,6 +13873,11 @@ fn snippet_completions( .take_while(|c| classifier.is_word(*c)) .collect::(); last_word = last_word.chars().rev().collect(); + + if last_word.is_empty() { + return Ok(vec![]); + } + let as_offset = text::ToOffset::to_offset(&buffer_position, &snapshot); let to_lsp = |point: &text::Anchor| { let end = text::ToPointUtf16::to_point_utf16(point, &snapshot); From 78fea0dd8ef4d84ad6a287f4a1fd40a4fbcb4966 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 5 Dec 2024 11:55:06 +0200 Subject: [PATCH 167/215] Defer is_staff check for the project_diff::Deploy action (#21582) During workspace registration, it's too early to check for the `is_staff` flag due to no connection being established yet. As a compromise, allow the action to appear and be registered, but do nothing for non-staff users. Release Notes: - N/A --- crates/editor/src/git/project_diff.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index 2c60ae4204..e3d9f6abd6 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -62,13 +62,15 @@ struct Changes { } impl ProjectDiffEditor { - fn register(workspace: &mut Workspace, cx: &mut ViewContext) { - if cx.is_staff() { - workspace.register_action(Self::deploy); - } + fn register(workspace: &mut Workspace, _: &mut ViewContext) { + workspace.register_action(Self::deploy); } fn deploy(workspace: &mut Workspace, _: &Deploy, cx: &mut ViewContext) { + if !cx.is_staff() { + return; + } + if let Some(existing) = workspace.item_of_type::(cx) { workspace.activate_item(&existing, true, true, cx); } else { From 7335f211fdc8503666cf11bfb14fd3ff2b288db1 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Thu, 5 Dec 2024 08:07:13 -0500 Subject: [PATCH 168/215] Add Project Panel navigation actions in netrw mode (#20941) Release Notes: - Added "[ c" & "] c" To select prev/next git modified file within the project panel - Added "[ d" & "] d" To select prev/next file with diagnostics from an LSP within the project panel - Added "{" & "}" To select prev/next directory within the project panel Note: I wanted to extend project panel's functionality when netrw is active so I added some shortcuts that I believe will be helpful for most users. I tried to keep the default key mappings for the shortcuts inline with Zed's vim mode. ## Selecting prev/next modified git file https://github.com/user-attachments/assets/a9c057c7-1015-444f-b273-6d52ac54aa9c ## Selecting prev/next diagnostics https://github.com/user-attachments/assets/d1fb04ac-02c6-477c-b751-90a11bb42a78 ## Selecting prev/next directories (Only works with visible directoires) https://github.com/user-attachments/assets/9e96371e-105f-4fe9-bbf7-58f4a529f0dd --- assets/keymaps/vim.json | 6 + crates/project_panel/src/project_panel.rs | 512 +++++++++++++++++++++- crates/project_panel/src/utils.rs | 42 ++ 3 files changed, 559 insertions(+), 1 deletion(-) create mode 100644 crates/project_panel/src/utils.rs diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index c80a6912cc..8931ad0dca 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -659,6 +659,12 @@ "p": "project_panel::Open", "x": "project_panel::RevealInFileManager", "s": "project_panel::OpenWithSystem", + "] c": "project_panel::SelectNextGitEntry", + "[ c": "project_panel::SelectPrevGitEntry", + "] d": "project_panel::SelectNextDiagnostic", + "[ d": "project_panel::SelectPrevDiagnostic", + "}": "project_panel::SelectNextDirectory", + "{": "project_panel::SelectPrevDirectory", "shift-g": "menu::SelectLast", "g g": "menu::SelectFirst", "-": "project_panel::SelectParent", diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 3ef9f1905d..d263c75ca7 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1,4 +1,5 @@ mod project_panel_settings; +mod utils; use client::{ErrorCode, ErrorExt}; use language::DiagnosticSeverity; @@ -56,7 +57,7 @@ use ui::{ IndentGuideColors, IndentGuideLayout, KeyBinding, Label, ListItem, Scrollbar, ScrollbarState, Tooltip, }; -use util::{maybe, paths::compare_paths, ResultExt, TryFutureExt}; +use util::{maybe, paths::compare_paths, ResultExt, TakeUntilExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, notifications::{DetachAndPromptErr, NotifyTaskExt}, @@ -192,6 +193,12 @@ actions!( UnfoldDirectory, FoldDirectory, SelectParent, + SelectNextGitEntry, + SelectPrevGitEntry, + SelectNextDiagnostic, + SelectPrevDiagnostic, + SelectNextDirectory, + SelectPrevDirectory, ] ); @@ -1489,6 +1496,176 @@ impl ProjectPanel { } } + fn select_prev_diagnostic(&mut self, _: &SelectPrevDiagnostic, cx: &mut ViewContext) { + let selection = self.find_entry( + self.selection.as_ref(), + true, + |entry, worktree_id| { + (self.selection.is_none() + || self.selection.is_some_and(|selection| { + if selection.worktree_id == worktree_id { + selection.entry_id != entry.id + } else { + true + } + })) + && entry.is_file() + && self + .diagnostics + .contains_key(&(worktree_id, entry.path.to_path_buf())) + }, + cx, + ); + + if let Some(selection) = selection { + self.selection = Some(selection); + self.expand_entry(selection.worktree_id, selection.entry_id, cx); + self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx); + self.autoscroll(cx); + cx.notify(); + } + } + + fn select_next_diagnostic(&mut self, _: &SelectNextDiagnostic, cx: &mut ViewContext) { + let selection = self.find_entry( + self.selection.as_ref(), + false, + |entry, worktree_id| { + (self.selection.is_none() + || self.selection.is_some_and(|selection| { + if selection.worktree_id == worktree_id { + selection.entry_id != entry.id + } else { + true + } + })) + && entry.is_file() + && self + .diagnostics + .contains_key(&(worktree_id, entry.path.to_path_buf())) + }, + cx, + ); + + if let Some(selection) = selection { + self.selection = Some(selection); + self.expand_entry(selection.worktree_id, selection.entry_id, cx); + self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx); + self.autoscroll(cx); + cx.notify(); + } + } + + fn select_prev_git_entry(&mut self, _: &SelectPrevGitEntry, cx: &mut ViewContext) { + let selection = self.find_entry( + self.selection.as_ref(), + true, + |entry, worktree_id| { + (self.selection.is_none() + || self.selection.is_some_and(|selection| { + if selection.worktree_id == worktree_id { + selection.entry_id != entry.id + } else { + true + } + })) + && entry.is_file() + && entry + .git_status + .is_some_and(|status| matches!(status, GitFileStatus::Modified)) + }, + cx, + ); + + if let Some(selection) = selection { + self.selection = Some(selection); + self.expand_entry(selection.worktree_id, selection.entry_id, cx); + self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx); + self.autoscroll(cx); + cx.notify(); + } + } + + fn select_prev_directory(&mut self, _: &SelectPrevDirectory, cx: &mut ViewContext) { + let selection = self.find_visible_entry( + self.selection.as_ref(), + true, + |entry, worktree_id| { + (self.selection.is_none() + || self.selection.is_some_and(|selection| { + if selection.worktree_id == worktree_id { + selection.entry_id != entry.id + } else { + true + } + })) + && entry.is_dir() + }, + cx, + ); + + if let Some(selection) = selection { + self.selection = Some(selection); + self.autoscroll(cx); + cx.notify(); + } + } + + fn select_next_directory(&mut self, _: &SelectNextDirectory, cx: &mut ViewContext) { + let selection = self.find_visible_entry( + self.selection.as_ref(), + false, + |entry, worktree_id| { + (self.selection.is_none() + || self.selection.is_some_and(|selection| { + if selection.worktree_id == worktree_id { + selection.entry_id != entry.id + } else { + true + } + })) + && entry.is_dir() + }, + cx, + ); + + if let Some(selection) = selection { + self.selection = Some(selection); + self.autoscroll(cx); + cx.notify(); + } + } + + fn select_next_git_entry(&mut self, _: &SelectNextGitEntry, cx: &mut ViewContext) { + let selection = self.find_entry( + self.selection.as_ref(), + true, + |entry, worktree_id| { + (self.selection.is_none() + || self.selection.is_some_and(|selection| { + if selection.worktree_id == worktree_id { + selection.entry_id != entry.id + } else { + true + } + })) + && entry.is_file() + && entry + .git_status + .is_some_and(|status| matches!(status, GitFileStatus::Modified)) + }, + cx, + ); + + if let Some(selection) = selection { + self.selection = Some(selection); + self.expand_entry(selection.worktree_id, selection.entry_id, cx); + self.update_visible_entries(Some((selection.worktree_id, selection.entry_id)), cx); + self.autoscroll(cx); + cx.notify(); + } + } + fn select_parent(&mut self, _: &SelectParent, cx: &mut ViewContext) { if let Some((worktree, entry)) = self.selected_sub_entry(cx) { if let Some(parent) = entry.path.parent() { @@ -2705,6 +2882,232 @@ impl ProjectPanel { } } + fn find_entry_in_worktree( + &self, + worktree_id: WorktreeId, + reverse_search: bool, + only_visible_entries: bool, + predicate: impl Fn(&Entry, WorktreeId) -> bool, + cx: &mut ViewContext, + ) -> Option { + if only_visible_entries { + let entries = self + .visible_entries + .iter() + .find_map(|(tree_id, entries, _)| { + if worktree_id == *tree_id { + Some(entries) + } else { + None + } + })? + .clone(); + + return utils::ReversibleIterable::new(entries.iter(), reverse_search) + .find(|ele| predicate(ele, worktree_id)) + .cloned(); + } + + let worktree = self.project.read(cx).worktree_for_id(worktree_id, cx)?; + worktree.update(cx, |tree, _| { + utils::ReversibleIterable::new(tree.entries(true, 0usize), reverse_search) + .find_single_ended(|ele| predicate(ele, worktree_id)) + .cloned() + }) + } + + fn find_entry( + &self, + start: Option<&SelectedEntry>, + reverse_search: bool, + predicate: impl Fn(&Entry, WorktreeId) -> bool, + cx: &mut ViewContext, + ) -> Option { + let mut worktree_ids: Vec<_> = self + .visible_entries + .iter() + .map(|(worktree_id, _, _)| *worktree_id) + .collect(); + + let mut last_found: Option = None; + + if let Some(start) = start { + let worktree = self + .project + .read(cx) + .worktree_for_id(start.worktree_id, cx)?; + + let search = worktree.update(cx, |tree, _| { + let entry = tree.entry_for_id(start.entry_id)?; + let root_entry = tree.root_entry()?; + let tree_id = tree.id(); + + let mut first_iter = tree.traverse_from_path(true, true, true, entry.path.as_ref()); + + if reverse_search { + first_iter.next(); + } + + let first = first_iter + .enumerate() + .take_until(|(count, ele)| *ele == root_entry && *count != 0usize) + .map(|(_, ele)| ele) + .find(|ele| predicate(ele, tree_id)) + .cloned(); + + let second_iter = tree.entries(true, 0usize); + + let second = if reverse_search { + second_iter + .take_until(|ele| ele.id == start.entry_id) + .filter(|ele| predicate(ele, tree_id)) + .last() + .cloned() + } else { + second_iter + .take_while(|ele| ele.id != start.entry_id) + .filter(|ele| predicate(ele, tree_id)) + .last() + .cloned() + }; + + if reverse_search { + Some((second, first)) + } else { + Some((first, second)) + } + }); + + if let Some((first, second)) = search { + let first = first.map(|entry| SelectedEntry { + worktree_id: start.worktree_id, + entry_id: entry.id, + }); + + let second = second.map(|entry| SelectedEntry { + worktree_id: start.worktree_id, + entry_id: entry.id, + }); + + if first.is_some() { + return first; + } + last_found = second; + + let idx = worktree_ids + .iter() + .enumerate() + .find(|(_, ele)| **ele == start.worktree_id) + .map(|(idx, _)| idx); + + if let Some(idx) = idx { + worktree_ids.rotate_left(idx + 1usize); + worktree_ids.pop(); + } + } + } + + for tree_id in worktree_ids.into_iter() { + if let Some(found) = + self.find_entry_in_worktree(tree_id, reverse_search, false, &predicate, cx) + { + return Some(SelectedEntry { + worktree_id: tree_id, + entry_id: found.id, + }); + } + } + + last_found + } + + fn find_visible_entry( + &self, + start: Option<&SelectedEntry>, + reverse_search: bool, + predicate: impl Fn(&Entry, WorktreeId) -> bool, + cx: &mut ViewContext, + ) -> Option { + let mut worktree_ids: Vec<_> = self + .visible_entries + .iter() + .map(|(worktree_id, _, _)| *worktree_id) + .collect(); + + let mut last_found: Option = None; + + if let Some(start) = start { + let entries = self + .visible_entries + .iter() + .find(|(worktree_id, _, _)| *worktree_id == start.worktree_id) + .map(|(_, entries, _)| entries)?; + + let mut start_idx = entries + .iter() + .enumerate() + .find(|(_, ele)| ele.id == start.entry_id) + .map(|(idx, _)| idx)?; + + if reverse_search { + start_idx = start_idx.saturating_add(1usize); + } + + let (left, right) = entries.split_at_checked(start_idx)?; + + let (first_iter, second_iter) = if reverse_search { + ( + utils::ReversibleIterable::new(left.iter(), reverse_search), + utils::ReversibleIterable::new(right.iter(), reverse_search), + ) + } else { + ( + utils::ReversibleIterable::new(right.iter(), reverse_search), + utils::ReversibleIterable::new(left.iter(), reverse_search), + ) + }; + + let first_search = first_iter.find(|ele| predicate(ele, start.worktree_id)); + let second_search = second_iter.find(|ele| predicate(ele, start.worktree_id)); + + if first_search.is_some() { + return first_search.map(|entry| SelectedEntry { + worktree_id: start.worktree_id, + entry_id: entry.id, + }); + } + + last_found = second_search.map(|entry| SelectedEntry { + worktree_id: start.worktree_id, + entry_id: entry.id, + }); + + let idx = worktree_ids + .iter() + .enumerate() + .find(|(_, ele)| **ele == start.worktree_id) + .map(|(idx, _)| idx); + + if let Some(idx) = idx { + worktree_ids.rotate_left(idx + 1usize); + worktree_ids.pop(); + } + } + + for tree_id in worktree_ids.into_iter() { + if let Some(found) = + self.find_entry_in_worktree(tree_id, reverse_search, true, &predicate, cx) + { + return Some(SelectedEntry { + worktree_id: tree_id, + entry_id: found.id, + }); + } + } + + last_found + } + fn calculate_depth_and_difference( entry: &Entry, visible_worktree_entries: &HashSet>, @@ -3482,6 +3885,12 @@ impl Render for ProjectPanel { .on_action(cx.listener(Self::select_first)) .on_action(cx.listener(Self::select_last)) .on_action(cx.listener(Self::select_parent)) + .on_action(cx.listener(Self::select_next_git_entry)) + .on_action(cx.listener(Self::select_prev_git_entry)) + .on_action(cx.listener(Self::select_next_diagnostic)) + .on_action(cx.listener(Self::select_prev_diagnostic)) + .on_action(cx.listener(Self::select_next_directory)) + .on_action(cx.listener(Self::select_prev_directory)) .on_action(cx.listener(Self::expand_selected_entry)) .on_action(cx.listener(Self::collapse_selected_entry)) .on_action(cx.listener(Self::collapse_all_entries)) @@ -5606,6 +6015,107 @@ mod tests { ); } + #[gpui::test] + async fn test_select_directory(cx: &mut gpui::TestAppContext) { + init_test_with_editor(cx); + + let fs = FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/project_root", + json!({ + "dir_1": { + "nested_dir": { + "file_a.py": "# File contents", + } + }, + "file_1.py": "# File contents", + "dir_2": { + + }, + "dir_3": { + + }, + "file_2.py": "# File contents", + "dir_4": { + + }, + }), + ) + .await; + + let project = Project::test(fs.clone(), ["/project_root".as_ref()], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace, cx); + let panel = workspace.update(cx, ProjectPanel::new).unwrap(); + + panel.update(cx, |panel, cx| panel.open(&Open, cx)); + cx.executor().run_until_parked(); + select_path(&panel, "project_root/dir_1", cx); + cx.executor().run_until_parked(); + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " > dir_1 <== selected", + " > dir_2", + " > dir_3", + " > dir_4", + " file_1.py", + " file_2.py", + ] + ); + panel.update(cx, |panel, cx| { + panel.select_prev_directory(&SelectPrevDirectory, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root <== selected", + " > dir_1", + " > dir_2", + " > dir_3", + " > dir_4", + " file_1.py", + " file_2.py", + ] + ); + + panel.update(cx, |panel, cx| { + panel.select_prev_directory(&SelectPrevDirectory, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root", + " > dir_1", + " > dir_2", + " > dir_3", + " > dir_4 <== selected", + " file_1.py", + " file_2.py", + ] + ); + + panel.update(cx, |panel, cx| { + panel.select_next_directory(&SelectNextDirectory, cx) + }); + + assert_eq!( + visible_entries_as_strings(&panel, 0..10, cx), + &[ + "v project_root <== selected", + " > dir_1", + " > dir_2", + " > dir_3", + " > dir_4", + " file_1.py", + " file_2.py", + ] + ); + } + #[gpui::test] async fn test_dir_toggle_collapse(cx: &mut gpui::TestAppContext) { init_test_with_editor(cx); diff --git a/crates/project_panel/src/utils.rs b/crates/project_panel/src/utils.rs new file mode 100644 index 0000000000..486def9b84 --- /dev/null +++ b/crates/project_panel/src/utils.rs @@ -0,0 +1,42 @@ +pub(crate) struct ReversibleIterable { + pub(crate) it: It, + pub(crate) reverse: bool, +} + +impl ReversibleIterable { + pub(crate) fn new(it: T, reverse: bool) -> Self { + Self { it, reverse } + } +} + +impl ReversibleIterable +where + It: Iterator, +{ + pub(crate) fn find_single_ended(mut self, pred: F) -> Option + where + F: FnMut(&Item) -> bool, + { + if self.reverse { + self.it.filter(pred).last() + } else { + self.it.find(pred) + } + } +} + +impl ReversibleIterable +where + It: DoubleEndedIterator, +{ + pub(crate) fn find(mut self, mut pred: F) -> Option + where + F: FnMut(&Item) -> bool, + { + if self.reverse { + self.it.rfind(|x| pred(x)) + } else { + self.it.find(|x| pred(x)) + } + } +} From 92dea066dd88f567130ce9b5f47967f9ff44ada7 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Thu, 5 Dec 2024 09:33:46 -0500 Subject: [PATCH 169/215] Extend filtering of backtrace frames a bit (#21573) Both rust_begin_unwind and _rust_begin_unwind appear in practice, not sure why. Release Notes: - N/A --- crates/zed/src/reliability.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/zed/src/reliability.rs b/crates/zed/src/reliability.rs index 681cc9834f..837db9df60 100644 --- a/crates/zed/src/reliability.rs +++ b/crates/zed/src/reliability.rs @@ -85,7 +85,7 @@ pub fn init_panic_hook( // Strip out leading stack frames for rust panic-handling. if let Some(ix) = backtrace .iter() - .position(|name| name == "rust_begin_unwind") + .position(|name| name == "rust_begin_unwind" || name == "_rust_begin_unwind") { backtrace.drain(0..=ix); } From 6ebd6c28931640ae71123ecd052b78907115e421 Mon Sep 17 00:00:00 2001 From: Nils Koch Date: Thu, 5 Dec 2024 15:43:04 +0100 Subject: [PATCH 170/215] Show error and warning indicators in tabs (#21383) Closes #21179 Release Notes: - Add setting to display error and warning indicators in tabs. demo_with_icons demo_without_icons --- Cargo.lock | 1 - assets/settings/default.json | 12 ++- crates/workspace/Cargo.toml | 1 - crates/workspace/src/item.rs | 15 ++++ crates/workspace/src/pane.rs | 140 ++++++++++++++++++++++++++--------- 5 files changed, 131 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6a6bbc36aa..be4e11263d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15195,7 +15195,6 @@ dependencies = [ "env_logger 0.11.5", "fs", "futures 0.3.31", - "git", "gpui", "http_client", "itertools 0.13.0", diff --git a/assets/settings/default.json b/assets/settings/default.json index db3b7130e0..dd9098e0c0 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -567,7 +567,17 @@ // "History" // 2. Activate the neighbour tab (prefers the right one, if present) // "Neighbour" - "activate_on_close": "history" + "activate_on_close": "history", + /// Which files containing diagnostic errors/warnings to mark in the tabs. + /// This setting can take the following three values: + /// + /// 1. Do not mark any files: + /// "off" + /// 2. Only mark files with errors: + /// "errors" + /// 3. Mark files with errors and warnings: + /// "all" + "show_diagnostics": "all" }, // Settings related to preview tabs. "preview_tabs": { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 1fa4db2af8..3b17ed8dab 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -38,7 +38,6 @@ db.workspace = true derive_more.workspace = true fs.workspace = true futures.workspace = true -git.workspace = true gpui.workspace = true http_client.workspace = true itertools.workspace = true diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index eab3ddc755..97c27b52a1 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -42,6 +42,7 @@ pub struct ItemSettings { pub close_position: ClosePosition, pub activate_on_close: ActivateOnClose, pub file_icons: bool, + pub show_diagnostics: ShowDiagnostics, pub always_show_close_button: bool, } @@ -60,6 +61,15 @@ pub enum ClosePosition { Right, } +#[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ShowDiagnostics { + Off, + Errors, + #[default] + All, +} + #[derive(Clone, Default, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "lowercase")] pub enum ActivateOnClose { @@ -86,6 +96,11 @@ pub struct ItemSettingsContent { /// /// Default: history pub activate_on_close: Option, + /// Which files containing diagnostic errors/warnings to mark in the tabs. + /// This setting can take the following three values: + /// + /// Default: all + show_diagnostics: Option, /// Whether to always show the close button on tabs. /// /// Default: false diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index a2c63addd8..c0a80cc943 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -1,7 +1,7 @@ use crate::{ item::{ ActivateOnClose, ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings, - TabContentParams, WeakItemHandle, + ShowDiagnostics, TabContentParams, WeakItemHandle, }, move_item, notifications::NotifyResultExt, @@ -13,7 +13,6 @@ use crate::{ use anyhow::Result; use collections::{BTreeSet, HashMap, HashSet, VecDeque}; use futures::{stream::FuturesUnordered, StreamExt}; -use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, impl_actions, prelude::*, Action, AnchorCorner, AnyElement, AppContext, AsyncWindowContext, ClickEvent, ClipboardItem, Div, DragMoveEvent, EntityId, @@ -23,6 +22,7 @@ use gpui::{ WindowContext, }; use itertools::Itertools; +use language::DiagnosticSeverity; use parking_lot::Mutex; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; use serde::Deserialize; @@ -39,10 +39,10 @@ use std::{ }, }; use theme::ThemeSettings; - use ui::{ - prelude::*, right_click_menu, ButtonSize, Color, IconButton, IconButtonShape, IconName, - IconSize, Indicator, Label, PopoverMenu, PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, + prelude::*, right_click_menu, ButtonSize, Color, DecoratedIcon, IconButton, IconButtonShape, + IconDecoration, IconDecorationKind, IconName, IconSize, Indicator, Label, PopoverMenu, + PopoverMenuHandle, Tab, TabBar, TabPosition, Tooltip, }; use ui::{v_flex, ContextMenu}; use util::{debug_panic, maybe, truncate_and_remove_front, ResultExt}; @@ -305,6 +305,7 @@ pub struct Pane { pub new_item_context_menu_handle: PopoverMenuHandle, pub split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, + diagnostics: HashMap, } pub struct ActivationHistoryEntry { @@ -381,6 +382,7 @@ impl Pane { cx.on_focus_in(&focus_handle, Pane::focus_in), cx.on_focus_out(&focus_handle, Pane::focus_out), cx.observe_global::(Self::settings_changed), + cx.subscribe(&project, Self::project_events), ]; let handle = cx.view().downgrade(); @@ -504,6 +506,7 @@ impl Pane { split_item_context_menu_handle: Default::default(), new_item_context_menu_handle: Default::default(), pinned_tab_count: 0, + diagnostics: Default::default(), } } @@ -598,6 +601,47 @@ impl Pane { cx.notify(); } + fn project_events( + this: &mut Pane, + _project: Model, + event: &project::Event, + cx: &mut ViewContext, + ) { + match event { + project::Event::DiskBasedDiagnosticsFinished { .. } + | project::Event::DiagnosticsUpdated { .. } => { + if ItemSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off { + this.update_diagnostics(cx); + cx.notify(); + } + } + _ => {} + } + } + + fn update_diagnostics(&mut self, cx: &mut ViewContext) { + let show_diagnostics = ItemSettings::get_global(cx).show_diagnostics; + self.diagnostics = if show_diagnostics != ShowDiagnostics::Off { + self.project + .read(cx) + .diagnostic_summaries(false, cx) + .filter_map(|(project_path, _, diagnostic_summary)| { + if diagnostic_summary.error_count > 0 { + Some((project_path, DiagnosticSeverity::ERROR)) + } else if diagnostic_summary.warning_count > 0 + && show_diagnostics != ShowDiagnostics::Errors + { + Some((project_path, DiagnosticSeverity::WARNING)) + } else { + None + } + }) + .collect::>() + } else { + Default::default() + } + } + fn settings_changed(&mut self, cx: &mut ViewContext) { if let Some(display_nav_history_buttons) = self.display_nav_history_buttons.as_mut() { *display_nav_history_buttons = TabBarSettings::get_global(cx).show_nav_history_buttons; @@ -605,6 +649,7 @@ impl Pane { if !PreviewTabsSettings::get_global(cx).enabled { self.preview_item_id = None; } + self.update_diagnostics(cx); cx.notify(); } @@ -1839,23 +1884,6 @@ impl Pane { } } - pub fn git_aware_icon_color( - git_status: Option, - ignored: bool, - selected: bool, - ) -> Color { - if ignored { - Color::Ignored - } else { - match git_status { - Some(GitFileStatus::Added) => Color::Created, - Some(GitFileStatus::Modified) => Color::Modified, - Some(GitFileStatus::Conflict) => Color::Conflict, - None => Self::icon_color(selected), - } - } - } - fn toggle_pin_tab(&mut self, _: &TogglePinTab, cx: &mut ViewContext<'_, Self>) { if self.items.is_empty() { return; @@ -1919,8 +1947,6 @@ impl Pane { focus_handle: &FocusHandle, cx: &mut ViewContext<'_, Pane>, ) -> impl IntoElement { - let project_path = item.project_path(cx); - let is_active = ix == self.active_item_index; let is_preview = self .preview_item_id @@ -1936,19 +1962,57 @@ impl Pane { cx, ); - let icon_color = if ItemSettings::get_global(cx).git_status { - project_path - .as_ref() - .and_then(|path| self.project.read(cx).entry_for_path(path, cx)) - .map(|entry| { - Self::git_aware_icon_color(entry.git_status, entry.is_ignored, is_active) - }) - .unwrap_or_else(|| Self::icon_color(is_active)) + let item_diagnostic = item + .project_path(cx) + .map_or(None, |project_path| self.diagnostics.get(&project_path)); + + let decorated_icon = item_diagnostic.map_or(None, |diagnostic| { + let icon = match item.tab_icon(cx) { + Some(icon) => icon, + None => return None, + }; + + let knockout_item_color = if is_active { + cx.theme().colors().tab_active_background + } else { + cx.theme().colors().tab_bar_background + }; + + let (icon_decoration, icon_color) = if matches!(diagnostic, &DiagnosticSeverity::ERROR) + { + (IconDecorationKind::X, Color::Error) + } else { + (IconDecorationKind::Triangle, Color::Warning) + }; + + Some(DecoratedIcon::new( + icon.size(IconSize::Small).color(Color::Muted), + Some( + IconDecoration::new(icon_decoration, knockout_item_color, cx) + .color(icon_color.color(cx)) + .position(Point { + x: px(-2.), + y: px(-2.), + }), + ), + )) + }); + + let icon = if decorated_icon.is_none() { + match item_diagnostic { + Some(&DiagnosticSeverity::ERROR) => { + Some(Icon::new(IconName::X).color(Color::Error)) + } + Some(&DiagnosticSeverity::WARNING) => { + Some(Icon::new(IconName::Triangle).color(Color::Warning)) + } + _ => item.tab_icon(cx).map(|icon| icon.color(Color::Muted)), + } + .map(|icon| icon.size(IconSize::Small)) } else { - Self::icon_color(is_active) + None }; - let icon = item.tab_icon(cx); let settings = ItemSettings::get_global(cx); let close_side = &settings.close_position; let always_show_close_button = settings.always_show_close_button; @@ -2078,7 +2142,13 @@ impl Pane { .child( h_flex() .gap_1() - .children(icon.map(|icon| icon.size(IconSize::Small).color(icon_color))) + .child(if let Some(decorated_icon) = decorated_icon { + div().child(decorated_icon.into_any_element()) + } else if let Some(icon) = icon { + div().child(icon.into_any_element()) + } else { + div() + }) .child(label), ); From 2d43ad12e6358c9a9b4c67bec201a6e13c09894e Mon Sep 17 00:00:00 2001 From: Thorsten Ball Date: Thu, 5 Dec 2024 18:55:40 +0100 Subject: [PATCH 171/215] git: Make worktrees work for bare git repositories (#21596) Fixes #21210 by ensuring that Zed can open worktrees of bare git repositories. Co-authored-by: Peter Tripp --- crates/worktree/src/worktree.rs | 33 ++++++++++++++++++++++----- crates/worktree/src/worktree_tests.rs | 22 ++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index a9762b942b..86981687ce 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -3110,12 +3110,8 @@ impl BackgroundScannerState { let repository = fs.open_repo(&dot_git_abs_path)?; let actual_repo_path = repository.path(); - let actual_dot_git_dir_abs_path: Arc = Arc::from( - actual_repo_path - .ancestors() - .find(|ancestor| ancestor.file_name() == Some(&*DOT_GIT))?, - ); + let actual_dot_git_dir_abs_path = smol::block_on(find_git_dir(&actual_repo_path, fs))?; watcher.add(&actual_repo_path).log_err()?; let dot_git_worktree_abs_path = if actual_dot_git_dir_abs_path.as_ref() == dot_git_abs_path @@ -3161,6 +3157,31 @@ impl BackgroundScannerState { } } +async fn is_git_dir(path: &Path, fs: &dyn Fs) -> bool { + if path.file_name() == Some(&*DOT_GIT) { + return true; + } + + // If we're in a bare repository, we are not inside a `.git` folder. In a + // bare repository, the root folder contains what would normally be in the + // `.git` folder. + let head_metadata = fs.metadata(&path.join("HEAD")).await; + if !matches!(head_metadata, Ok(Some(_))) { + return false; + } + let config_metadata = fs.metadata(&path.join("config")).await; + matches!(config_metadata, Ok(Some(_))) +} + +async fn find_git_dir(path: &Path, fs: &dyn Fs) -> Option> { + for ancestor in path.ancestors() { + if is_git_dir(ancestor, fs).await { + return Some(Arc::from(ancestor)); + } + } + None +} + async fn build_gitignore(abs_path: &Path, fs: &dyn Fs) -> Result { let contents = fs.load(abs_path).await?; let parent = abs_path.parent().unwrap_or_else(|| Path::new("/")); @@ -3967,7 +3988,7 @@ impl BackgroundScanner { } else if fsmonitor_parse_state == Some(FsMonitorParseState::Cookies) && file_name == Some(*FSMONITOR_DAEMON) { fsmonitor_parse_state = Some(FsMonitorParseState::FsMonitor); false - } else if fsmonitor_parse_state != Some(FsMonitorParseState::FsMonitor) && file_name == Some(*DOT_GIT) { + } else if fsmonitor_parse_state != Some(FsMonitorParseState::FsMonitor) && smol::block_on(is_git_dir(ancestor, self.fs.as_ref())) { true } else { fsmonitor_parse_state.take(); diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index fbedd896e3..121caf0b7b 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -12,7 +12,13 @@ use pretty_assertions::assert_eq; use rand::prelude::*; use serde_json::json; use settings::{Settings, SettingsStore}; -use std::{env, fmt::Write, mem, path::Path, sync::Arc}; +use std::{ + env, + fmt::Write, + mem, + path::{Path, PathBuf}, + sync::Arc, +}; use util::{test::temp_tree, ResultExt}; #[gpui::test] @@ -532,14 +538,20 @@ async fn test_open_gitignored_files(cx: &mut TestAppContext) { assert_eq!(fs.read_dir_call_count() - prev_read_dir_count, 1); }); + let path = PathBuf::from("/root/one/node_modules/c/lib"); + // No work happens when files and directories change within an unloaded directory. let prev_fs_call_count = fs.read_dir_call_count() + fs.metadata_call_count(); - fs.create_dir("/root/one/node_modules/c/lib".as_ref()) - .await - .unwrap(); + // When we open a directory, we check each ancestor whether it's a git + // repository. That means we have an fs.metadata call per ancestor that we + // need to subtract here. + let ancestors = path.ancestors().count(); + + fs.create_dir(path.as_ref()).await.unwrap(); cx.executor().run_until_parked(); + assert_eq!( - fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count, + fs.read_dir_call_count() + fs.metadata_call_count() - prev_fs_call_count - ancestors, 0 ); } From 787c75cbda8b0ea3ad1bc036329f3826ae6b9e8f Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 5 Dec 2024 13:22:25 -0500 Subject: [PATCH 172/215] assistant2: Add thread history (#21599) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds support for thread history to the Assistant 2 panel. We also now generate summaries for the threads. Screenshot 2024-12-05 at 12 56 53 PM Screenshot 2024-12-05 at 12 56 58 PM Release Notes: - N/A --------- Co-authored-by: Piotr --- Cargo.lock | 3 + crates/assistant2/Cargo.toml | 3 + crates/assistant2/src/active_thread.rs | 9 +- crates/assistant2/src/assistant.rs | 1 + crates/assistant2/src/assistant_panel.rs | 224 ++++++++++++----------- crates/assistant2/src/thread.rs | 102 +++++++++-- crates/assistant2/src/thread_history.rs | 144 +++++++++++++++ crates/assistant2/src/thread_store.rs | 16 +- 8 files changed, 375 insertions(+), 127 deletions(-) create mode 100644 crates/assistant2/src/thread_history.rs diff --git a/Cargo.lock b/Cargo.lock index be4e11263d..bd3cd06dca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -456,6 +456,7 @@ version = "0.1.0" dependencies = [ "anyhow", "assistant_tool", + "chrono", "client", "collections", "command_palette_hooks", @@ -478,6 +479,8 @@ dependencies = [ "settings", "smol", "theme", + "time", + "time_format", "ui", "unindent", "util", diff --git a/crates/assistant2/Cargo.toml b/crates/assistant2/Cargo.toml index e5253adbce..b5f5fe8ecd 100644 --- a/crates/assistant2/Cargo.toml +++ b/crates/assistant2/Cargo.toml @@ -15,6 +15,7 @@ doctest = false [dependencies] anyhow.workspace = true assistant_tool.workspace = true +chrono.workspace = true client.workspace = true collections.workspace = true command_palette_hooks.workspace = true @@ -37,6 +38,8 @@ serde_json.workspace = true settings.workspace = true smol.workspace = true theme.workspace = true +time.workspace = true +time_format.workspace = true ui.workspace = true unindent.workspace = true util.workspace = true diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index 13b67dc437..d9cd8fcc46 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -3,8 +3,8 @@ use std::sync::Arc; use assistant_tool::ToolWorkingSet; use collections::HashMap; use gpui::{ - list, AnyElement, Empty, ListAlignment, ListState, Model, StyleRefinement, Subscription, - TextStyleRefinement, View, WeakView, + list, AnyElement, AppContext, Empty, ListAlignment, ListState, Model, StyleRefinement, + Subscription, TextStyleRefinement, View, WeakView, }; use language::LanguageRegistry; use language_model::Role; @@ -70,6 +70,10 @@ impl ActiveThread { self.messages.is_empty() } + pub fn summary(&self, cx: &AppContext) -> Option { + self.thread.read(cx).summary() + } + pub fn last_error(&self) -> Option { self.last_error.clone() } @@ -139,6 +143,7 @@ impl ActiveThread { self.last_error = Some(error.clone()); } ThreadEvent::StreamedCompletion => {} + ThreadEvent::SummaryChanged => {} ThreadEvent::StreamedAssistantText(message_id, text) => { if let Some(markdown) = self.rendered_messages_by_id.get_mut(&message_id) { markdown.update(cx, |markdown, cx| { diff --git a/crates/assistant2/src/assistant.rs b/crates/assistant2/src/assistant.rs index 13ac2d821b..3c8520680e 100644 --- a/crates/assistant2/src/assistant.rs +++ b/crates/assistant2/src/assistant.rs @@ -3,6 +3,7 @@ mod assistant_panel; mod context_picker; mod message_editor; mod thread; +mod thread_history; mod thread_store; use command_palette_hooks::CommandPaletteFilter; diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index d17480cd0e..2d9f563c2f 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -11,13 +11,15 @@ use gpui::{ use language::LanguageRegistry; use language_model::LanguageModelRegistry; use language_model_selector::LanguageModelSelector; -use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, KeyBinding, ListItem, Tab, Tooltip}; +use time::UtcOffset; +use ui::{prelude::*, ButtonLike, Divider, IconButtonShape, KeyBinding, Tab, Tooltip}; use workspace::dock::{DockPosition, Panel, PanelEvent}; use workspace::Workspace; use crate::active_thread::ActiveThread; use crate::message_editor::MessageEditor; -use crate::thread::{Thread, ThreadError, ThreadId}; +use crate::thread::{ThreadError, ThreadId}; +use crate::thread_history::{PastThread, ThreadHistory}; use crate::thread_store::ThreadStore; use crate::{NewThread, OpenHistory, ToggleFocus, ToggleModelSelector}; @@ -32,13 +34,21 @@ pub fn init(cx: &mut AppContext) { .detach(); } +enum ActiveView { + Thread, + History, +} + pub struct AssistantPanel { workspace: WeakView, language_registry: Arc, thread_store: Model, - thread: Option>, + thread: View, message_editor: View, tools: Arc, + local_timezone: UtcOffset, + active_view: ActiveView, + history: View, } impl AssistantPanel { @@ -68,14 +78,31 @@ impl AssistantPanel { cx: &mut ViewContext, ) -> Self { let thread = thread_store.update(cx, |this, cx| this.create_thread(cx)); + let language_registry = workspace.project().read(cx).languages().clone(); + let workspace = workspace.weak_handle(); + let weak_self = cx.view().downgrade(); Self { - workspace: workspace.weak_handle(), - language_registry: workspace.project().read(cx).languages().clone(), - thread_store, - thread: None, - message_editor: cx.new_view(|cx| MessageEditor::new(thread, cx)), + active_view: ActiveView::Thread, + workspace: workspace.clone(), + language_registry: language_registry.clone(), + thread_store: thread_store.clone(), + thread: cx.new_view(|cx| { + ActiveThread::new( + thread.clone(), + workspace, + language_registry, + tools.clone(), + cx, + ) + }), + message_editor: cx.new_view(|cx| MessageEditor::new(thread.clone(), cx)), tools, + local_timezone: UtcOffset::from_whole_seconds( + chrono::Local::now().offset().local_minus_utc(), + ) + .unwrap(), + history: cx.new_view(|cx| ThreadHistory::new(weak_self, thread_store, cx)), } } @@ -84,7 +111,8 @@ impl AssistantPanel { .thread_store .update(cx, |this, cx| this.create_thread(cx)); - self.thread = Some(cx.new_view(|cx| { + self.active_view = ActiveView::Thread; + self.thread = cx.new_view(|cx| { ActiveThread::new( thread.clone(), self.workspace.clone(), @@ -92,12 +120,12 @@ impl AssistantPanel { self.tools.clone(), cx, ) - })); + }); self.message_editor = cx.new_view(|cx| MessageEditor::new(thread, cx)); self.message_editor.focus_handle(cx).focus(cx); } - fn open_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { + pub(crate) fn open_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { let Some(thread) = self .thread_store .update(cx, |this, cx| this.open_thread(thread_id, cx)) @@ -105,7 +133,8 @@ impl AssistantPanel { return; }; - self.thread = Some(cx.new_view(|cx| { + self.active_view = ActiveView::Thread; + self.thread = cx.new_view(|cx| { ActiveThread::new( thread.clone(), self.workspace.clone(), @@ -113,15 +142,22 @@ impl AssistantPanel { self.tools.clone(), cx, ) - })); + }); self.message_editor = cx.new_view(|cx| MessageEditor::new(thread, cx)); self.message_editor.focus_handle(cx).focus(cx); } + + pub(crate) fn local_timezone(&self) -> UtcOffset { + self.local_timezone + } } impl FocusableView for AssistantPanel { fn focus_handle(&self, cx: &AppContext) -> FocusHandle { - self.message_editor.focus_handle(cx) + match self.active_view { + ActiveView::Thread => self.message_editor.focus_handle(cx), + ActiveView::History => self.history.focus_handle(cx), + } } } @@ -180,7 +216,7 @@ impl AssistantPanel { .bg(cx.theme().colors().tab_bar_background) .border_b_1() .border_color(cx.theme().colors().border_variant) - .child(h_flex().child(Label::new("Thread Title Goes Here"))) + .child(h_flex().children(self.thread.read(cx).summary(cx).map(Label::new))) .child( h_flex() .gap(DynamicSpacing::Base08.rems(cx)) @@ -291,15 +327,11 @@ impl AssistantPanel { } fn render_active_thread_or_empty_state(&self, cx: &mut ViewContext) -> AnyElement { - let Some(thread) = self.thread.as_ref() else { - return self.render_thread_empty_state(cx).into_any_element(); - }; - - if thread.read(cx).is_empty() { + if self.thread.read(cx).is_empty() { return self.render_thread_empty_state(cx).into_any_element(); } - thread.clone().into_any() + self.thread.clone().into_any() } fn render_thread_empty_state(&self, cx: &mut ViewContext) -> impl IntoElement { @@ -361,63 +393,41 @@ impl AssistantPanel { .child(Label::new("/src/components").size(LabelSize::Small)), ), ) - .child( - h_flex() - .w_full() - .justify_center() - .child(Label::new("Recent Threads:").size(LabelSize::Small)), - ) - .child( - v_flex().gap_2().children( - recent_threads - .into_iter() - .map(|thread| self.render_past_thread(thread, cx)), - ), - ) - .child( - h_flex().w_full().justify_center().child( - Button::new("view-all-past-threads", "View All Past Threads") - .style(ButtonStyle::Subtle) - .label_size(LabelSize::Small) - .key_binding(KeyBinding::for_action_in( - &OpenHistory, - &self.focus_handle(cx), - cx, - )) - .on_click(move |_event, cx| { - cx.dispatch_action(OpenHistory.boxed_clone()); - }), - ), - ) - } - - fn render_past_thread( - &self, - thread: Model, - cx: &mut ViewContext, - ) -> impl IntoElement { - let id = thread.read(cx).id().clone(); - - ListItem::new(("past-thread", thread.entity_id())) - .start_slot(Icon::new(IconName::MessageBubbles)) - .child(Label::new(format!("Thread {id}"))) - .end_slot( - h_flex() - .gap_2() - .child(Label::new("1 hour ago").color(Color::Disabled)) + .when(!recent_threads.is_empty(), |parent| { + parent .child( - IconButton::new("delete", IconName::TrashAlt) - .shape(IconButtonShape::Square) - .icon_size(IconSize::Small), - ), - ) - .on_click(cx.listener(move |this, _event, cx| { - this.open_thread(&id, cx); - })) + h_flex() + .w_full() + .justify_center() + .child(Label::new("Recent Threads:").size(LabelSize::Small)), + ) + .child( + v_flex().gap_2().children( + recent_threads + .into_iter() + .map(|thread| PastThread::new(thread, cx.view().downgrade())), + ), + ) + .child( + h_flex().w_full().justify_center().child( + Button::new("view-all-past-threads", "View All Past Threads") + .style(ButtonStyle::Subtle) + .label_size(LabelSize::Small) + .key_binding(KeyBinding::for_action_in( + &OpenHistory, + &self.focus_handle(cx), + cx, + )) + .on_click(move |_event, cx| { + cx.dispatch_action(OpenHistory.boxed_clone()); + }), + ), + ) + }) } fn render_last_error(&self, cx: &mut ViewContext) -> Option { - let last_error = self.thread.as_ref()?.read(cx).last_error()?; + let last_error = self.thread.read(cx).last_error()?; Some( div() @@ -467,11 +477,9 @@ impl AssistantPanel { .mt_1() .child(Button::new("subscribe", "Subscribe").on_click(cx.listener( |this, _, cx| { - if let Some(thread) = this.thread.as_ref() { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - } + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); cx.open_url(&zed_urls::account_url(cx)); cx.notify(); @@ -479,11 +487,9 @@ impl AssistantPanel { ))) .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( |this, _, cx| { - if let Some(thread) = this.thread.as_ref() { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - } + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); cx.notify(); }, @@ -518,11 +524,9 @@ impl AssistantPanel { .child( Button::new("subscribe", "Update Monthly Spend Limit").on_click( cx.listener(|this, _, cx| { - if let Some(thread) = this.thread.as_ref() { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - } + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); cx.open_url(&zed_urls::account_url(cx)); cx.notify(); @@ -531,11 +535,9 @@ impl AssistantPanel { ) .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( |this, _, cx| { - if let Some(thread) = this.thread.as_ref() { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - } + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); cx.notify(); }, @@ -574,11 +576,9 @@ impl AssistantPanel { .mt_1() .child(Button::new("dismiss", "Dismiss").on_click(cx.listener( |this, _, cx| { - if let Some(thread) = this.thread.as_ref() { - thread.update(cx, |this, _cx| { - this.clear_last_error(); - }); - } + this.thread.update(cx, |this, _cx| { + this.clear_last_error(); + }); cx.notify(); }, @@ -597,17 +597,23 @@ impl Render for AssistantPanel { .on_action(cx.listener(|this, _: &NewThread, cx| { this.new_thread(cx); })) - .on_action(cx.listener(|_this, _: &OpenHistory, _cx| { - println!("Open History"); + .on_action(cx.listener(|this, _: &OpenHistory, cx| { + this.active_view = ActiveView::History; + this.history.focus_handle(cx).focus(cx); + cx.notify(); })) .child(self.render_toolbar(cx)) - .child(self.render_active_thread_or_empty_state(cx)) - .child( - h_flex() - .border_t_1() - .border_color(cx.theme().colors().border_variant) - .child(self.message_editor.clone()), - ) - .children(self.render_last_error(cx)) + .map(|parent| match self.active_view { + ActiveView::Thread => parent + .child(self.render_active_thread_or_empty_state(cx)) + .child( + h_flex() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child(self.message_editor.clone()), + ) + .children(self.render_last_error(cx)), + ActiveView::History => parent.child(self.history.clone()), + }) } } diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 185719fa98..833f8c9b03 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -2,18 +2,19 @@ use std::sync::Arc; use anyhow::Result; use assistant_tool::ToolWorkingSet; +use chrono::{DateTime, Utc}; use collections::HashMap; use futures::future::Shared; use futures::{FutureExt as _, StreamExt as _}; use gpui::{AppContext, EventEmitter, ModelContext, SharedString, Task}; use language_model::{ - LanguageModel, LanguageModelCompletionEvent, LanguageModelRequest, LanguageModelRequestMessage, - LanguageModelToolResult, LanguageModelToolUse, LanguageModelToolUseId, MessageContent, Role, - StopReason, + LanguageModel, LanguageModelCompletionEvent, LanguageModelRegistry, LanguageModelRequest, + LanguageModelRequestMessage, LanguageModelToolResult, LanguageModelToolUse, + LanguageModelToolUseId, MessageContent, Role, StopReason, }; use language_models::provider::cloud::{MaxMonthlySpendReachedError, PaymentRequiredError}; use serde::{Deserialize, Serialize}; -use util::post_inc; +use util::{post_inc, TryFutureExt as _}; use uuid::Uuid; #[derive(Debug, Clone, Copy)] @@ -56,6 +57,9 @@ pub struct Message { /// A thread of conversation with the LLM. pub struct Thread { id: ThreadId, + updated_at: DateTime, + summary: Option, + pending_summary: Task>, messages: Vec, next_message_id: MessageId, completion_count: usize, @@ -70,6 +74,9 @@ impl Thread { pub fn new(tools: Arc, _cx: &mut ModelContext) -> Self { Self { id: ThreadId::new(), + updated_at: Utc::now(), + summary: None, + pending_summary: Task::ready(None), messages: Vec::new(), next_message_id: MessageId(0), completion_count: 0, @@ -89,6 +96,23 @@ impl Thread { self.messages.is_empty() } + pub fn updated_at(&self) -> DateTime { + self.updated_at + } + + pub fn touch_updated_at(&mut self) { + self.updated_at = Utc::now(); + } + + pub fn summary(&self) -> Option { + self.summary.clone() + } + + pub fn set_summary(&mut self, summary: impl Into, cx: &mut ModelContext) { + self.summary = Some(summary.into()); + cx.emit(ThreadEvent::SummaryChanged); + } + pub fn message(&self, id: MessageId) -> Option<&Message> { self.messages.iter().find(|message| message.id == id) } @@ -121,6 +145,7 @@ impl Thread { role, text: text.into(), }); + self.touch_updated_at(); cx.emit(ThreadEvent::MessageAdded(id)); } @@ -191,13 +216,7 @@ impl Thread { thread.update(&mut cx, |thread, cx| { match event { LanguageModelCompletionEvent::StartMessage { .. } => { - let id = thread.next_message_id.post_inc(); - thread.messages.push(Message { - id, - role: Role::Assistant, - text: String::new(), - }); - cx.emit(ThreadEvent::MessageAdded(id)); + thread.insert_message(Role::Assistant, String::new(), cx); } LanguageModelCompletionEvent::Stop(reason) => { stop_reason = reason; @@ -239,6 +258,7 @@ impl Thread { } } + thread.touch_updated_at(); cx.emit(ThreadEvent::StreamedCompletion); cx.notify(); })?; @@ -246,10 +266,14 @@ impl Thread { smol::future::yield_now().await; } - thread.update(&mut cx, |thread, _cx| { + thread.update(&mut cx, |thread, cx| { thread .pending_completions .retain(|completion| completion.id != pending_completion_id); + + if thread.summary.is_none() && thread.messages.len() >= 2 { + thread.summarize(cx); + } })?; anyhow::Ok(stop_reason) @@ -292,6 +316,59 @@ impl Thread { }); } + pub fn summarize(&mut self, cx: &mut ModelContext) { + let Some(provider) = LanguageModelRegistry::read_global(cx).active_provider() else { + return; + }; + let Some(model) = LanguageModelRegistry::read_global(cx).active_model() else { + return; + }; + + if !provider.is_authenticated(cx) { + return; + } + + let mut request = self.to_completion_request(RequestKind::Chat, cx); + request.messages.push(LanguageModelRequestMessage { + role: Role::User, + content: vec![ + "Generate a concise 3-7 word title for this conversation, omitting punctuation. Go straight to the title, without any preamble and prefix like `Here's a concise suggestion:...` or `Title:`" + .into(), + ], + cache: false, + }); + + self.pending_summary = cx.spawn(|this, mut cx| { + async move { + let stream = model.stream_completion_text(request, &cx); + let mut messages = stream.await?; + + let mut new_summary = String::new(); + while let Some(message) = messages.stream.next().await { + let text = message?; + let mut lines = text.lines(); + new_summary.extend(lines.next()); + + // Stop if the LLM generated multiple lines. + if lines.next().is_some() { + break; + } + } + + this.update(&mut cx, |this, cx| { + if !new_summary.is_empty() { + this.summary = Some(new_summary.into()); + } + + cx.emit(ThreadEvent::SummaryChanged); + })?; + + anyhow::Ok(()) + } + .log_err() + }); + } + pub fn insert_tool_output( &mut self, assistant_message_id: MessageId, @@ -365,6 +442,7 @@ pub enum ThreadEvent { StreamedCompletion, StreamedAssistantText(MessageId, String), MessageAdded(MessageId), + SummaryChanged, UsePendingTools, ToolFinished { #[allow(unused)] diff --git a/crates/assistant2/src/thread_history.rs b/crates/assistant2/src/thread_history.rs new file mode 100644 index 0000000000..7216ca695a --- /dev/null +++ b/crates/assistant2/src/thread_history.rs @@ -0,0 +1,144 @@ +use gpui::{ + uniform_list, AppContext, FocusHandle, FocusableView, Model, UniformListScrollHandle, WeakView, +}; +use time::{OffsetDateTime, UtcOffset}; +use ui::{prelude::*, IconButtonShape, ListItem}; + +use crate::thread::Thread; +use crate::thread_store::ThreadStore; +use crate::AssistantPanel; + +pub struct ThreadHistory { + focus_handle: FocusHandle, + assistant_panel: WeakView, + thread_store: Model, + scroll_handle: UniformListScrollHandle, +} + +impl ThreadHistory { + pub(crate) fn new( + assistant_panel: WeakView, + thread_store: Model, + cx: &mut ViewContext, + ) -> Self { + Self { + focus_handle: cx.focus_handle(), + assistant_panel, + thread_store, + scroll_handle: UniformListScrollHandle::default(), + } + } +} + +impl FocusableView for ThreadHistory { + fn focus_handle(&self, _cx: &AppContext) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for ThreadHistory { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let threads = self.thread_store.update(cx, |this, cx| this.threads(cx)); + + v_flex() + .id("thread-history-container") + .track_focus(&self.focus_handle) + .overflow_y_scroll() + .size_full() + .p_1() + .map(|history| { + if threads.is_empty() { + history + .justify_center() + .child( + h_flex().w_full().justify_center().child( + Label::new("You don't have any past threads yet.") + .size(LabelSize::Small), + ), + ) + } else { + history.child( + uniform_list( + cx.view().clone(), + "thread-history", + threads.len(), + move |history, range, _cx| { + threads[range] + .iter() + .map(|thread| { + PastThread::new( + thread.clone(), + history.assistant_panel.clone(), + ) + }) + .collect() + }, + ) + .track_scroll(self.scroll_handle.clone()) + .flex_grow(), + ) + } + }) + } +} + +#[derive(IntoElement)] +pub struct PastThread { + thread: Model, + assistant_panel: WeakView, +} + +impl PastThread { + pub fn new(thread: Model, assistant_panel: WeakView) -> Self { + Self { + thread, + assistant_panel, + } + } +} + +impl RenderOnce for PastThread { + fn render(self, cx: &mut WindowContext) -> impl IntoElement { + let (id, summary) = { + const DEFAULT_SUMMARY: SharedString = SharedString::new_static("New Thread"); + let thread = self.thread.read(cx); + ( + thread.id().clone(), + thread.summary().unwrap_or(DEFAULT_SUMMARY), + ) + }; + + let thread_timestamp = time_format::format_localized_timestamp( + OffsetDateTime::from_unix_timestamp(self.thread.read(cx).updated_at().timestamp()) + .unwrap(), + OffsetDateTime::now_utc(), + self.assistant_panel + .update(cx, |this, _cx| this.local_timezone()) + .unwrap_or(UtcOffset::UTC), + time_format::TimestampFormat::EnhancedAbsolute, + ); + ListItem::new(("past-thread", self.thread.entity_id())) + .start_slot(Icon::new(IconName::MessageBubbles)) + .child(Label::new(summary)) + .end_slot( + h_flex() + .gap_2() + .child(Label::new(thread_timestamp).color(Color::Disabled)) + .child( + IconButton::new("delete", IconName::TrashAlt) + .shape(IconButtonShape::Square) + .icon_size(IconSize::Small), + ), + ) + .on_click({ + let assistant_panel = self.assistant_panel.clone(); + move |_event, cx| { + assistant_panel + .update(cx, |this, cx| { + this.open_thread(&id, cx); + }) + .ok(); + } + }) + } +} diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs index 80e6d29265..7ceee9306b 100644 --- a/crates/assistant2/src/thread_store.rs +++ b/crates/assistant2/src/thread_store.rs @@ -52,13 +52,19 @@ impl ThreadStore { }) } - pub fn recent_threads(&self, limit: usize, cx: &ModelContext) -> Vec> { - self.threads + pub fn threads(&self, cx: &ModelContext) -> Vec> { + let mut threads = self + .threads .iter() .filter(|thread| !thread.read(cx).is_empty()) - .take(limit) .cloned() - .collect() + .collect::>(); + threads.sort_unstable_by_key(|thread| std::cmp::Reverse(thread.read(cx).updated_at())); + threads + } + + pub fn recent_threads(&self, limit: usize, cx: &ModelContext) -> Vec> { + self.threads(cx).into_iter().take(limit).collect() } pub fn create_thread(&mut self, cx: &mut ModelContext) -> Model { @@ -148,6 +154,7 @@ impl ThreadStore { self.threads.push(cx.new_model(|cx| { let mut thread = Thread::new(self.tools.clone(), cx); + thread.set_summary("Introduction to quantum computing", cx); thread.insert_user_message("Hello! Can you help me understand quantum computing?", cx); thread.insert_message(Role::Assistant, "Of course! I'd be happy to help you understand quantum computing. Quantum computing is a fascinating field that uses the principles of quantum mechanics to process information. Unlike classical computers that use bits (0s and 1s), quantum computers use quantum bits or 'qubits'. These qubits can exist in multiple states simultaneously, a property called superposition. This allows quantum computers to perform certain calculations much faster than classical computers. What specific aspect of quantum computing would you like to know more about?", cx); thread.insert_user_message("That's interesting! Can you explain how quantum entanglement is used in quantum computing?", cx); @@ -157,6 +164,7 @@ impl ThreadStore { self.threads.push(cx.new_model(|cx| { let mut thread = Thread::new(self.tools.clone(), cx); + thread.set_summary("Rust web development and async programming", cx); thread.insert_user_message("Can you show me an example of Rust code for a simple web server?", cx); thread.insert_message(Role::Assistant, "Certainly! Here's an example of a simple web server in Rust using the `actix-web` framework: From 1efd165ead62cc00957cfd3a8c3da0cc4572e93b Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Thu, 5 Dec 2024 21:48:33 +0200 Subject: [PATCH 173/215] Restore project diff test (#21606) Restores a basic project diff test Release Notes: - N/A --------- Co-authored-by: Cole Miller --- crates/editor/src/git/project_diff.rs | 246 ++++++++++++++------------ crates/project/src/buffer_store.rs | 7 + 2 files changed, 141 insertions(+), 112 deletions(-) diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index e3d9f6abd6..8ececa9bb8 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -55,6 +55,7 @@ struct ProjectDiffEditor { _subscriptions: Vec, } +#[derive(Debug)] struct Changes { _status: GitFileStatus, buffer: Model, @@ -235,15 +236,16 @@ impl ProjectDiffEditor { let mut change_sets = Vec::new(); for (status, entry_id, entry_path, open_task) in open_tasks { let (_, opened_model) = open_task.await.with_context(|| { - format!("loading buffer {} for git diff", entry_path.path.display()) + format!("loading buffer {:?} for git diff", entry_path.path) })?; let buffer = match opened_model.downcast::() { Ok(buffer) => buffer, Err(_model) => anyhow::bail!( - "Could not load {} as a buffer for git diff", - entry_path.path.display() + "Could not load {:?} as a buffer for git diff", + entry_path.path ), }; + let change_set = project .update(&mut cx, |project, cx| { project.open_unstaged_changes(buffer.clone(), cx) @@ -1089,13 +1091,16 @@ impl Render for ProjectDiffEditor { #[cfg(test)] mod tests { - // use std::{ops::Deref as _, path::Path, sync::Arc}; + use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; + use project::buffer_store::BufferChangeSet; + use serde_json::json; + use settings::SettingsStore; + use std::{ + ops::Deref as _, + path::{Path, PathBuf}, + }; - // use fs::RealFs; - // use gpui::{SemanticVersion, TestAppContext, VisualTestContext}; - // use settings::SettingsStore; - - // use super::*; + use super::*; // TODO finish // #[gpui::test] @@ -1111,114 +1116,131 @@ mod tests { // // Apply randomized changes to the project: select a random file, random change and apply to buffers // } - // #[gpui::test] - // async fn simple_edit_test(cx: &mut TestAppContext) { - // cx.executor().allow_parking(); - // init_test(cx); + #[gpui::test(iterations = 30)] + async fn simple_edit_test(cx: &mut TestAppContext) { + cx.executor().allow_parking(); + init_test(cx); - // let dir = tempfile::tempdir().unwrap(); - // let dst = dir.path(); + let fs = fs::FakeFs::new(cx.executor().clone()); + fs.insert_tree( + "/root", + json!({ + ".git": {}, + "file_a": "This is file_a", + "file_b": "This is file_b", + }), + ) + .await; - // std::fs::write(dst.join("file_a"), "This is file_a").unwrap(); - // std::fs::write(dst.join("file_b"), "This is file_b").unwrap(); + let project = Project::test(fs.clone(), [Path::new("/root")], cx).await; + let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); + let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); - // run_git(dst, &["init"]); - // run_git(dst, &["add", "*"]); - // run_git(dst, &["commit", "-m", "Initial commit"]); + let file_a_editor = workspace + .update(cx, |workspace, cx| { + let file_a_editor = + workspace.open_abs_path(PathBuf::from("/root/file_a"), true, cx); + ProjectDiffEditor::deploy(workspace, &Deploy, cx); + file_a_editor + }) + .unwrap() + .await + .expect("did not open an item at all") + .downcast::() + .expect("did not open an editor for file_a"); + let project_diff_editor = workspace + .update(cx, |workspace, cx| { + workspace + .active_pane() + .read(cx) + .items() + .find_map(|item| item.downcast::()) + }) + .unwrap() + .expect("did not find a ProjectDiffEditor"); + project_diff_editor.update(cx, |project_diff_editor, cx| { + assert!( + project_diff_editor.editor.read(cx).text(cx).is_empty(), + "Should have no changes after opening the diff on no git changes" + ); + }); - // let project = Project::test(Arc::new(RealFs::default()), [dst], cx).await; - // let workspace = cx.add_window(|cx| Workspace::test_new(project.clone(), cx)); - // let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx); + let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx)); + let change = "an edit after git add"; + file_a_editor + .update(cx, |file_a_editor, cx| { + file_a_editor.insert(change, cx); + file_a_editor.save(false, project.clone(), cx) + }) + .await + .expect("failed to save a file"); + file_a_editor.update(cx, |file_a_editor, cx| { + let change_set = cx.new_model(|cx| { + BufferChangeSet::new_with_base_text( + old_text.clone(), + file_a_editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .text_snapshot(), + cx, + ) + }); + file_a_editor + .diff_map + .add_change_set(change_set.clone(), cx); + project.update(cx, |project, cx| { + project.buffer_store().update(cx, |buffer_store, cx| { + buffer_store.set_change_set( + file_a_editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .read(cx) + .remote_id(), + change_set, + ); + }); + }); + }); + fs.set_status_for_repo_via_git_operation( + Path::new("/root/.git"), + &[(Path::new("file_a"), GitFileStatus::Modified)], + ); + cx.executor() + .advance_clock(UPDATE_DEBOUNCE + Duration::from_millis(100)); + cx.run_until_parked(); - // let file_a_editor = workspace - // .update(cx, |workspace, cx| { - // let file_a_editor = workspace.open_abs_path(dst.join("file_a"), true, cx); - // ProjectDiffEditor::deploy(workspace, &Deploy, cx); - // file_a_editor - // }) - // .unwrap() - // .await - // .expect("did not open an item at all") - // .downcast::() - // .expect("did not open an editor for file_a"); + project_diff_editor.update(cx, |project_diff_editor, cx| { + assert_eq!( + // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added) + project_diff_editor.editor.read(cx).text(cx), + format!("{change}{old_text}"), + "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards" + ); + }); + } - // let project_diff_editor = workspace - // .update(cx, |workspace, cx| { - // workspace - // .active_pane() - // .read(cx) - // .items() - // .find_map(|item| item.downcast::()) - // }) - // .unwrap() - // .expect("did not find a ProjectDiffEditor"); - // project_diff_editor.update(cx, |project_diff_editor, cx| { - // assert!( - // project_diff_editor.editor.read(cx).text(cx).is_empty(), - // "Should have no changes after opening the diff on no git changes" - // ); - // }); + fn init_test(cx: &mut gpui::TestAppContext) { + if std::env::var("RUST_LOG").is_ok() { + env_logger::try_init().ok(); + } - // let old_text = file_a_editor.update(cx, |editor, cx| editor.text(cx)); - // let change = "an edit after git add"; - // file_a_editor - // .update(cx, |file_a_editor, cx| { - // file_a_editor.insert(change, cx); - // file_a_editor.save(false, project.clone(), cx) - // }) - // .await - // .expect("failed to save a file"); - // cx.executor().advance_clock(Duration::from_secs(1)); - // cx.run_until_parked(); - - // // TODO does not work on Linux for some reason, returning a blank line - // // hence disable the last check for now, and do some fiddling to avoid the warnings. - // #[cfg(target_os = "linux")] - // { - // if true { - // return; - // } - // } - // project_diff_editor.update(cx, |project_diff_editor, cx| { - // // TODO assert it better: extract added text (based on the background changes) and deleted text (based on the deleted blocks added) - // assert_eq!( - // project_diff_editor.editor.read(cx).text(cx), - // format!("{change}{old_text}"), - // "Should have a new change shown in the beginning, and the old text shown as deleted text afterwards" - // ); - // }); - // } - - // fn run_git(path: &Path, args: &[&str]) -> String { - // let output = std::process::Command::new("git") - // .args(args) - // .current_dir(path) - // .output() - // .expect("git commit failed"); - - // format!( - // "Stdout: {}; stderr: {}", - // String::from_utf8(output.stdout).unwrap(), - // String::from_utf8(output.stderr).unwrap() - // ) - // } - - // fn init_test(cx: &mut gpui::TestAppContext) { - // if std::env::var("RUST_LOG").is_ok() { - // env_logger::try_init().ok(); - // } - - // cx.update(|cx| { - // assets::Assets.load_test_fonts(cx); - // let settings_store = SettingsStore::test(cx); - // cx.set_global(settings_store); - // theme::init(theme::LoadThemes::JustBase, cx); - // release_channel::init(SemanticVersion::default(), cx); - // client::init_settings(cx); - // language::init(cx); - // Project::init_settings(cx); - // workspace::init_settings(cx); - // crate::init(cx); - // }); - // } + cx.update(|cx| { + assets::Assets.load_test_fonts(cx); + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + theme::init(theme::LoadThemes::JustBase, cx); + release_channel::init(SemanticVersion::default(), cx); + client::init_settings(cx); + language::init(cx); + Project::init_settings(cx); + workspace::init_settings(cx); + crate::init(cx); + cx.set_staff(true); + }); + } } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index a4c6231206..4509142189 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -49,6 +49,7 @@ struct SharedBuffer { unstaged_changes: Option>, } +#[derive(Debug)] pub struct BufferChangeSet { pub buffer_id: BufferId, pub base_text: Option>, @@ -1045,6 +1046,12 @@ impl BufferStore { .spawn(async move { task.await.map_err(|e| anyhow!("{e}")) }) } + #[cfg(any(test, feature = "test-support"))] + pub fn set_change_set(&mut self, buffer_id: BufferId, change_set: Model) { + self.loading_change_sets + .insert(buffer_id, Task::ready(Ok(change_set)).shared()); + } + pub async fn open_unstaged_changes_internal( this: WeakModel, text: Result>, From c8b3c4c6cd82f7c60776c13ea82aac906eee3a3f Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Thu, 5 Dec 2024 15:57:35 -0500 Subject: [PATCH 174/215] assistant2: Add ability to delete past threads (#21607) This PR adds the ability to delete past threads in Assistant2. Release Notes: - N/A --- crates/assistant2/src/assistant_panel.rs | 9 +++++++-- crates/assistant2/src/thread_history.rs | 14 +++++++++++++- crates/assistant2/src/thread_store.rs | 4 ++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 2d9f563c2f..fde3aa02ba 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -106,6 +106,10 @@ impl AssistantPanel { } } + pub(crate) fn local_timezone(&self) -> UtcOffset { + self.local_timezone + } + fn new_thread(&mut self, cx: &mut ViewContext) { let thread = self .thread_store @@ -147,8 +151,9 @@ impl AssistantPanel { self.message_editor.focus_handle(cx).focus(cx); } - pub(crate) fn local_timezone(&self) -> UtcOffset { - self.local_timezone + pub(crate) fn delete_thread(&mut self, thread_id: &ThreadId, cx: &mut ViewContext) { + self.thread_store + .update(cx, |this, cx| this.delete_thread(thread_id, cx)); } } diff --git a/crates/assistant2/src/thread_history.rs b/crates/assistant2/src/thread_history.rs index 7216ca695a..f183276f7b 100644 --- a/crates/assistant2/src/thread_history.rs +++ b/crates/assistant2/src/thread_history.rs @@ -127,11 +127,23 @@ impl RenderOnce for PastThread { .child( IconButton::new("delete", IconName::TrashAlt) .shape(IconButtonShape::Square) - .icon_size(IconSize::Small), + .icon_size(IconSize::Small) + .on_click({ + let assistant_panel = self.assistant_panel.clone(); + let id = id.clone(); + move |_event, cx| { + assistant_panel + .update(cx, |this, cx| { + this.delete_thread(&id, cx); + }) + .ok(); + } + }), ), ) .on_click({ let assistant_panel = self.assistant_panel.clone(); + let id = id.clone(); move |_event, cx| { assistant_panel .update(cx, |this, cx| { diff --git a/crates/assistant2/src/thread_store.rs b/crates/assistant2/src/thread_store.rs index 7ceee9306b..94cb72ce43 100644 --- a/crates/assistant2/src/thread_store.rs +++ b/crates/assistant2/src/thread_store.rs @@ -80,6 +80,10 @@ impl ThreadStore { .cloned() } + pub fn delete_thread(&mut self, id: &ThreadId, cx: &mut ModelContext) { + self.threads.retain(|thread| thread.read(cx).id() != id); + } + fn register_context_server_handlers(&self, cx: &mut ModelContext) { cx.subscribe( &self.context_server_manager.clone(), From 0511768b226094a25fddb99fc6e7ef53ad220197 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Thu, 5 Dec 2024 19:17:26 -0300 Subject: [PATCH 175/215] project panel: Use theme token for focused border color (#21593) Closes https://github.com/zed-industries/zed/issues/12723 This PR makes the border color of a focused project panel item use the `panel_focused_border` theme token. This allow theme makers to customize that independently of the `text_accent` color, which was the one being previously used. ### One Dark | Before | After | |--------|--------| | Screenshot 2024-12-05 at 18 37 00 | Screenshot 2024-12-05 at 18 39 42 | | Screenshot 2024-12-05 at 18 37 08 | Screenshot 2024-12-05 at 18 39 51 | ### Gruvbox Hard | Before | After | |--------|--------| | Screenshot 2024-12-05 at 18 38 05 | Screenshot 2024-12-05 at 18 40 15 | | Screenshot 2024-12-05 at 18 38 16 | Screenshot 2024-12-05 at 18 39 57 | Release Notes: - N/A --- crates/project_panel/src/project_panel.rs | 4 +++- crates/theme/src/default_colors.rs | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index d263c75ca7..ca6f89f69a 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -261,6 +261,7 @@ struct ItemColors { hover: Hsla, drag_over: Hsla, marked_active: Hsla, + focused: Hsla, } fn get_item_color(cx: &ViewContext) -> ItemColors { @@ -271,6 +272,7 @@ fn get_item_color(cx: &ViewContext) -> ItemColors { hover: colors.ghost_element_hover, drag_over: colors.drop_target_background, marked_active: colors.ghost_element_selected, + focused: colors.panel_focused_border, } } @@ -3504,7 +3506,7 @@ impl ProjectPanel { .rounded_none() .when( !self.mouse_down && is_active && self.focus_handle.contains_focused(cx), - |this| this.border_color(Color::Selected.color(cx)), + |this| this.border_color(item_colors.focused), ) } diff --git a/crates/theme/src/default_colors.rs b/crates/theme/src/default_colors.rs index 05dd6cd1e7..b9780a304a 100644 --- a/crates/theme/src/default_colors.rs +++ b/crates/theme/src/default_colors.rs @@ -58,7 +58,7 @@ impl ThemeColors { tab_active_background: neutral().light().step_1(), search_match_background: neutral().light().step_5(), panel_background: neutral().light().step_2(), - panel_focused_border: blue().light().step_5(), + panel_focused_border: blue().light().step_10(), panel_indent_guide: neutral().light_alpha().step_5(), panel_indent_guide_hover: neutral().light_alpha().step_6(), panel_indent_guide_active: neutral().light_alpha().step_6(), @@ -164,7 +164,7 @@ impl ThemeColors { tab_active_background: neutral().dark().step_1(), search_match_background: neutral().dark().step_5(), panel_background: neutral().dark().step_2(), - panel_focused_border: blue().dark().step_5(), + panel_focused_border: blue().dark().step_12(), panel_indent_guide: neutral().dark_alpha().step_4(), panel_indent_guide_hover: neutral().dark_alpha().step_6(), panel_indent_guide_active: neutral().dark_alpha().step_6(), From 6a4cd53fd8abf6caffbd7f25d4e1d51d54343db8 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Thu, 5 Dec 2024 16:06:17 -0700 Subject: [PATCH 176/215] Use LiveKit's Rust SDK on Linux while continue using Swift SDK on Mac (#21550) Similar to #20826 but keeps the Swift implementation. There were quite a few changes in the `call` crate, and so that code now has two variants. Closes #13714 Release Notes: - Added preliminary Linux support for voice chat and viewing screenshares. --------- Co-authored-by: Kirill Bulatov Co-authored-by: Kirill Bulatov Co-authored-by: Mikayla --- .cargo/config.toml | 6 + .github/workflows/ci.yml | 1 + Cargo.lock | 502 ++++- Cargo.toml | 16 +- crates/call/Cargo.toml | 15 +- crates/call/src/call.rs | 575 +----- crates/call/src/cross_platform/mod.rs | 552 +++++ crates/call/src/cross_platform/participant.rs | 68 + crates/call/src/cross_platform/room.rs | 1771 +++++++++++++++++ crates/call/src/macos/mod.rs | 545 +++++ crates/call/src/{ => macos}/participant.rs | 14 +- crates/call/src/{ => macos}/room.rs | 16 +- crates/collab/.env.toml | 6 +- crates/collab/Cargo.toml | 15 +- crates/collab/k8s/collab.template.yml | 6 +- crates/collab/src/db/queries/channels.rs | 8 +- crates/collab/src/db/queries/rooms.rs | 6 +- crates/collab/src/lib.rs | 28 +- crates/collab/src/rpc.rs | 90 +- crates/collab/src/tests.rs | 3 + .../collab/src/tests/channel_guest_tests.rs | 24 +- crates/collab/src/tests/following_tests.rs | 208 +- crates/collab/src/tests/integration_tests.rs | 123 +- crates/collab/src/tests/test_server.rs | 36 +- crates/collab_ui/src/collab_panel.rs | 5 +- crates/gpui/build.rs | 1 + crates/gpui/src/app.rs | 11 +- crates/gpui/src/app/test_context.rs | 10 +- crates/gpui/src/geometry.rs | 5 + crates/gpui/src/platform.rs | 26 + crates/gpui/src/platform/linux.rs | 2 + crates/gpui/src/platform/linux/platform.rs | 12 +- crates/gpui/src/platform/mac.rs | 5 + crates/gpui/src/platform/mac/platform.rs | 14 +- .../gpui/src/platform/mac/screen_capture.rs | 239 +++ crates/gpui/src/platform/test.rs | 2 + crates/gpui/src/platform/test/platform.rs | 58 +- crates/gpui/src/platform/windows.rs | 2 + crates/gpui/src/platform/windows/platform.rs | 8 + crates/http_client/Cargo.toml | 2 +- .../.cargo/config.toml | 2 +- crates/livekit_client/Cargo.toml | 65 + .../LICENSE-GPL | 0 crates/livekit_client/examples/test_app.rs | 442 ++++ crates/livekit_client/src/livekit_client.rs | 661 ++++++ .../src/remote_video_track_view.rs | 99 + crates/livekit_client/src/test.rs | 825 ++++++++ crates/livekit_client/src/test/participant.rs | 111 ++ crates/livekit_client/src/test/publication.rs | 116 ++ crates/livekit_client/src/test/track.rs | 201 ++ crates/livekit_client/src/test/webrtc.rs | 136 ++ .../livekit_client_macos/.cargo/config.toml | 2 + .../Cargo.toml | 12 +- crates/livekit_client_macos/LICENSE-GPL | 1 + .../LiveKitBridge/Package.resolved | 0 .../LiveKitBridge/Package.swift | 0 .../LiveKitBridge/README.md | 0 .../Sources/LiveKitBridge/LiveKitBridge.swift | 0 .../build.rs | 0 .../examples/test_app.rs | 6 +- .../src/livekit_client.rs} | 0 .../src/prod.rs | 0 .../src/test.rs | 22 +- .../Cargo.toml | 4 +- .../LICENSE-AGPL | 0 .../build.rs | 0 .../src/api.rs | 0 .../src/livekit_server.rs} | 0 .../src/proto.rs | 0 .../src/token.rs | 0 .../vendored/protocol/README.md | 0 .../vendored/protocol/livekit_analytics.proto | 0 .../vendored/protocol/livekit_egress.proto | 0 .../vendored/protocol/livekit_ingress.proto | 0 .../vendored/protocol/livekit_internal.proto | 0 .../vendored/protocol/livekit_models.proto | 0 .../vendored/protocol/livekit_room.proto | 0 .../protocol/livekit_rpc_internal.proto | 0 .../vendored/protocol/livekit_rtc.proto | 0 .../vendored/protocol/livekit_webhook.proto | 0 crates/media/Cargo.toml | 1 + crates/media/src/media.rs | 11 +- crates/proto/proto/zed.proto | 2 +- crates/title_bar/src/collab.rs | 65 +- crates/workspace/Cargo.toml | 2 + crates/workspace/src/shared_screen.rs | 362 +++- crates/workspace/src/workspace.rs | 13 +- crates/zed/Cargo.toml | 6 + crates/zed/build.rs | 8 + script/bundle-linux | 2 +- typos.toml | 2 +- 91 files changed, 7187 insertions(+), 1028 deletions(-) create mode 100644 crates/call/src/cross_platform/mod.rs create mode 100644 crates/call/src/cross_platform/participant.rs create mode 100644 crates/call/src/cross_platform/room.rs create mode 100644 crates/call/src/macos/mod.rs rename crates/call/src/{ => macos}/participant.rs (80%) rename crates/call/src/{ => macos}/room.rs (99%) create mode 100644 crates/gpui/src/platform/mac/screen_capture.rs rename crates/{live_kit_client => livekit_client}/.cargo/config.toml (62%) create mode 100644 crates/livekit_client/Cargo.toml rename crates/{live_kit_client => livekit_client}/LICENSE-GPL (100%) create mode 100644 crates/livekit_client/examples/test_app.rs create mode 100644 crates/livekit_client/src/livekit_client.rs create mode 100644 crates/livekit_client/src/remote_video_track_view.rs create mode 100644 crates/livekit_client/src/test.rs create mode 100644 crates/livekit_client/src/test/participant.rs create mode 100644 crates/livekit_client/src/test/publication.rs create mode 100644 crates/livekit_client/src/test/track.rs create mode 100644 crates/livekit_client/src/test/webrtc.rs create mode 100644 crates/livekit_client_macos/.cargo/config.toml rename crates/{live_kit_client => livekit_client_macos}/Cargo.toml (87%) create mode 120000 crates/livekit_client_macos/LICENSE-GPL rename crates/{live_kit_client => livekit_client_macos}/LiveKitBridge/Package.resolved (100%) rename crates/{live_kit_client => livekit_client_macos}/LiveKitBridge/Package.swift (100%) rename crates/{live_kit_client => livekit_client_macos}/LiveKitBridge/README.md (100%) rename crates/{live_kit_client => livekit_client_macos}/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift (100%) rename crates/{live_kit_client => livekit_client_macos}/build.rs (100%) rename crates/{live_kit_client => livekit_client_macos}/examples/test_app.rs (97%) rename crates/{live_kit_client/src/live_kit_client.rs => livekit_client_macos/src/livekit_client.rs} (100%) rename crates/{live_kit_client => livekit_client_macos}/src/prod.rs (100%) rename crates/{live_kit_client => livekit_client_macos}/src/test.rs (97%) rename crates/{live_kit_server => livekit_server}/Cargo.toml (90%) rename crates/{live_kit_server => livekit_server}/LICENSE-AGPL (100%) rename crates/{live_kit_server => livekit_server}/build.rs (100%) rename crates/{live_kit_server => livekit_server}/src/api.rs (100%) rename crates/{live_kit_server/src/live_kit_server.rs => livekit_server/src/livekit_server.rs} (100%) rename crates/{live_kit_server => livekit_server}/src/proto.rs (100%) rename crates/{live_kit_server => livekit_server}/src/token.rs (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/README.md (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_analytics.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_egress.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_ingress.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_internal.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_models.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_room.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_rpc_internal.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_rtc.proto (100%) rename crates/{live_kit_server => livekit_server}/vendored/protocol/livekit_webhook.proto (100%) diff --git a/.cargo/config.toml b/.cargo/config.toml index a657ae61b9..043adf6b30 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -13,6 +13,12 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"] linker = "clang" rustflags = ["-C", "link-arg=-fuse-ld=mold"] +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-args=-Objc -all_load"] + +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-args=-Objc -all_load"] + # This cfg will reduce the size of `windows::core::Error` from 16 bytes to 4 bytes [target.'cfg(target_os = "windows")'] rustflags = ["--cfg", "windows_slim_errors"] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 602808f1b5..46e7ab7d51 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,6 +129,7 @@ jobs: run: | cargo build --workspace --bins --all-features cargo check -p gpui --features "macos-blade" + cargo check -p workspace --features "livekit-cross-platform" cargo build -p remote_server linux_tests: diff --git a/Cargo.lock b/Cargo.lock index bd3cd06dca..4d040c581c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -962,6 +962,22 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "async-tungstenite" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cca750b12e02c389c1694d35c16539f88b8bbaa5945934fdc1b41a776688589" +dependencies = [ + "async-native-tls", + "async-std", + "async-tls", + "futures-io", + "futures-util", + "log", + "pin-project-lite", + "tungstenite 0.21.0", +] + [[package]] name = "async-tungstenite" version = "0.28.0" @@ -1830,7 +1846,7 @@ dependencies = [ "arrayvec", "cc", "cfg-if", - "constant_time_eq", + "constant_time_eq 0.3.1", ] [[package]] @@ -2015,6 +2031,27 @@ dependencies = [ "either", ] +[[package]] +name = "bzip2" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdb116a6ef3f6c3698828873ad02c3014b3c85cadb88496095628e3ef1e347f8" +dependencies = [ + "bzip2-sys", + "libc", +] + +[[package]] +name = "bzip2-sys" +version = "0.1.11+1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736a955f3fa7875102d57c82b8cac37ec45224a07fd32d58f9f7a186b6cd4cdc" +dependencies = [ + "cc", + "libc", + "pkg-config", +] + [[package]] name = "call" version = "0.1.0" @@ -2023,12 +2060,14 @@ dependencies = [ "audio", "client", "collections", + "feature_flags", "fs", "futures 0.3.31", "gpui", "http_client", "language", - "live_kit_client", + "livekit_client", + "livekit_client_macos", "log", "postage", "project", @@ -2486,7 +2525,7 @@ dependencies = [ "anyhow", "async-native-tls", "async-recursion 0.3.2", - "async-tungstenite", + "async-tungstenite 0.28.0", "chrono", "clock", "cocoa 0.26.0", @@ -2618,7 +2657,7 @@ dependencies = [ "assistant_tool", "async-stripe", "async-trait", - "async-tungstenite", + "async-tungstenite 0.28.0", "audio", "aws-config", "aws-sdk-kinesis", @@ -2656,8 +2695,9 @@ dependencies = [ "jsonwebtoken", "language", "language_model", - "live_kit_client", - "live_kit_server", + "livekit_client", + "livekit_client_macos", + "livekit_server", "log", "lsp", "menu", @@ -2670,7 +2710,7 @@ dependencies = [ "pretty_assertions", "project", "prometheus", - "prost", + "prost 0.9.0", "rand 0.8.5", "recent_projects", "release_channel", @@ -2870,6 +2910,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -3077,6 +3123,17 @@ dependencies = [ "coreaudio-sys", ] +[[package]] +name = "coreaudio-rs" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ca07354f6d0640333ef95f48d460a4bcf34812a7e7967f9b44c728a8f37c28" +dependencies = [ + "bitflags 1.3.2", + "core-foundation-sys", + "coreaudio-sys", +] + [[package]] name = "coreaudio-sys" version = "0.2.16" @@ -3111,12 +3168,11 @@ dependencies = [ [[package]] name = "cpal" version = "0.15.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" +source = "git+https://github.com/zed-industries/cpal?rev=fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50#fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" dependencies = [ "alsa", "core-foundation-sys", - "coreaudio-rs", + "coreaudio-rs 0.11.3", "dasp_sample", "jni", "js-sys", @@ -3448,6 +3504,65 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96a6ac251f4a2aca6b3f91340350eab87ae57c3f127ffeb585e92bd336717991" +[[package]] +name = "cxx" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05e1ec88093d2abd9cf1b09ffd979136b8e922bf31cad966a8fe0d73233112ef" +dependencies = [ + "cc", + "cxxbridge-cmd", + "cxxbridge-flags", + "cxxbridge-macro", + "foldhash", + "link-cplusplus", +] + +[[package]] +name = "cxx-build" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afa390d956ee7ccb41aeed7ed7856ab3ffb4fc587e7216be7e0f83e949b4e6c" +dependencies = [ + "cc", + "codespan-reporting", + "proc-macro2", + "quote", + "scratch", + "syn 2.0.87", +] + +[[package]] +name = "cxxbridge-cmd" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c23bfff654d6227cbc83de8e059d2f8678ede5fc3a6c5a35d5c379983cc61e6" +dependencies = [ + "clap", + "codespan-reporting", + "proc-macro2", + "quote", + "syn 2.0.87", +] + +[[package]] +name = "cxxbridge-flags" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c01b36e22051bc6928a78583f1621abaaf7621561c2ada1b00f7878fbe2caa" + +[[package]] +name = "cxxbridge-macro" +version = "1.0.133" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e14013136fac689345d17b9a6df55977251f11d333c0a571e8d963b55e1f95" +dependencies = [ + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.87", +] + [[package]] name = "dashmap" version = "5.5.3" @@ -4706,6 +4821,16 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent" version = "0.1.0" @@ -6338,6 +6463,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -6463,7 +6597,7 @@ checksum = "58d9afa5bc6eeafb78f710a2efc585f69099f8b6a99dc7eb826581e3773a6e31" dependencies = [ "anyhow", "async-trait", - "async-tungstenite", + "async-tungstenite 0.28.0", "futures 0.3.31", "jupyter-protocol", "serde", @@ -6881,6 +7015,29 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebrtc" +version = "0.3.7" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "cxx", + "jni", + "js-sys", + "lazy_static", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webrtc-sys", +] + [[package]] name = "libz-sys" version = "1.1.20" @@ -6893,6 +7050,15 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "link-cplusplus" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d240c6f7e1ba3a28b0249f774e6a9dd0175054b52dfbb61b16eb8505c3785c9" +dependencies = [ + "cc", +] + [[package]] name = "linkify" version = "0.10.0" @@ -6941,7 +7107,112 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" [[package]] -name = "live_kit_client" +name = "livekit" +version = "0.7.0" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "chrono", + "futures-util", + "lazy_static", + "libwebrtc", + "livekit-api", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "prost 0.12.6", + "semver", + "serde", + "serde_json", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "livekit-api" +version = "0.4.1" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "async-tungstenite 0.25.1", + "futures-util", + "http 0.2.12", + "jsonwebtoken", + "livekit-protocol", + "livekit-runtime", + "log", + "parking_lot", + "prost 0.12.6", + "reqwest 0.11.27", + "scopeguard", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite 0.20.1", + "url", +] + +[[package]] +name = "livekit-protocol" +version = "0.3.6" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "futures-util", + "livekit-runtime", + "parking_lot", + "pbjson", + "pbjson-types", + "prost 0.12.6", + "prost-types 0.12.6", + "serde", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "livekit-runtime" +version = "0.3.1" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "async-io 2.4.0", + "async-std", + "async-task", + "futures 0.3.31", +] + +[[package]] +name = "livekit_client" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "collections", + "core-foundation 0.9.4", + "coreaudio-rs 0.12.1", + "cpal", + "futures 0.3.31", + "gpui", + "http 0.2.12", + "http_client", + "image", + "livekit", + "livekit_server", + "log", + "media", + "nanoid", + "parking_lot", + "postage", + "serde", + "serde_json", + "sha2", + "simplelog", + "smallvec", + "util", +] + +[[package]] +name = "livekit_client_macos" version = "0.1.0" dependencies = [ "anyhow", @@ -6951,7 +7222,7 @@ dependencies = [ "core-foundation 0.9.4", "futures 0.3.31", "gpui", - "live_kit_server", + "livekit_server", "log", "media", "nanoid", @@ -6964,16 +7235,16 @@ dependencies = [ ] [[package]] -name = "live_kit_server" +name = "livekit_server" version = "0.1.0" dependencies = [ "anyhow", "async-trait", "jsonwebtoken", "log", - "prost", - "prost-build", - "prost-types", + "prost 0.9.0", + "prost-build 0.9.0", + "prost-types 0.9.0", "reqwest 0.12.8", "serde", ] @@ -7262,6 +7533,7 @@ dependencies = [ "anyhow", "bindgen", "core-foundation 0.9.4", + "ctor", "foreign-types 0.5.0", "metal", "objc", @@ -7954,7 +8226,7 @@ dependencies = [ "md-5", "num", "num-bigint-dig", - "pbkdf2", + "pbkdf2 0.12.2", "rand 0.8.5", "serde", "sha2", @@ -8274,6 +8546,17 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "password-hash" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7676374caaee8a325c9e7a2ae557f216c5563a171d6997b0ef8a65af35147700" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "password-hash" version = "0.5.0" @@ -8324,6 +8607,55 @@ dependencies = [ "util", ] +[[package]] +name = "pbjson" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1030c719b0ec2a2d25a5df729d6cff1acf3cc230bf766f4f97833591f7577b90" +dependencies = [ + "base64 0.21.7", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735" +dependencies = [ + "heck 0.4.1", + "itertools 0.11.0", + "prost 0.12.6", + "prost-types 0.12.6", +] + +[[package]] +name = "pbjson-types" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f596653ba4ac51bdecbb4ef6773bc7f56042dc13927910de1684ad3d32aa12" +dependencies = [ + "bytes 1.8.0", + "chrono", + "pbjson", + "pbjson-build", + "prost 0.12.6", + "prost-build 0.12.6", + "serde", +] + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest", + "hmac", + "password-hash 0.4.2", + "sha2", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -9353,7 +9685,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "444879275cb4fd84958b1a1d5420d15e6fcf7c235fe47f053c9c2a80aceb6001" dependencies = [ "bytes 1.8.0", - "prost-derive", + "prost-derive 0.9.0", +] + +[[package]] +name = "prost" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" +dependencies = [ + "bytes 1.8.0", + "prost-derive 0.12.6", ] [[package]] @@ -9369,13 +9711,34 @@ dependencies = [ "log", "multimap", "petgraph", - "prost", - "prost-types", + "prost 0.9.0", + "prost-types 0.9.0", "regex", "tempfile", "which 4.4.2", ] +[[package]] +name = "prost-build" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22505a5c94da8e3b7c2996394d1c933236c4d743e81a410bcca4e6989fc066a4" +dependencies = [ + "bytes 1.8.0", + "heck 0.5.0", + "itertools 0.12.1", + "log", + "multimap", + "once_cell", + "petgraph", + "prettyplease", + "prost 0.12.6", + "prost-types 0.12.6", + "regex", + "syn 2.0.87", + "tempfile", +] + [[package]] name = "prost-derive" version = "0.9.0" @@ -9389,6 +9752,19 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "prost-derive" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81bddcdb20abf9501610992b6759a4c888aef7d1a7247ef75e2404275ac24af1" +dependencies = [ + "anyhow", + "itertools 0.12.1", + "proc-macro2", + "quote", + "syn 2.0.87", +] + [[package]] name = "prost-types" version = "0.9.0" @@ -9396,7 +9772,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "534b7a0e836e3c482d2693070f982e39e7611da9695d4d1f5a4b186b51faef0a" dependencies = [ "bytes 1.8.0", - "prost", + "prost 0.9.0", +] + +[[package]] +name = "prost-types" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" +dependencies = [ + "prost 0.12.6", ] [[package]] @@ -9405,8 +9790,8 @@ version = "0.1.0" dependencies = [ "anyhow", "collections", - "prost", - "prost-build", + "prost 0.9.0", + "prost-build 0.9.0", "serde", ] @@ -9906,7 +10291,7 @@ dependencies = [ "log", "parking_lot", "paths", - "prost", + "prost 0.9.0", "release_channel", "rpc", "serde", @@ -10041,6 +10426,7 @@ dependencies = [ "http 0.2.12", "http-body 0.4.6", "hyper 0.14.31", + "hyper-rustls 0.24.2", "hyper-tls", "ipnet", "js-sys", @@ -10050,6 +10436,8 @@ dependencies = [ "once_cell", "percent-encoding", "pin-project-lite", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", "rustls-pemfile 1.0.4", "serde", "serde_json", @@ -10058,6 +10446,7 @@ dependencies = [ "system-configuration 0.5.1", "tokio", "tokio-native-tls", + "tokio-rustls 0.24.1", "tower-service", "url", "wasm-bindgen", @@ -10281,7 +10670,7 @@ name = "rpc" version = "0.1.0" dependencies = [ "anyhow", - "async-tungstenite", + "async-tungstenite 0.28.0", "base64 0.22.1", "chrono", "collections", @@ -10657,14 +11046,20 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scratch" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3cf7c11c38cb994f3d40e8a8cde3bbd1f72a435e4c49e85d6553d8312306152" + [[package]] name = "scrypt" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ - "password-hash", - "pbkdf2", + "password-hash 0.5.0", + "pbkdf2 0.12.2", "salsa20", "sha2", ] @@ -12823,7 +13218,10 @@ checksum = "212d5dcb2a1ce06d81107c3d0ffa3121fe974b73f068c8282cb1c32328113b6c" dependencies = [ "futures-util", "log", + "rustls 0.21.12", + "rustls-native-certs 0.6.3", "tokio", + "tokio-rustls 0.24.1", "tungstenite 0.20.1", ] @@ -13331,6 +13729,7 @@ dependencies = [ "httparse", "log", "rand 0.8.5", + "rustls 0.21.12", "sha1", "thiserror 1.0.69", "url", @@ -13349,6 +13748,7 @@ dependencies = [ "http 1.1.0", "httparse", "log", + "native-tls", "rand 0.8.5", "sha1", "thiserror 1.0.69", @@ -14461,6 +14861,32 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webrtc-sys" +version = "0.3.5" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "cc", + "cxx", + "cxx-build", + "glob", + "log", + "webrtc-sys-build", +] + +[[package]] +name = "webrtc-sys-build" +version = "0.3.5" +source = "git+https://github.com/zed-industries/rust-sdks?rev=799f10133d93ba2a88642cd480d01ec4da53408c#799f10133d93ba2a88642cd480d01ec4da53408c" +dependencies = [ + "fs2", + "regex", + "reqwest 0.11.27", + "scratch", + "semver", + "zip", +] + [[package]] name = "weezl" version = "0.1.8" @@ -16026,6 +16452,26 @@ dependencies = [ "syn 2.0.87", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "aes", + "byteorder", + "bzip2", + "constant_time_eq 0.1.5", + "crc32fast", + "crossbeam-utils", + "flate2", + "hmac", + "pbkdf2 0.11.0", + "sha1", + "time", + "zstd", +] + [[package]] name = "zstd" version = "0.11.2+zstd.1.5.2" diff --git a/Cargo.toml b/Cargo.toml index 5bf65b3e14..a21a65c8fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -65,8 +65,9 @@ members = [ "crates/language_selector", "crates/language_tools", "crates/languages", - "crates/live_kit_client", - "crates/live_kit_server", + "crates/livekit_client", + "crates/livekit_client_macos", + "crates/livekit_server", "crates/lsp", "crates/markdown", "crates/markdown_preview", @@ -248,8 +249,9 @@ language_models = { path = "crates/language_models" } language_selector = { path = "crates/language_selector" } language_tools = { path = "crates/language_tools" } languages = { path = "crates/languages" } -live_kit_client = { path = "crates/live_kit_client" } -live_kit_server = { path = "crates/live_kit_server" } +livekit_client = { path = "crates/livekit_client" } +livekit_client_macos = { path = "crates/livekit_client_macos" } +livekit_server = { path = "crates/livekit_server" } lsp = { path = "crates/lsp" } markdown = { path = "crates/markdown" } markdown_preview = { path = "crates/markdown_preview" } @@ -382,6 +384,7 @@ heed = { version = "0.20.1", features = ["read-txn-no-tls"] } hex = "0.4.3" html5ever = "0.27.0" hyper = "0.14" +http = "1.1" ignore = "0.4.22" image = "0.25.1" indexmap = { version = "1.6.2", features = ["serde"] } @@ -393,6 +396,7 @@ jupyter-websocket-client = { version = "0.8.0" } libc = "0.2" libsqlite3-sys = { version = "0.30.1", features = ["bundled"] } linkify = "0.10.0" +livekit = { git = "https://github.com/zed-industries/rust-sdks", rev="799f10133d93ba2a88642cd480d01ec4da53408c", features = ["dispatcher", "services-dispatcher", "rustls-tls-native-roots"], default-features = false } log = { version = "0.4.16", features = ["kv_unstable_serde", "serde"] } markup5ever_rcdom = "0.3.0" nanoid = "0.4" @@ -571,6 +575,10 @@ features = [ "Win32_UI_WindowsAndMessaging", ] +# TODO livekit https://github.com/RustAudio/cpal/pull/891 +[patch.crates-io] +cpal = { git = "https://github.com/zed-industries/cpal", rev = "fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50" } + [profile.dev] split-debuginfo = "unpacked" debug = "limited" diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index 974c860c08..e7bc8b44a3 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -17,21 +17,23 @@ test-support = [ "client/test-support", "collections/test-support", "gpui/test-support", - "live_kit_client/test-support", + "livekit_client/test-support", "project/test-support", "util/test-support" ] +livekit-macos = ["livekit_client_macos"] +livekit-cross-platform = ["livekit_client"] [dependencies] anyhow.workspace = true audio.workspace = true client.workspace = true collections.workspace = true +feature_flags.workspace = true fs.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true -live_kit_client.workspace = true log.workspace = true postage.workspace = true project.workspace = true @@ -40,6 +42,8 @@ serde.workspace = true serde_derive.workspace = true settings.workspace = true util.workspace = true +livekit_client_macos = { workspace = true, optional = true } +livekit_client = { workspace = true, optional = true } [dev-dependencies] client = { workspace = true, features = ["test-support"] } @@ -47,7 +51,12 @@ collections = { workspace = true, features = ["test-support"] } fs = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } language = { workspace = true, features = ["test-support"] } -live_kit_client = { workspace = true, features = ["test-support"] } project = { workspace = true, features = ["test-support"] } util = { workspace = true, features = ["test-support"] } http_client = { workspace = true, features = ["test-support"] } + +[target.'cfg(target_os = "macos")'.dev-dependencies] +livekit_client_macos = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_os = "macos"))'.dev-dependencies] +livekit_client = { workspace = true, features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index c7993f3658..9fdce4b8ba 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -1,546 +1,41 @@ pub mod call_settings; -pub mod participant; -pub mod room; -use anyhow::{anyhow, Result}; -use audio::Audio; -use call_settings::CallSettings; -use client::{proto, ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; -use collections::HashSet; -use futures::{channel::oneshot, future::Shared, Future, FutureExt}; -use gpui::{ - AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Subscription, - Task, WeakModel, -}; -use postage::watch; -use project::Project; -use room::Event; -use settings::Settings; -use std::sync::Arc; +#[cfg(any( + all(target_os = "macos", feature = "livekit-macos"), + all( + not(target_os = "macos"), + feature = "livekit-macos", + not(feature = "livekit-cross-platform") + ) +))] +mod macos; -pub use participant::ParticipantLocation; -pub use room::Room; +#[cfg(any( + all(target_os = "macos", feature = "livekit-macos"), + all( + not(target_os = "macos"), + feature = "livekit-macos", + not(feature = "livekit-cross-platform") + ) +))] +pub use macos::*; -struct GlobalActiveCall(Model); - -impl Global for GlobalActiveCall {} - -pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { - CallSettings::register(cx); - - let active_call = cx.new_model(|cx| ActiveCall::new(client, user_store, cx)); - cx.set_global(GlobalActiveCall(active_call)); -} - -pub struct OneAtATime { - cancel: Option>, -} - -impl OneAtATime { - /// spawn a task in the given context. - /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None) - /// otherwise you'll see the result of the task. - fn spawn(&mut self, cx: &mut AppContext, f: F) -> Task>> - where - F: 'static + FnOnce(AsyncAppContext) -> Fut, - Fut: Future>, - R: 'static, - { - let (tx, rx) = oneshot::channel(); - self.cancel.replace(tx); - cx.spawn(|cx| async move { - futures::select_biased! { - _ = rx.fuse() => Ok(None), - result = f(cx).fuse() => result.map(Some), - } - }) - } - - fn running(&self) -> bool { - self.cancel - .as_ref() - .is_some_and(|cancel| !cancel.is_canceled()) - } -} - -#[derive(Clone)] -pub struct IncomingCall { - pub room_id: u64, - pub calling_user: Arc, - pub participants: Vec>, - pub initial_project: Option, -} - -/// Singleton global maintaining the user's participation in a room across workspaces. -pub struct ActiveCall { - room: Option<(Model, Vec)>, - pending_room_creation: Option, Arc>>>>, - location: Option>, - _join_debouncer: OneAtATime, - pending_invites: HashSet, - incoming_call: ( - watch::Sender>, - watch::Receiver>, +#[cfg(any( + all( + target_os = "macos", + feature = "livekit-cross-platform", + not(feature = "livekit-macos"), ), - client: Arc, - user_store: Model, - _subscriptions: Vec, -} + all(not(target_os = "macos"), feature = "livekit-cross-platform"), +))] +mod cross_platform; -impl EventEmitter for ActiveCall {} - -impl ActiveCall { - fn new(client: Arc, user_store: Model, cx: &mut ModelContext) -> Self { - Self { - room: None, - pending_room_creation: None, - location: None, - pending_invites: Default::default(), - incoming_call: watch::channel(), - _join_debouncer: OneAtATime { cancel: None }, - _subscriptions: vec![ - client.add_request_handler(cx.weak_model(), Self::handle_incoming_call), - client.add_message_handler(cx.weak_model(), Self::handle_call_canceled), - ], - client, - user_store, - } - } - - pub fn channel_id(&self, cx: &AppContext) -> Option { - self.room()?.read(cx).channel_id() - } - - async fn handle_incoming_call( - this: Model, - envelope: TypedEnvelope, - mut cx: AsyncAppContext, - ) -> Result { - let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; - let call = IncomingCall { - room_id: envelope.payload.room_id, - participants: user_store - .update(&mut cx, |user_store, cx| { - user_store.get_users(envelope.payload.participant_user_ids, cx) - })? - .await?, - calling_user: user_store - .update(&mut cx, |user_store, cx| { - user_store.get_user(envelope.payload.calling_user_id, cx) - })? - .await?, - initial_project: envelope.payload.initial_project, - }; - this.update(&mut cx, |this, _| { - *this.incoming_call.0.borrow_mut() = Some(call); - })?; - - Ok(proto::Ack {}) - } - - async fn handle_call_canceled( - this: Model, - envelope: TypedEnvelope, - mut cx: AsyncAppContext, - ) -> Result<()> { - this.update(&mut cx, |this, _| { - let mut incoming_call = this.incoming_call.0.borrow_mut(); - if incoming_call - .as_ref() - .map_or(false, |call| call.room_id == envelope.payload.room_id) - { - incoming_call.take(); - } - })?; - Ok(()) - } - - pub fn global(cx: &AppContext) -> Model { - cx.global::().0.clone() - } - - pub fn try_global(cx: &AppContext) -> Option> { - cx.try_global::() - .map(|call| call.0.clone()) - } - - pub fn invite( - &mut self, - called_user_id: u64, - initial_project: Option>, - cx: &mut ModelContext, - ) -> Task> { - if !self.pending_invites.insert(called_user_id) { - return Task::ready(Err(anyhow!("user was already invited"))); - } - cx.notify(); - - if self._join_debouncer.running() { - return Task::ready(Ok(())); - } - - let room = if let Some(room) = self.room().cloned() { - Some(Task::ready(Ok(room)).shared()) - } else { - self.pending_room_creation.clone() - }; - - let invite = if let Some(room) = room { - cx.spawn(move |_, mut cx| async move { - let room = room.await.map_err(|err| anyhow!("{:?}", err))?; - - let initial_project_id = if let Some(initial_project) = initial_project { - Some( - room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))? - .await?, - ) - } else { - None - }; - - room.update(&mut cx, move |room, cx| { - room.call(called_user_id, initial_project_id, cx) - })? - .await?; - - anyhow::Ok(()) - }) - } else { - let client = self.client.clone(); - let user_store = self.user_store.clone(); - let room = cx - .spawn(move |this, mut cx| async move { - let create_room = async { - let room = cx - .update(|cx| { - Room::create( - called_user_id, - initial_project, - client, - user_store, - cx, - ) - })? - .await?; - - this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))? - .await?; - - anyhow::Ok(room) - }; - - let room = create_room.await; - this.update(&mut cx, |this, _| this.pending_room_creation = None)?; - room.map_err(Arc::new) - }) - .shared(); - self.pending_room_creation = Some(room.clone()); - cx.background_executor().spawn(async move { - room.await.map_err(|err| anyhow!("{:?}", err))?; - anyhow::Ok(()) - }) - }; - - cx.spawn(move |this, mut cx| async move { - let result = invite.await; - if result.is_ok() { - this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; - } else { - //TODO: report collaboration error - log::error!("invite failed: {:?}", result); - } - - this.update(&mut cx, |this, cx| { - this.pending_invites.remove(&called_user_id); - cx.notify(); - })?; - result - }) - } - - pub fn cancel_invite( - &mut self, - called_user_id: u64, - cx: &mut ModelContext, - ) -> Task> { - let room_id = if let Some(room) = self.room() { - room.read(cx).id() - } else { - return Task::ready(Err(anyhow!("no active call"))); - }; - - let client = self.client.clone(); - cx.background_executor().spawn(async move { - client - .request(proto::CancelCall { - room_id, - called_user_id, - }) - .await?; - anyhow::Ok(()) - }) - } - - pub fn incoming(&self) -> watch::Receiver> { - self.incoming_call.1.clone() - } - - pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { - if self.room.is_some() { - return Task::ready(Err(anyhow!("cannot join while on another call"))); - } - - let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { - call - } else { - return Task::ready(Err(anyhow!("no incoming call"))); - }; - - if self.pending_room_creation.is_some() { - return Task::ready(Ok(())); - } - - let room_id = call.room_id; - let client = self.client.clone(); - let user_store = self.user_store.clone(); - let join = self - ._join_debouncer - .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx)); - - cx.spawn(|this, mut cx| async move { - let room = join.await?; - this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? - .await?; - this.update(&mut cx, |this, cx| { - this.report_call_event("accept incoming", cx) - })?; - Ok(()) - }) - } - - pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { - let call = self - .incoming_call - .0 - .borrow_mut() - .take() - .ok_or_else(|| anyhow!("no incoming call"))?; - report_call_event_for_room("decline incoming", call.room_id, None, &self.client); - self.client.send(proto::DeclineCall { - room_id: call.room_id, - })?; - Ok(()) - } - - pub fn join_channel( - &mut self, - channel_id: ChannelId, - cx: &mut ModelContext, - ) -> Task>>> { - if let Some(room) = self.room().cloned() { - if room.read(cx).channel_id() == Some(channel_id) { - return Task::ready(Ok(Some(room))); - } else { - room.update(cx, |room, cx| room.clear_state(cx)); - } - } - - if self.pending_room_creation.is_some() { - return Task::ready(Ok(None)); - } - - let client = self.client.clone(); - let user_store = self.user_store.clone(); - let join = self._join_debouncer.spawn(cx, move |cx| async move { - Room::join_channel(channel_id, client, user_store, cx).await - }); - - cx.spawn(|this, mut cx| async move { - let room = join.await?; - this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? - .await?; - this.update(&mut cx, |this, cx| { - this.report_call_event("join channel", cx) - })?; - Ok(room) - }) - } - - pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { - cx.notify(); - self.report_call_event("hang up", cx); - - Audio::end_call(cx); - - let channel_id = self.channel_id(cx); - if let Some((room, _)) = self.room.take() { - cx.emit(Event::RoomLeft { channel_id }); - room.update(cx, |room, cx| room.leave(cx)) - } else { - Task::ready(Ok(())) - } - } - - pub fn share_project( - &mut self, - project: Model, - cx: &mut ModelContext, - ) -> Task> { - if let Some((room, _)) = self.room.as_ref() { - self.report_call_event("share project", cx); - room.update(cx, |room, cx| room.share_project(project, cx)) - } else { - Task::ready(Err(anyhow!("no active call"))) - } - } - - pub fn unshare_project( - &mut self, - project: Model, - cx: &mut ModelContext, - ) -> Result<()> { - if let Some((room, _)) = self.room.as_ref() { - self.report_call_event("unshare project", cx); - room.update(cx, |room, cx| room.unshare_project(project, cx)) - } else { - Err(anyhow!("no active call")) - } - } - - pub fn location(&self) -> Option<&WeakModel> { - self.location.as_ref() - } - - pub fn set_location( - &mut self, - project: Option<&Model>, - cx: &mut ModelContext, - ) -> Task> { - if project.is_some() || !*ZED_ALWAYS_ACTIVE { - self.location = project.map(|project| project.downgrade()); - if let Some((room, _)) = self.room.as_ref() { - return room.update(cx, |room, cx| room.set_location(project, cx)); - } - } - Task::ready(Ok(())) - } - - fn set_room( - &mut self, - room: Option>, - cx: &mut ModelContext, - ) -> Task> { - if room.as_ref() == self.room.as_ref().map(|room| &room.0) { - Task::ready(Ok(())) - } else { - cx.notify(); - if let Some(room) = room { - if room.read(cx).status().is_offline() { - self.room = None; - Task::ready(Ok(())) - } else { - let subscriptions = vec![ - cx.observe(&room, |this, room, cx| { - if room.read(cx).status().is_offline() { - this.set_room(None, cx).detach_and_log_err(cx); - } - - cx.notify(); - }), - cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), - ]; - self.room = Some((room.clone(), subscriptions)); - let location = self - .location - .as_ref() - .and_then(|location| location.upgrade()); - let channel_id = room.read(cx).channel_id(); - cx.emit(Event::RoomJoined { channel_id }); - room.update(cx, |room, cx| room.set_location(location.as_ref(), cx)) - } - } else { - self.room = None; - Task::ready(Ok(())) - } - } - } - - pub fn room(&self) -> Option<&Model> { - self.room.as_ref().map(|(room, _)| room) - } - - pub fn client(&self) -> Arc { - self.client.clone() - } - - pub fn pending_invites(&self) -> &HashSet { - &self.pending_invites - } - - pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { - if let Some(room) = self.room() { - let room = room.read(cx); - report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); - } - } -} - -pub fn report_call_event_for_room( - operation: &'static str, - room_id: u64, - channel_id: Option, - client: &Arc, -) { - let telemetry = client.telemetry(); - - telemetry.report_call_event(operation, Some(room_id), channel_id) -} - -pub fn report_call_event_for_channel( - operation: &'static str, - channel_id: ChannelId, - client: &Arc, - cx: &AppContext, -) { - let room = ActiveCall::global(cx).read(cx).room(); - - let telemetry = client.telemetry(); - - telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) -} - -#[cfg(test)] -mod test { - use gpui::TestAppContext; - - use crate::OneAtATime; - - #[gpui::test] - async fn test_one_at_a_time(cx: &mut TestAppContext) { - let mut one_at_a_time = OneAtATime { cancel: None }; - - assert_eq!( - cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) })) - .await - .unwrap(), - Some(1) - ); - - let (a, b) = cx.update(|cx| { - ( - one_at_a_time.spawn(cx, |_| async { - panic!(""); - }), - one_at_a_time.spawn(cx, |_| async { Ok(3) }), - ) - }); - - assert_eq!(a.await.unwrap(), None::); - assert_eq!(b.await.unwrap(), Some(3)); - - let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) })); - drop(one_at_a_time); - - assert_eq!(promise.await.unwrap(), None); - } -} +#[cfg(any( + all( + target_os = "macos", + feature = "livekit-cross-platform", + not(feature = "livekit-macos"), + ), + all(not(target_os = "macos"), feature = "livekit-cross-platform"), +))] +pub use cross_platform::*; diff --git a/crates/call/src/cross_platform/mod.rs b/crates/call/src/cross_platform/mod.rs new file mode 100644 index 0000000000..4a95af1525 --- /dev/null +++ b/crates/call/src/cross_platform/mod.rs @@ -0,0 +1,552 @@ +pub mod participant; +pub mod room; + +use crate::call_settings::CallSettings; +use anyhow::{anyhow, Result}; +use audio::Audio; +use client::{proto, ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; +use collections::HashSet; +use futures::{channel::oneshot, future::Shared, Future, FutureExt}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Subscription, + Task, WeakModel, +}; +use postage::watch; +use project::Project; +use room::Event; +use settings::Settings; +use std::sync::Arc; + +pub use livekit_client::{ + track::RemoteVideoTrack, RemoteVideoTrackView, RemoteVideoTrackViewEvent, +}; +pub use participant::ParticipantLocation; +pub use room::Room; + +struct GlobalActiveCall(Model); + +impl Global for GlobalActiveCall {} + +pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { + livekit_client::init( + cx.background_executor().dispatcher.clone(), + cx.http_client(), + ); + CallSettings::register(cx); + + let active_call = cx.new_model(|cx| ActiveCall::new(client, user_store, cx)); + cx.set_global(GlobalActiveCall(active_call)); +} + +pub struct OneAtATime { + cancel: Option>, +} + +impl OneAtATime { + /// spawn a task in the given context. + /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None) + /// otherwise you'll see the result of the task. + fn spawn(&mut self, cx: &mut AppContext, f: F) -> Task>> + where + F: 'static + FnOnce(AsyncAppContext) -> Fut, + Fut: Future>, + R: 'static, + { + let (tx, rx) = oneshot::channel(); + self.cancel.replace(tx); + cx.spawn(|cx| async move { + futures::select_biased! { + _ = rx.fuse() => Ok(None), + result = f(cx).fuse() => result.map(Some), + } + }) + } + + fn running(&self) -> bool { + self.cancel + .as_ref() + .is_some_and(|cancel| !cancel.is_canceled()) + } +} + +#[derive(Clone)] +pub struct IncomingCall { + pub room_id: u64, + pub calling_user: Arc, + pub participants: Vec>, + pub initial_project: Option, +} + +/// Singleton global maintaining the user's participation in a room across workspaces. +pub struct ActiveCall { + room: Option<(Model, Vec)>, + pending_room_creation: Option, Arc>>>>, + location: Option>, + _join_debouncer: OneAtATime, + pending_invites: HashSet, + incoming_call: ( + watch::Sender>, + watch::Receiver>, + ), + client: Arc, + user_store: Model, + _subscriptions: Vec, +} + +impl EventEmitter for ActiveCall {} + +impl ActiveCall { + fn new(client: Arc, user_store: Model, cx: &mut ModelContext) -> Self { + Self { + room: None, + pending_room_creation: None, + location: None, + pending_invites: Default::default(), + incoming_call: watch::channel(), + _join_debouncer: OneAtATime { cancel: None }, + _subscriptions: vec![ + client.add_request_handler(cx.weak_model(), Self::handle_incoming_call), + client.add_message_handler(cx.weak_model(), Self::handle_call_canceled), + ], + client, + user_store, + } + } + + pub fn channel_id(&self, cx: &AppContext) -> Option { + self.room()?.read(cx).channel_id() + } + + async fn handle_incoming_call( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; + let call = IncomingCall { + room_id: envelope.payload.room_id, + participants: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_users(envelope.payload.participant_user_ids, cx) + })? + .await?, + calling_user: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_user(envelope.payload.calling_user_id, cx) + })? + .await?, + initial_project: envelope.payload.initial_project, + }; + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = Some(call); + })?; + + Ok(proto::Ack {}) + } + + async fn handle_call_canceled( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + let mut incoming_call = this.incoming_call.0.borrow_mut(); + if incoming_call + .as_ref() + .map_or(false, |call| call.room_id == envelope.payload.room_id) + { + incoming_call.take(); + } + })?; + Ok(()) + } + + pub fn global(cx: &AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn try_global(cx: &AppContext) -> Option> { + cx.try_global::() + .map(|call| call.0.clone()) + } + + pub fn invite( + &mut self, + called_user_id: u64, + initial_project: Option>, + cx: &mut ModelContext, + ) -> Task> { + if !self.pending_invites.insert(called_user_id) { + return Task::ready(Err(anyhow!("user was already invited"))); + } + cx.notify(); + + if self._join_debouncer.running() { + return Task::ready(Ok(())); + } + + let room = if let Some(room) = self.room().cloned() { + Some(Task::ready(Ok(room)).shared()) + } else { + self.pending_room_creation.clone() + }; + + let invite = if let Some(room) = room { + cx.spawn(move |_, mut cx| async move { + let room = room.await.map_err(|err| anyhow!("{:?}", err))?; + + let initial_project_id = if let Some(initial_project) = initial_project { + Some( + room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))? + .await?, + ) + } else { + None + }; + + room.update(&mut cx, move |room, cx| { + room.call(called_user_id, initial_project_id, cx) + })? + .await?; + + anyhow::Ok(()) + }) + } else { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let room = cx + .spawn(move |this, mut cx| async move { + let create_room = async { + let room = cx + .update(|cx| { + Room::create( + called_user_id, + initial_project, + client, + user_store, + cx, + ) + })? + .await?; + + this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))? + .await?; + + anyhow::Ok(room) + }; + + let room = create_room.await; + this.update(&mut cx, |this, _| this.pending_room_creation = None)?; + room.map_err(Arc::new) + }) + .shared(); + self.pending_room_creation = Some(room.clone()); + cx.background_executor().spawn(async move { + room.await.map_err(|err| anyhow!("{:?}", err))?; + anyhow::Ok(()) + }) + }; + + cx.spawn(move |this, mut cx| async move { + let result = invite.await; + if result.is_ok() { + this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; + } else { + //TODO: report collaboration error + log::error!("invite failed: {:?}", result); + } + + this.update(&mut cx, |this, cx| { + this.pending_invites.remove(&called_user_id); + cx.notify(); + })?; + result + }) + } + + pub fn cancel_invite( + &mut self, + called_user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let room_id = if let Some(room) = self.room() { + room.read(cx).id() + } else { + return Task::ready(Err(anyhow!("no active call"))); + }; + + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::CancelCall { + room_id, + called_user_id, + }) + .await?; + anyhow::Ok(()) + }) + } + + pub fn incoming(&self) -> watch::Receiver> { + self.incoming_call.1.clone() + } + + pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { + if self.room.is_some() { + return Task::ready(Err(anyhow!("cannot join while on another call"))); + } + + let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { + call + } else { + return Task::ready(Err(anyhow!("no incoming call"))); + }; + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(())); + } + + let room_id = call.room_id; + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self + ._join_debouncer + .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx)); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("accept incoming", cx) + })?; + Ok(()) + }) + } + + pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { + let call = self + .incoming_call + .0 + .borrow_mut() + .take() + .ok_or_else(|| anyhow!("no incoming call"))?; + report_call_event_for_room("decline incoming", call.room_id, None, &self.client); + self.client.send(proto::DeclineCall { + room_id: call.room_id, + })?; + Ok(()) + } + + pub fn join_channel( + &mut self, + channel_id: ChannelId, + cx: &mut ModelContext, + ) -> Task>>> { + if let Some(room) = self.room().cloned() { + if room.read(cx).channel_id() == Some(channel_id) { + return Task::ready(Ok(Some(room))); + } else { + room.update(cx, |room, cx| room.clear_state(cx)); + } + } + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(None)); + } + + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self._join_debouncer.spawn(cx, move |cx| async move { + Room::join_channel(channel_id, client, user_store, cx).await + }); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("join channel", cx) + })?; + Ok(room) + }) + } + + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); + self.report_call_event("hang up", cx); + + Audio::end_call(cx); + + let channel_id = self.channel_id(cx); + if let Some((room, _)) = self.room.take() { + cx.emit(Event::RoomLeft { channel_id }); + room.update(cx, |room, cx| room.leave(cx)) + } else { + Task::ready(Ok(())) + } + } + + pub fn share_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("share project", cx); + room.update(cx, |room, cx| room.share_project(project, cx)) + } else { + Task::ready(Err(anyhow!("no active call"))) + } + } + + pub fn unshare_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Result<()> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("unshare project", cx); + room.update(cx, |room, cx| room.unshare_project(project, cx)) + } else { + Err(anyhow!("no active call")) + } + } + + pub fn location(&self) -> Option<&WeakModel> { + self.location.as_ref() + } + + pub fn set_location( + &mut self, + project: Option<&Model>, + cx: &mut ModelContext, + ) -> Task> { + if project.is_some() || !*ZED_ALWAYS_ACTIVE { + self.location = project.map(|project| project.downgrade()); + if let Some((room, _)) = self.room.as_ref() { + return room.update(cx, |room, cx| room.set_location(project, cx)); + } + } + Task::ready(Ok(())) + } + + fn set_room( + &mut self, + room: Option>, + cx: &mut ModelContext, + ) -> Task> { + if room.as_ref() == self.room.as_ref().map(|room| &room.0) { + Task::ready(Ok(())) + } else { + cx.notify(); + if let Some(room) = room { + if room.read(cx).status().is_offline() { + self.room = None; + Task::ready(Ok(())) + } else { + let subscriptions = vec![ + cx.observe(&room, |this, room, cx| { + if room.read(cx).status().is_offline() { + this.set_room(None, cx).detach_and_log_err(cx); + } + + cx.notify(); + }), + cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), + ]; + self.room = Some((room.clone(), subscriptions)); + let location = self + .location + .as_ref() + .and_then(|location| location.upgrade()); + let channel_id = room.read(cx).channel_id(); + cx.emit(Event::RoomJoined { channel_id }); + room.update(cx, |room, cx| room.set_location(location.as_ref(), cx)) + } + } else { + self.room = None; + Task::ready(Ok(())) + } + } + } + + pub fn room(&self) -> Option<&Model> { + self.room.as_ref().map(|(room, _)| room) + } + + pub fn client(&self) -> Arc { + self.client.clone() + } + + pub fn pending_invites(&self) -> &HashSet { + &self.pending_invites + } + + pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { + if let Some(room) = self.room() { + let room = room.read(cx); + report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); + } + } +} + +pub fn report_call_event_for_room( + operation: &'static str, + room_id: u64, + channel_id: Option, + client: &Arc, +) { + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, Some(room_id), channel_id) +} + +pub fn report_call_event_for_channel( + operation: &'static str, + channel_id: ChannelId, + client: &Arc, + cx: &AppContext, +) { + let room = ActiveCall::global(cx).read(cx).room(); + + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) +} + +#[cfg(test)] +mod test { + use gpui::TestAppContext; + + use crate::OneAtATime; + + #[gpui::test] + async fn test_one_at_a_time(cx: &mut TestAppContext) { + let mut one_at_a_time = OneAtATime { cancel: None }; + + assert_eq!( + cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) })) + .await + .unwrap(), + Some(1) + ); + + let (a, b) = cx.update(|cx| { + ( + one_at_a_time.spawn(cx, |_| async { + panic!(""); + }), + one_at_a_time.spawn(cx, |_| async { Ok(3) }), + ) + }); + + assert_eq!(a.await.unwrap(), None::); + assert_eq!(b.await.unwrap(), Some(3)); + + let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) })); + drop(one_at_a_time); + + assert_eq!(promise.await.unwrap(), None); + } +} diff --git a/crates/call/src/cross_platform/participant.rs b/crates/call/src/cross_platform/participant.rs new file mode 100644 index 0000000000..2ca33be728 --- /dev/null +++ b/crates/call/src/cross_platform/participant.rs @@ -0,0 +1,68 @@ +#![cfg_attr(target_os = "windows", allow(unused))] + +use anyhow::{anyhow, Result}; +use client::{proto, ParticipantIndex, User}; +use collections::HashMap; +use gpui::WeakModel; +use livekit_client::AudioStream; +use project::Project; +use std::sync::Arc; + +#[cfg(not(target_os = "windows"))] +pub use livekit_client::id::TrackSid; +pub use livekit_client::track::{RemoteAudioTrack, RemoteVideoTrack}; + +#[derive(Copy, Clone, Debug, Eq, PartialEq)] +pub enum ParticipantLocation { + SharedProject { project_id: u64 }, + UnsharedProject, + External, +} + +impl ParticipantLocation { + pub fn from_proto(location: Option) -> Result { + match location.and_then(|l| l.variant) { + Some(proto::participant_location::Variant::SharedProject(project)) => { + Ok(Self::SharedProject { + project_id: project.id, + }) + } + Some(proto::participant_location::Variant::UnsharedProject(_)) => { + Ok(Self::UnsharedProject) + } + Some(proto::participant_location::Variant::External(_)) => Ok(Self::External), + None => Err(anyhow!("participant location was not provided")), + } + } +} + +#[derive(Clone, Default)] +pub struct LocalParticipant { + pub projects: Vec, + pub active_project: Option>, + pub role: proto::ChannelRole, +} + +pub struct RemoteParticipant { + pub user: Arc, + pub peer_id: proto::PeerId, + pub role: proto::ChannelRole, + pub projects: Vec, + pub location: ParticipantLocation, + pub participant_index: ParticipantIndex, + pub muted: bool, + pub speaking: bool, + #[cfg(not(target_os = "windows"))] + pub video_tracks: HashMap, + #[cfg(not(target_os = "windows"))] + pub audio_tracks: HashMap, +} + +impl RemoteParticipant { + pub fn has_video_tracks(&self) -> bool { + #[cfg(not(target_os = "windows"))] + return !self.video_tracks.is_empty(); + #[cfg(target_os = "windows")] + return false; + } +} diff --git a/crates/call/src/cross_platform/room.rs b/crates/call/src/cross_platform/room.rs new file mode 100644 index 0000000000..11033098f7 --- /dev/null +++ b/crates/call/src/cross_platform/room.rs @@ -0,0 +1,1771 @@ +#![cfg_attr(target_os = "windows", allow(unused))] + +use crate::{ + call_settings::CallSettings, + participant::{LocalParticipant, ParticipantLocation, RemoteParticipant}, +}; +use anyhow::{anyhow, Result}; +use audio::{Audio, Sound}; +use client::{ + proto::{self, PeerId}, + ChannelId, Client, ParticipantIndex, TypedEnvelope, User, UserStore, +}; +use collections::{BTreeMap, HashMap, HashSet}; +use fs::Fs; +use futures::{FutureExt, StreamExt}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, +}; +use language::LanguageRegistry; +#[cfg(not(target_os = "windows"))] +use livekit::{ + capture_local_audio_track, capture_local_video_track, + id::ParticipantIdentity, + options::{TrackPublishOptions, VideoCodec}, + play_remote_audio_track, + publication::LocalTrackPublication, + track::{TrackKind, TrackSource}, + RoomEvent, RoomOptions, +}; +#[cfg(target_os = "windows")] +use livekit::{publication::LocalTrackPublication, RoomEvent}; +use livekit_client as livekit; +use postage::{sink::Sink, stream::Stream, watch}; +use project::Project; +use settings::Settings as _; +use std::{any::Any, future::Future, mem, sync::Arc, time::Duration}; +use util::{post_inc, ResultExt, TryFutureExt}; + +pub const RECONNECT_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Event { + RoomJoined { + channel_id: Option, + }, + ParticipantLocationChanged { + participant_id: proto::PeerId, + }, + RemoteVideoTracksChanged { + participant_id: proto::PeerId, + }, + RemoteAudioTracksChanged { + participant_id: proto::PeerId, + }, + RemoteProjectShared { + owner: Arc, + project_id: u64, + worktree_root_names: Vec, + }, + RemoteProjectUnshared { + project_id: u64, + }, + RemoteProjectJoined { + project_id: u64, + }, + RemoteProjectInvitationDiscarded { + project_id: u64, + }, + RoomLeft { + channel_id: Option, + }, +} + +pub struct Room { + id: u64, + channel_id: Option, + live_kit: Option, + status: RoomStatus, + shared_projects: HashSet>, + joined_projects: HashSet>, + local_participant: LocalParticipant, + remote_participants: BTreeMap, + pending_participants: Vec>, + participant_user_ids: HashSet, + pending_call_count: usize, + leave_when_empty: bool, + client: Arc, + user_store: Model, + follows_by_leader_id_project_id: HashMap<(PeerId, u64), Vec>, + client_subscriptions: Vec, + _subscriptions: Vec, + room_update_completed_tx: watch::Sender>, + room_update_completed_rx: watch::Receiver>, + pending_room_update: Option>, + maintain_connection: Option>>, +} + +impl EventEmitter for Room {} + +impl Room { + pub fn channel_id(&self) -> Option { + self.channel_id + } + + pub fn is_sharing_project(&self) -> bool { + !self.shared_projects.is_empty() + } + + #[cfg(all(any(test, feature = "test-support"), not(target_os = "windows")))] + pub fn is_connected(&self) -> bool { + if let Some(live_kit) = self.live_kit.as_ref() { + live_kit.room.connection_state() == livekit::ConnectionState::Connected + } else { + false + } + } + + fn new( + id: u64, + channel_id: Option, + livekit_connection_info: Option, + client: Arc, + user_store: Model, + cx: &mut ModelContext, + ) -> Self { + spawn_room_connection(livekit_connection_info, cx); + + let maintain_connection = cx.spawn({ + let client = client.clone(); + move |this, cx| Self::maintain_connection(this, client.clone(), cx).log_err() + }); + + Audio::play_sound(Sound::Joined, cx); + + let (room_update_completed_tx, room_update_completed_rx) = watch::channel(); + + Self { + id, + channel_id, + live_kit: None, + status: RoomStatus::Online, + shared_projects: Default::default(), + joined_projects: Default::default(), + participant_user_ids: Default::default(), + local_participant: Default::default(), + remote_participants: Default::default(), + pending_participants: Default::default(), + pending_call_count: 0, + client_subscriptions: vec![ + client.add_message_handler(cx.weak_model(), Self::handle_room_updated) + ], + _subscriptions: vec![ + cx.on_release(Self::released), + cx.on_app_quit(Self::app_will_quit), + ], + leave_when_empty: false, + pending_room_update: None, + client, + user_store, + follows_by_leader_id_project_id: Default::default(), + maintain_connection: Some(maintain_connection), + room_update_completed_tx, + room_update_completed_rx, + } + } + + pub(crate) fn create( + called_user_id: u64, + initial_project: Option>, + client: Arc, + user_store: Model, + cx: &mut AppContext, + ) -> Task>> { + cx.spawn(move |mut cx| async move { + let response = client.request(proto::CreateRoom {}).await?; + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.new_model(|cx| { + let mut room = Self::new( + room_proto.id, + None, + response.live_kit_connection_info, + client, + user_store, + cx, + ); + if let Some(participant) = room_proto.participants.first() { + room.local_participant.role = participant.role() + } + room + })?; + + let initial_project_id = if let Some(initial_project) = initial_project { + let initial_project_id = room + .update(&mut cx, |room, cx| { + room.share_project(initial_project.clone(), cx) + })? + .await?; + Some(initial_project_id) + } else { + None + }; + + let did_join = room + .update(&mut cx, |room, cx| { + room.leave_when_empty = true; + room.call(called_user_id, initial_project_id, cx) + })? + .await; + match did_join { + Ok(()) => Ok(room), + Err(error) => Err(error.context("room creation failed")), + } + }) + } + + pub(crate) async fn join_channel( + channel_id: ChannelId, + client: Arc, + user_store: Model, + cx: AsyncAppContext, + ) -> Result> { + Self::from_join_response( + client + .request(proto::JoinChannel { + channel_id: channel_id.0, + }) + .await?, + client, + user_store, + cx, + ) + } + + pub(crate) async fn join( + room_id: u64, + client: Arc, + user_store: Model, + cx: AsyncAppContext, + ) -> Result> { + Self::from_join_response( + client.request(proto::JoinRoom { id: room_id }).await?, + client, + user_store, + cx, + ) + } + + fn released(&mut self, cx: &mut AppContext) { + if self.status.is_online() { + self.leave_internal(cx).detach_and_log_err(cx); + } + } + + fn app_will_quit(&mut self, cx: &mut ModelContext) -> impl Future { + let task = if self.status.is_online() { + let leave = self.leave_internal(cx); + Some(cx.background_executor().spawn(async move { + leave.await.log_err(); + })) + } else { + None + }; + + async move { + if let Some(task) = task { + task.await; + } + } + } + + pub fn mute_on_join(cx: &AppContext) -> bool { + CallSettings::get_global(cx).mute_on_join || client::IMPERSONATE_LOGIN.is_some() + } + + fn from_join_response( + response: proto::JoinRoomResponse, + client: Arc, + user_store: Model, + mut cx: AsyncAppContext, + ) -> Result> { + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + let room = cx.new_model(|cx| { + Self::new( + room_proto.id, + response.channel_id.map(ChannelId), + response.live_kit_connection_info, + client, + user_store, + cx, + ) + })?; + room.update(&mut cx, |room, cx| { + room.leave_when_empty = room.channel_id.is_none(); + room.apply_room_update(room_proto, cx)?; + anyhow::Ok(()) + })??; + Ok(room) + } + + fn should_leave(&self) -> bool { + self.leave_when_empty + && self.pending_room_update.is_none() + && self.pending_participants.is_empty() + && self.remote_participants.is_empty() + && self.pending_call_count == 0 + } + + pub(crate) fn leave(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); + self.leave_internal(cx) + } + + fn leave_internal(&mut self, cx: &mut AppContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + log::info!("leaving room"); + Audio::play_sound(Sound::Leave, cx); + + self.clear_state(cx); + + let leave_room = self.client.request(proto::LeaveRoom {}); + cx.background_executor().spawn(async move { + leave_room.await?; + anyhow::Ok(()) + }) + } + + pub(crate) fn clear_state(&mut self, cx: &mut AppContext) { + for project in self.shared_projects.drain() { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| { + project.unshare(cx).log_err(); + }); + } + } + for project in self.joined_projects.drain() { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| { + project.disconnected_from_host(cx); + project.close(cx); + }); + } + } + + self.status = RoomStatus::Offline; + self.remote_participants.clear(); + self.pending_participants.clear(); + self.participant_user_ids.clear(); + self.client_subscriptions.clear(); + self.live_kit.take(); + self.pending_room_update.take(); + self.maintain_connection.take(); + } + + async fn maintain_connection( + this: WeakModel, + client: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let mut client_status = client.status(); + loop { + let _ = client_status.try_recv(); + let is_connected = client_status.borrow().is_connected(); + // Even if we're initially connected, any future change of the status means we momentarily disconnected. + if !is_connected || client_status.next().await.is_some() { + log::info!("detected client disconnection"); + + this.upgrade() + .ok_or_else(|| anyhow!("room was dropped"))? + .update(&mut cx, |this, cx| { + this.status = RoomStatus::Rejoining; + cx.notify(); + })?; + + // Wait for client to re-establish a connection to the server. + { + let mut reconnection_timeout = + cx.background_executor().timer(RECONNECT_TIMEOUT).fuse(); + let client_reconnection = async { + let mut remaining_attempts = 3; + while remaining_attempts > 0 { + if client_status.borrow().is_connected() { + log::info!("client reconnected, attempting to rejoin room"); + + let Some(this) = this.upgrade() else { break }; + match this.update(&mut cx, |this, cx| this.rejoin(cx)) { + Ok(task) => { + if task.await.log_err().is_some() { + return true; + } else { + remaining_attempts -= 1; + } + } + Err(_app_dropped) => return false, + } + } else if client_status.borrow().is_signed_out() { + return false; + } + + log::info!( + "waiting for client status change, remaining attempts {}", + remaining_attempts + ); + client_status.next().await; + } + false + } + .fuse(); + futures::pin_mut!(client_reconnection); + + futures::select_biased! { + reconnected = client_reconnection => { + if reconnected { + log::info!("successfully reconnected to room"); + // If we successfully joined the room, go back around the loop + // waiting for future connection status changes. + continue; + } + } + _ = reconnection_timeout => { + log::info!("room reconnection timeout expired"); + } + } + } + + break; + } + } + + // The client failed to re-establish a connection to the server + // or an error occurred while trying to re-join the room. Either way + // we leave the room and return an error. + if let Some(this) = this.upgrade() { + log::info!("reconnection failed, leaving room"); + this.update(&mut cx, |this, cx| this.leave(cx))?.await?; + } + Err(anyhow!( + "can't reconnect to room: client failed to re-establish connection" + )) + } + + fn rejoin(&mut self, cx: &mut ModelContext) -> Task> { + let mut projects = HashMap::default(); + let mut reshared_projects = Vec::new(); + let mut rejoined_projects = Vec::new(); + self.shared_projects.retain(|project| { + if let Some(handle) = project.upgrade() { + let project = handle.read(cx); + if let Some(project_id) = project.remote_id() { + projects.insert(project_id, handle.clone()); + reshared_projects.push(proto::UpdateProject { + project_id, + worktrees: project.worktree_metadata_protos(cx), + }); + return true; + } + } + false + }); + self.joined_projects.retain(|project| { + if let Some(handle) = project.upgrade() { + let project = handle.read(cx); + if let Some(project_id) = project.remote_id() { + projects.insert(project_id, handle.clone()); + rejoined_projects.push(proto::RejoinProject { + id: project_id, + worktrees: project + .worktrees(cx) + .map(|worktree| { + let worktree = worktree.read(cx); + proto::RejoinWorktree { + id: worktree.id().to_proto(), + scan_id: worktree.completed_scan_id() as u64, + } + }) + .collect(), + }); + } + return true; + } + false + }); + + let response = self.client.request_envelope(proto::RejoinRoom { + id: self.id, + reshared_projects, + rejoined_projects, + }); + + cx.spawn(|this, mut cx| async move { + let response = response.await?; + let message_id = response.message_id; + let response = response.payload; + let room_proto = response.room.ok_or_else(|| anyhow!("invalid room"))?; + this.update(&mut cx, |this, cx| { + this.status = RoomStatus::Online; + this.apply_room_update(room_proto, cx)?; + + for reshared_project in response.reshared_projects { + if let Some(project) = projects.get(&reshared_project.id) { + project.update(cx, |project, cx| { + project.reshared(reshared_project, cx).log_err(); + }); + } + } + + for rejoined_project in response.rejoined_projects { + if let Some(project) = projects.get(&rejoined_project.id) { + project.update(cx, |project, cx| { + project.rejoined(rejoined_project, message_id, cx).log_err(); + }); + } + } + + anyhow::Ok(()) + })? + }) + } + + pub fn id(&self) -> u64 { + self.id + } + + pub fn status(&self) -> RoomStatus { + self.status + } + + pub fn local_participant(&self) -> &LocalParticipant { + &self.local_participant + } + + pub fn remote_participants(&self) -> &BTreeMap { + &self.remote_participants + } + + pub fn remote_participant_for_peer_id(&self, peer_id: PeerId) -> Option<&RemoteParticipant> { + self.remote_participants + .values() + .find(|p| p.peer_id == peer_id) + } + + pub fn role_for_user(&self, user_id: u64) -> Option { + self.remote_participants + .get(&user_id) + .map(|participant| participant.role) + } + + pub fn contains_guests(&self) -> bool { + self.local_participant.role == proto::ChannelRole::Guest + || self + .remote_participants + .values() + .any(|p| p.role == proto::ChannelRole::Guest) + } + + pub fn local_participant_is_admin(&self) -> bool { + self.local_participant.role == proto::ChannelRole::Admin + } + + pub fn local_participant_is_guest(&self) -> bool { + self.local_participant.role == proto::ChannelRole::Guest + } + + pub fn set_participant_role( + &mut self, + user_id: u64, + role: proto::ChannelRole, + cx: &ModelContext, + ) -> Task> { + let client = self.client.clone(); + let room_id = self.id; + let role = role.into(); + cx.spawn(|_, _| async move { + client + .request(proto::SetRoomParticipantRole { + room_id, + user_id, + role, + }) + .await + .map(|_| ()) + }) + } + + pub fn pending_participants(&self) -> &[Arc] { + &self.pending_participants + } + + pub fn contains_participant(&self, user_id: u64) -> bool { + self.participant_user_ids.contains(&user_id) + } + + pub fn followers_for(&self, leader_id: PeerId, project_id: u64) -> &[PeerId] { + self.follows_by_leader_id_project_id + .get(&(leader_id, project_id)) + .map_or(&[], |v| v.as_slice()) + } + + /// Returns the most 'active' projects, defined as most people in the project + pub fn most_active_project(&self, cx: &AppContext) -> Option<(u64, u64)> { + let mut project_hosts_and_guest_counts = HashMap::, u32)>::default(); + for participant in self.remote_participants.values() { + match participant.location { + ParticipantLocation::SharedProject { project_id } => { + project_hosts_and_guest_counts + .entry(project_id) + .or_default() + .1 += 1; + } + ParticipantLocation::External | ParticipantLocation::UnsharedProject => {} + } + for project in &participant.projects { + project_hosts_and_guest_counts + .entry(project.id) + .or_default() + .0 = Some(participant.user.id); + } + } + + if let Some(user) = self.user_store.read(cx).current_user() { + for project in &self.local_participant.projects { + project_hosts_and_guest_counts + .entry(project.id) + .or_default() + .0 = Some(user.id); + } + } + + project_hosts_and_guest_counts + .into_iter() + .filter_map(|(id, (host, guest_count))| Some((id, host?, guest_count))) + .max_by_key(|(_, _, guest_count)| *guest_count) + .map(|(id, host, _)| (id, host)) + } + + async fn handle_room_updated( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + let room = envelope + .payload + .room + .ok_or_else(|| anyhow!("invalid room"))?; + this.update(&mut cx, |this, cx| this.apply_room_update(room, cx))? + } + + fn apply_room_update(&mut self, room: proto::Room, cx: &mut ModelContext) -> Result<()> { + log::trace!( + "client {:?}. room update: {:?}", + self.client.user_id(), + &room + ); + + self.pending_room_update = Some(self.start_room_connection(room, cx)); + + cx.notify(); + Ok(()) + } + + pub fn room_update_completed(&mut self) -> impl Future { + let mut done_rx = self.room_update_completed_rx.clone(); + async move { + while let Some(result) = done_rx.next().await { + if result.is_some() { + break; + } + } + } + } + + #[cfg(target_os = "windows")] + fn start_room_connection( + &self, + mut room: proto::Room, + cx: &mut ModelContext, + ) -> Task<()> { + Task::ready(()) + } + + #[cfg(not(target_os = "windows"))] + fn start_room_connection( + &self, + mut room: proto::Room, + cx: &mut ModelContext, + ) -> Task<()> { + // Filter ourselves out from the room's participants. + let local_participant_ix = room + .participants + .iter() + .position(|participant| Some(participant.user_id) == self.client.user_id()); + let local_participant = local_participant_ix.map(|ix| room.participants.swap_remove(ix)); + + let pending_participant_user_ids = room + .pending_participants + .iter() + .map(|p| p.user_id) + .collect::>(); + + let remote_participant_user_ids = room + .participants + .iter() + .map(|p| p.user_id) + .collect::>(); + + let (remote_participants, pending_participants) = + self.user_store.update(cx, move |user_store, cx| { + ( + user_store.get_users(remote_participant_user_ids, cx), + user_store.get_users(pending_participant_user_ids, cx), + ) + }); + cx.spawn(|this, mut cx| async move { + let (remote_participants, pending_participants) = + futures::join!(remote_participants, pending_participants); + + this.update(&mut cx, |this, cx| { + this.participant_user_ids.clear(); + + if let Some(participant) = local_participant { + let role = participant.role(); + this.local_participant.projects = participant.projects; + if this.local_participant.role != role { + this.local_participant.role = role; + + if role == proto::ChannelRole::Guest { + for project in mem::take(&mut this.shared_projects) { + if let Some(project) = project.upgrade() { + this.unshare_project(project, cx).log_err(); + } + } + this.local_participant.projects.clear(); + if let Some(livekit_room) = &mut this.live_kit { + livekit_room.stop_publishing(cx); + } + } + + this.joined_projects.retain(|project| { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| project.set_role(role, cx)); + true + } else { + false + } + }); + } + } else { + this.local_participant.projects.clear(); + } + + let livekit_participants = this + .live_kit + .as_ref() + .map(|live_kit| live_kit.room.remote_participants()); + + if let Some(participants) = remote_participants.log_err() { + for (participant, user) in room.participants.into_iter().zip(participants) { + let Some(peer_id) = participant.peer_id else { + continue; + }; + let participant_index = ParticipantIndex(participant.participant_index); + this.participant_user_ids.insert(participant.user_id); + + let old_projects = this + .remote_participants + .get(&participant.user_id) + .into_iter() + .flat_map(|existing| &existing.projects) + .map(|project| project.id) + .collect::>(); + let new_projects = participant + .projects + .iter() + .map(|project| project.id) + .collect::>(); + + for project in &participant.projects { + if !old_projects.contains(&project.id) { + cx.emit(Event::RemoteProjectShared { + owner: user.clone(), + project_id: project.id, + worktree_root_names: project.worktree_root_names.clone(), + }); + } + } + + for unshared_project_id in old_projects.difference(&new_projects) { + this.joined_projects.retain(|project| { + if let Some(project) = project.upgrade() { + project.update(cx, |project, cx| { + if project.remote_id() == Some(*unshared_project_id) { + project.disconnected_from_host(cx); + false + } else { + true + } + }) + } else { + false + } + }); + cx.emit(Event::RemoteProjectUnshared { + project_id: *unshared_project_id, + }); + } + + let role = participant.role(); + let location = ParticipantLocation::from_proto(participant.location) + .unwrap_or(ParticipantLocation::External); + if let Some(remote_participant) = + this.remote_participants.get_mut(&participant.user_id) + { + remote_participant.peer_id = peer_id; + remote_participant.projects = participant.projects; + remote_participant.participant_index = participant_index; + if location != remote_participant.location + || role != remote_participant.role + { + remote_participant.location = location; + remote_participant.role = role; + cx.emit(Event::ParticipantLocationChanged { + participant_id: peer_id, + }); + } + } else { + this.remote_participants.insert( + participant.user_id, + RemoteParticipant { + user: user.clone(), + participant_index, + peer_id, + projects: participant.projects, + location, + role, + muted: true, + speaking: false, + video_tracks: Default::default(), + #[cfg(not(target_os = "windows"))] + audio_tracks: Default::default(), + }, + ); + + Audio::play_sound(Sound::Joined, cx); + if let Some(livekit_participants) = &livekit_participants { + if let Some(livekit_participant) = livekit_participants + .get(&ParticipantIdentity(user.id.to_string())) + { + for publication in + livekit_participant.track_publications().into_values() + { + if let Some(track) = publication.track() { + this.livekit_room_updated( + RoomEvent::TrackSubscribed { + track, + publication, + participant: livekit_participant.clone(), + }, + cx, + ) + .warn_on_err(); + } + } + } + } + } + } + + this.remote_participants.retain(|user_id, participant| { + if this.participant_user_ids.contains(user_id) { + true + } else { + for project in &participant.projects { + cx.emit(Event::RemoteProjectUnshared { + project_id: project.id, + }); + } + false + } + }); + } + + if let Some(pending_participants) = pending_participants.log_err() { + this.pending_participants = pending_participants; + for participant in &this.pending_participants { + this.participant_user_ids.insert(participant.id); + } + } + + this.follows_by_leader_id_project_id.clear(); + for follower in room.followers { + let project_id = follower.project_id; + let (leader, follower) = match (follower.leader_id, follower.follower_id) { + (Some(leader), Some(follower)) => (leader, follower), + + _ => { + log::error!("Follower message {follower:?} missing some state"); + continue; + } + }; + + let list = this + .follows_by_leader_id_project_id + .entry((leader, project_id)) + .or_default(); + if !list.contains(&follower) { + list.push(follower); + } + } + + this.pending_room_update.take(); + if this.should_leave() { + log::info!("room is empty, leaving"); + this.leave(cx).detach(); + } + + this.user_store.update(cx, |user_store, cx| { + let participant_indices_by_user_id = this + .remote_participants + .iter() + .map(|(user_id, participant)| (*user_id, participant.participant_index)) + .collect(); + user_store.set_participant_indices(participant_indices_by_user_id, cx); + }); + + this.check_invariants(); + this.room_update_completed_tx.try_send(Some(())).ok(); + cx.notify(); + }) + .ok(); + }) + } + + fn livekit_room_updated( + &mut self, + event: RoomEvent, + cx: &mut ModelContext, + ) -> Result<()> { + log::trace!( + "client {:?}. livekit event: {:?}", + self.client.user_id(), + &event + ); + + match event { + #[cfg(not(target_os = "windows"))] + RoomEvent::TrackSubscribed { + track, + participant, + publication, + } => { + let user_id = participant.identity().0.parse()?; + let track_id = track.sid(); + let participant = self.remote_participants.get_mut(&user_id).ok_or_else(|| { + anyhow!( + "{:?} subscribed to track by unknown participant {user_id}", + self.client.user_id() + ) + })?; + if self.live_kit.as_ref().map_or(true, |kit| kit.deafened) { + track.rtc_track().set_enabled(false); + } + match track { + livekit::track::RemoteTrack::Audio(track) => { + cx.emit(Event::RemoteAudioTracksChanged { + participant_id: participant.peer_id, + }); + let stream = play_remote_audio_track(&track, cx.background_executor())?; + participant.audio_tracks.insert(track_id, (track, stream)); + participant.muted = publication.is_muted(); + } + livekit::track::RemoteTrack::Video(track) => { + cx.emit(Event::RemoteVideoTracksChanged { + participant_id: participant.peer_id, + }); + participant.video_tracks.insert(track_id, track); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::TrackUnsubscribed { + track, participant, .. + } => { + let user_id = participant.identity().0.parse()?; + let participant = self.remote_participants.get_mut(&user_id).ok_or_else(|| { + anyhow!( + "{:?}, unsubscribed from track by unknown participant {user_id}", + self.client.user_id() + ) + })?; + match track { + livekit::track::RemoteTrack::Audio(track) => { + participant.audio_tracks.remove(&track.sid()); + participant.muted = true; + cx.emit(Event::RemoteAudioTracksChanged { + participant_id: participant.peer_id, + }); + } + livekit::track::RemoteTrack::Video(track) => { + participant.video_tracks.remove(&track.sid()); + cx.emit(Event::RemoteVideoTracksChanged { + participant_id: participant.peer_id, + }); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::ActiveSpeakersChanged { speakers } => { + let mut speaker_ids = speakers + .into_iter() + .filter_map(|speaker| speaker.identity().0.parse().ok()) + .collect::>(); + speaker_ids.sort_unstable(); + for (sid, participant) in &mut self.remote_participants { + participant.speaking = speaker_ids.binary_search(sid).is_ok(); + } + if let Some(id) = self.client.user_id() { + if let Some(room) = &mut self.live_kit { + room.speaking = speaker_ids.binary_search(&id).is_ok(); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::TrackMuted { + participant, + publication, + } + | RoomEvent::TrackUnmuted { + participant, + publication, + } => { + let mut found = false; + let user_id = participant.identity().0.parse()?; + let track_id = publication.sid(); + if let Some(participant) = self.remote_participants.get_mut(&user_id) { + for (track, _) in participant.audio_tracks.values() { + if track.sid() == track_id { + found = true; + break; + } + } + if found { + participant.muted = publication.is_muted(); + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::LocalTrackUnpublished { publication, .. } => { + log::info!("unpublished track {}", publication.sid()); + if let Some(room) = &mut self.live_kit { + if let LocalTrack::Published { + track_publication, .. + } = &room.microphone_track + { + if track_publication.sid() == publication.sid() { + room.microphone_track = LocalTrack::None; + } + } + if let LocalTrack::Published { + track_publication, .. + } = &room.screen_track + { + if track_publication.sid() == publication.sid() { + room.screen_track = LocalTrack::None; + } + } + } + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::LocalTrackPublished { publication, .. } => { + log::info!("published track {:?}", publication.sid()); + } + + #[cfg(not(target_os = "windows"))] + RoomEvent::Disconnected { reason } => { + log::info!("disconnected from room: {reason:?}"); + self.leave(cx).detach_and_log_err(cx); + } + _ => {} + } + + cx.notify(); + Ok(()) + } + + fn check_invariants(&self) { + #[cfg(any(test, feature = "test-support"))] + { + for participant in self.remote_participants.values() { + assert!(self.participant_user_ids.contains(&participant.user.id)); + assert_ne!(participant.user.id, self.client.user_id().unwrap()); + } + + for participant in &self.pending_participants { + assert!(self.participant_user_ids.contains(&participant.id)); + assert_ne!(participant.id, self.client.user_id().unwrap()); + } + + assert_eq!( + self.participant_user_ids.len(), + self.remote_participants.len() + self.pending_participants.len() + ); + } + } + + pub(crate) fn call( + &mut self, + called_user_id: u64, + initial_project_id: Option, + cx: &mut ModelContext, + ) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + cx.notify(); + let client = self.client.clone(); + let room_id = self.id; + self.pending_call_count += 1; + cx.spawn(move |this, mut cx| async move { + let result = client + .request(proto::Call { + room_id, + called_user_id, + initial_project_id, + }) + .await; + this.update(&mut cx, |this, cx| { + this.pending_call_count -= 1; + if this.should_leave() { + this.leave(cx).detach_and_log_err(cx); + } + })?; + result?; + Ok(()) + }) + } + + pub fn join_project( + &mut self, + id: u64, + language_registry: Arc, + fs: Arc, + cx: &mut ModelContext, + ) -> Task>> { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + cx.emit(Event::RemoteProjectJoined { project_id: id }); + cx.spawn(move |this, mut cx| async move { + let project = + Project::in_room(id, client, user_store, language_registry, fs, cx.clone()).await?; + + this.update(&mut cx, |this, cx| { + this.joined_projects.retain(|project| { + if let Some(project) = project.upgrade() { + !project.read(cx).is_disconnected(cx) + } else { + false + } + }); + this.joined_projects.insert(project.downgrade()); + })?; + Ok(project) + }) + } + + pub fn share_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if let Some(project_id) = project.read(cx).remote_id() { + return Task::ready(Ok(project_id)); + } + + let request = self.client.request(proto::ShareProject { + room_id: self.id(), + worktrees: project.read(cx).worktree_metadata_protos(cx), + is_ssh_project: project.read(cx).is_via_ssh(), + }); + + cx.spawn(|this, mut cx| async move { + let response = request.await?; + + project.update(&mut cx, |project, cx| { + project.shared(response.project_id, cx) + })??; + + // If the user's location is in this project, it changes from UnsharedProject to SharedProject. + this.update(&mut cx, |this, cx| { + this.shared_projects.insert(project.downgrade()); + let active_project = this.local_participant.active_project.as_ref(); + if active_project.map_or(false, |location| *location == project) { + this.set_location(Some(&project), cx) + } else { + Task::ready(Ok(())) + } + })? + .await?; + + Ok(response.project_id) + }) + } + + pub(crate) fn unshare_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Result<()> { + let project_id = match project.read(cx).remote_id() { + Some(project_id) => project_id, + None => return Ok(()), + }; + + self.client.send(proto::UnshareProject { project_id })?; + project.update(cx, |this, cx| this.unshare(cx))?; + + if self.local_participant.active_project == Some(project.downgrade()) { + self.set_location(Some(&project), cx).detach_and_log_err(cx); + } + Ok(()) + } + + pub(crate) fn set_location( + &mut self, + project: Option<&Model>, + cx: &mut ModelContext, + ) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + let client = self.client.clone(); + let room_id = self.id; + let location = if let Some(project) = project { + self.local_participant.active_project = Some(project.downgrade()); + if let Some(project_id) = project.read(cx).remote_id() { + proto::participant_location::Variant::SharedProject( + proto::participant_location::SharedProject { id: project_id }, + ) + } else { + proto::participant_location::Variant::UnsharedProject( + proto::participant_location::UnsharedProject {}, + ) + } + } else { + self.local_participant.active_project = None; + proto::participant_location::Variant::External(proto::participant_location::External {}) + }; + + cx.notify(); + cx.background_executor().spawn(async move { + client + .request(proto::UpdateParticipantLocation { + room_id, + location: Some(proto::ParticipantLocation { + variant: Some(location), + }), + }) + .await?; + Ok(()) + }) + } + + pub fn is_screen_sharing(&self) -> bool { + self.live_kit.as_ref().map_or(false, |live_kit| { + !matches!(live_kit.screen_track, LocalTrack::None) + }) + } + + pub fn is_sharing_mic(&self) -> bool { + self.live_kit.as_ref().map_or(false, |live_kit| { + !matches!(live_kit.microphone_track, LocalTrack::None) + }) + } + + pub fn is_muted(&self) -> bool { + self.live_kit.as_ref().map_or(false, |live_kit| { + matches!(live_kit.microphone_track, LocalTrack::None) + || live_kit.muted_by_user + || live_kit.deafened + }) + } + + pub fn is_speaking(&self) -> bool { + self.live_kit + .as_ref() + .map_or(false, |live_kit| live_kit.speaking) + } + + pub fn is_deafened(&self) -> Option { + self.live_kit.as_ref().map(|live_kit| live_kit.deafened) + } + + pub fn can_use_microphone(&self, _cx: &AppContext) -> bool { + use proto::ChannelRole::*; + + #[cfg(not(any(test, feature = "test-support")))] + { + use feature_flags::FeatureFlagAppExt as _; + if cfg!(target_os = "windows") || (cfg!(target_os = "linux") && !_cx.is_staff()) { + return false; + } + } + + match self.local_participant.role { + Admin | Member | Talker => true, + Guest | Banned => false, + } + } + + pub fn can_share_projects(&self) -> bool { + use proto::ChannelRole::*; + match self.local_participant.role { + Admin | Member => true, + Guest | Banned | Talker => false, + } + } + + #[cfg(target_os = "windows")] + pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { + Task::ready(Err(anyhow!("Windows is not supported yet"))) + } + + #[cfg(not(target_os = "windows"))] + #[track_caller] + pub fn share_microphone(&mut self, cx: &mut ModelContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + + let (participant, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() { + let publish_id = post_inc(&mut live_kit.next_publish_id); + live_kit.microphone_track = LocalTrack::Pending { publish_id }; + cx.notify(); + (live_kit.room.local_participant(), publish_id) + } else { + return Task::ready(Err(anyhow!("live-kit was not initialized"))); + }; + + cx.spawn(move |this, mut cx| async move { + let (track, stream) = capture_local_audio_track(cx.background_executor())?.await; + + let publication = participant + .publish_track( + livekit::track::LocalTrack::Audio(track), + TrackPublishOptions { + source: TrackSource::Microphone, + ..Default::default() + }, + ) + .await + .map_err(|error| anyhow!("failed to publish track: {error}")); + this.update(&mut cx, |this, cx| { + let live_kit = this + .live_kit + .as_mut() + .ok_or_else(|| anyhow!("live-kit was not initialized"))?; + + let canceled = if let LocalTrack::Pending { + publish_id: cur_publish_id, + } = &live_kit.microphone_track + { + *cur_publish_id != publish_id + } else { + true + }; + + match publication { + Ok(publication) => { + if canceled { + cx.background_executor() + .spawn(async move { + participant.unpublish_track(&publication.sid()).await + }) + .detach_and_log_err(cx) + } else { + if live_kit.muted_by_user || live_kit.deafened { + publication.mute(); + } + live_kit.microphone_track = LocalTrack::Published { + track_publication: publication, + _stream: Box::new(stream), + }; + cx.notify(); + } + Ok(()) + } + Err(error) => { + if canceled { + Ok(()) + } else { + live_kit.microphone_track = LocalTrack::None; + cx.notify(); + Err(error) + } + } + } + })? + }) + } + + #[cfg(target_os = "windows")] + pub fn share_screen(&mut self, cx: &mut ModelContext) -> Task> { + Task::ready(Err(anyhow!("Windows is not supported yet"))) + } + + #[cfg(not(target_os = "windows"))] + pub fn share_screen(&mut self, cx: &mut ModelContext) -> Task> { + if self.status.is_offline() { + return Task::ready(Err(anyhow!("room is offline"))); + } + if self.is_screen_sharing() { + return Task::ready(Err(anyhow!("screen was already shared"))); + } + + let (participant, publish_id) = if let Some(live_kit) = self.live_kit.as_mut() { + let publish_id = post_inc(&mut live_kit.next_publish_id); + live_kit.screen_track = LocalTrack::Pending { publish_id }; + cx.notify(); + (live_kit.room.local_participant(), publish_id) + } else { + return Task::ready(Err(anyhow!("live-kit was not initialized"))); + }; + + let sources = cx.screen_capture_sources(); + + cx.spawn(move |this, mut cx| async move { + let sources = sources.await??; + let source = sources.first().ok_or_else(|| anyhow!("no display found"))?; + + let (track, stream) = capture_local_video_track(&**source).await?; + + let publication = participant + .publish_track( + livekit::track::LocalTrack::Video(track), + TrackPublishOptions { + source: TrackSource::Screenshare, + video_codec: VideoCodec::H264, + ..Default::default() + }, + ) + .await + .map_err(|error| anyhow!("error publishing screen track {error:?}")); + + this.update(&mut cx, |this, cx| { + let live_kit = this + .live_kit + .as_mut() + .ok_or_else(|| anyhow!("live-kit was not initialized"))?; + + let canceled = if let LocalTrack::Pending { + publish_id: cur_publish_id, + } = &live_kit.screen_track + { + *cur_publish_id != publish_id + } else { + true + }; + + match publication { + Ok(publication) => { + if canceled { + cx.background_executor() + .spawn(async move { + participant.unpublish_track(&publication.sid()).await + }) + .detach() + } else { + live_kit.screen_track = LocalTrack::Published { + track_publication: publication, + _stream: Box::new(stream), + }; + cx.notify(); + } + + Audio::play_sound(Sound::StartScreenshare, cx); + Ok(()) + } + Err(error) => { + if canceled { + Ok(()) + } else { + live_kit.screen_track = LocalTrack::None; + cx.notify(); + Err(error) + } + } + } + })? + }) + } + + pub fn toggle_mute(&mut self, cx: &mut ModelContext) { + if let Some(live_kit) = self.live_kit.as_mut() { + // When unmuting, undeafen if the user was deafened before. + let was_deafened = live_kit.deafened; + if live_kit.muted_by_user + || live_kit.deafened + || matches!(live_kit.microphone_track, LocalTrack::None) + { + live_kit.muted_by_user = false; + live_kit.deafened = false; + } else { + live_kit.muted_by_user = true; + } + let muted = live_kit.muted_by_user; + let should_undeafen = was_deafened && !live_kit.deafened; + + if let Some(task) = self.set_mute(muted, cx) { + task.detach_and_log_err(cx); + } + + if should_undeafen { + self.set_deafened(false, cx); + } + } + } + + pub fn toggle_deafen(&mut self, cx: &mut ModelContext) { + if let Some(live_kit) = self.live_kit.as_mut() { + // When deafening, mute the microphone if it was not already muted. + // When un-deafening, unmute the microphone, unless it was explicitly muted. + let deafened = !live_kit.deafened; + live_kit.deafened = deafened; + let should_change_mute = !live_kit.muted_by_user; + + self.set_deafened(deafened, cx); + + if should_change_mute { + if let Some(task) = self.set_mute(deafened, cx) { + task.detach_and_log_err(cx); + } + } + } + } + + pub fn unshare_screen(&mut self, cx: &mut ModelContext) -> Result<()> { + if self.status.is_offline() { + return Err(anyhow!("room is offline")); + } + + let live_kit = self + .live_kit + .as_mut() + .ok_or_else(|| anyhow!("live-kit was not initialized"))?; + match mem::take(&mut live_kit.screen_track) { + LocalTrack::None => Err(anyhow!("screen was not shared")), + LocalTrack::Pending { .. } => { + cx.notify(); + Ok(()) + } + LocalTrack::Published { + track_publication, .. + } => { + #[cfg(not(target_os = "windows"))] + { + let local_participant = live_kit.room.local_participant(); + let sid = track_publication.sid(); + cx.background_executor() + .spawn(async move { local_participant.unpublish_track(&sid).await }) + .detach_and_log_err(cx); + cx.notify(); + } + Audio::play_sound(Sound::StopScreenshare, cx); + Ok(()) + } + } + } + + fn set_deafened(&mut self, deafened: bool, cx: &mut ModelContext) -> Option<()> { + #[cfg(not(target_os = "windows"))] + { + let live_kit = self.live_kit.as_mut()?; + cx.notify(); + for (_, participant) in live_kit.room.remote_participants() { + for (_, publication) in participant.track_publications() { + if publication.kind() == TrackKind::Audio { + publication.set_enabled(!deafened); + } + } + } + } + + None + } + + fn set_mute( + &mut self, + should_mute: bool, + cx: &mut ModelContext, + ) -> Option>> { + let live_kit = self.live_kit.as_mut()?; + cx.notify(); + + if should_mute { + Audio::play_sound(Sound::Mute, cx); + } else { + Audio::play_sound(Sound::Unmute, cx); + } + + match &mut live_kit.microphone_track { + LocalTrack::None => { + if should_mute { + None + } else { + Some(self.share_microphone(cx)) + } + } + LocalTrack::Pending { .. } => None, + LocalTrack::Published { + track_publication, .. + } => { + #[cfg(not(target_os = "windows"))] + { + if should_mute { + track_publication.mute() + } else { + track_publication.unmute() + } + } + None + } + } + } +} + +#[cfg(target_os = "windows")] +fn spawn_room_connection( + livekit_connection_info: Option, + cx: &mut ModelContext<'_, Room>, +) { +} + +#[cfg(not(target_os = "windows"))] +fn spawn_room_connection( + livekit_connection_info: Option, + cx: &mut ModelContext<'_, Room>, +) { + if let Some(connection_info) = livekit_connection_info { + cx.spawn(|this, mut cx| async move { + let (room, mut events) = livekit::Room::connect( + &connection_info.server_url, + &connection_info.token, + RoomOptions::default(), + ) + .await?; + + this.update(&mut cx, |this, cx| { + let _handle_updates = cx.spawn(|this, mut cx| async move { + while let Some(event) = events.recv().await { + if this + .update(&mut cx, |this, cx| { + this.livekit_room_updated(event, cx).warn_on_err(); + }) + .is_err() + { + break; + } + } + }); + + let muted_by_user = Room::mute_on_join(cx); + this.live_kit = Some(LiveKitRoom { + room: Arc::new(room), + screen_track: LocalTrack::None, + microphone_track: LocalTrack::None, + next_publish_id: 0, + muted_by_user, + deafened: false, + speaking: false, + _handle_updates, + }); + + if !muted_by_user && this.can_use_microphone(cx) { + this.share_microphone(cx) + } else { + Task::ready(Ok(())) + } + })? + .await + }) + .detach_and_log_err(cx); + } +} + +struct LiveKitRoom { + room: Arc, + screen_track: LocalTrack, + microphone_track: LocalTrack, + /// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user. + muted_by_user: bool, + deafened: bool, + speaking: bool, + next_publish_id: usize, + _handle_updates: Task<()>, +} + +impl LiveKitRoom { + #[cfg(target_os = "windows")] + fn stop_publishing(&mut self, _cx: &mut ModelContext) {} + + #[cfg(not(target_os = "windows"))] + fn stop_publishing(&mut self, cx: &mut ModelContext) { + let mut tracks_to_unpublish = Vec::new(); + if let LocalTrack::Published { + track_publication, .. + } = mem::replace(&mut self.microphone_track, LocalTrack::None) + { + tracks_to_unpublish.push(track_publication.sid()); + cx.notify(); + } + + if let LocalTrack::Published { + track_publication, .. + } = mem::replace(&mut self.screen_track, LocalTrack::None) + { + tracks_to_unpublish.push(track_publication.sid()); + cx.notify(); + } + + let participant = self.room.local_participant(); + cx.background_executor() + .spawn(async move { + for sid in tracks_to_unpublish { + participant.unpublish_track(&sid).await.log_err(); + } + }) + .detach(); + } +} + +enum LocalTrack { + None, + Pending { + publish_id: usize, + }, + Published { + track_publication: LocalTrackPublication, + _stream: Box, + }, +} + +impl Default for LocalTrack { + fn default() -> Self { + Self::None + } +} + +#[derive(Copy, Clone, PartialEq, Eq)] +pub enum RoomStatus { + Online, + Rejoining, + Offline, +} + +impl RoomStatus { + pub fn is_offline(&self) -> bool { + matches!(self, RoomStatus::Offline) + } + + pub fn is_online(&self) -> bool { + matches!(self, RoomStatus::Online) + } +} diff --git a/crates/call/src/macos/mod.rs b/crates/call/src/macos/mod.rs new file mode 100644 index 0000000000..24472bd1fb --- /dev/null +++ b/crates/call/src/macos/mod.rs @@ -0,0 +1,545 @@ +pub mod participant; +pub mod room; + +use crate::call_settings::CallSettings; +use anyhow::{anyhow, Result}; +use audio::Audio; +use client::{proto, ChannelId, Client, TypedEnvelope, User, UserStore, ZED_ALWAYS_ACTIVE}; +use collections::HashSet; +use futures::{channel::oneshot, future::Shared, Future, FutureExt}; +use gpui::{ + AppContext, AsyncAppContext, Context, EventEmitter, Global, Model, ModelContext, Subscription, + Task, WeakModel, +}; +use postage::watch; +use project::Project; +use room::Event; +use settings::Settings; +use std::sync::Arc; + +pub use participant::ParticipantLocation; +pub use room::Room; + +struct GlobalActiveCall(Model); + +impl Global for GlobalActiveCall {} + +pub fn init(client: Arc, user_store: Model, cx: &mut AppContext) { + CallSettings::register(cx); + + let active_call = cx.new_model(|cx| ActiveCall::new(client, user_store, cx)); + cx.set_global(GlobalActiveCall(active_call)); +} + +pub struct OneAtATime { + cancel: Option>, +} + +impl OneAtATime { + /// spawn a task in the given context. + /// if another task is spawned before that resolves, or if the OneAtATime itself is dropped, the first task will be cancelled and return Ok(None) + /// otherwise you'll see the result of the task. + fn spawn(&mut self, cx: &mut AppContext, f: F) -> Task>> + where + F: 'static + FnOnce(AsyncAppContext) -> Fut, + Fut: Future>, + R: 'static, + { + let (tx, rx) = oneshot::channel(); + self.cancel.replace(tx); + cx.spawn(|cx| async move { + futures::select_biased! { + _ = rx.fuse() => Ok(None), + result = f(cx).fuse() => result.map(Some), + } + }) + } + + fn running(&self) -> bool { + self.cancel + .as_ref() + .is_some_and(|cancel| !cancel.is_canceled()) + } +} + +#[derive(Clone)] +pub struct IncomingCall { + pub room_id: u64, + pub calling_user: Arc, + pub participants: Vec>, + pub initial_project: Option, +} + +/// Singleton global maintaining the user's participation in a room across workspaces. +pub struct ActiveCall { + room: Option<(Model, Vec)>, + pending_room_creation: Option, Arc>>>>, + location: Option>, + _join_debouncer: OneAtATime, + pending_invites: HashSet, + incoming_call: ( + watch::Sender>, + watch::Receiver>, + ), + client: Arc, + user_store: Model, + _subscriptions: Vec, +} + +impl EventEmitter for ActiveCall {} + +impl ActiveCall { + fn new(client: Arc, user_store: Model, cx: &mut ModelContext) -> Self { + Self { + room: None, + pending_room_creation: None, + location: None, + pending_invites: Default::default(), + incoming_call: watch::channel(), + _join_debouncer: OneAtATime { cancel: None }, + _subscriptions: vec![ + client.add_request_handler(cx.weak_model(), Self::handle_incoming_call), + client.add_message_handler(cx.weak_model(), Self::handle_call_canceled), + ], + client, + user_store, + } + } + + pub fn channel_id(&self, cx: &AppContext) -> Option { + self.room()?.read(cx).channel_id() + } + + async fn handle_incoming_call( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result { + let user_store = this.update(&mut cx, |this, _| this.user_store.clone())?; + let call = IncomingCall { + room_id: envelope.payload.room_id, + participants: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_users(envelope.payload.participant_user_ids, cx) + })? + .await?, + calling_user: user_store + .update(&mut cx, |user_store, cx| { + user_store.get_user(envelope.payload.calling_user_id, cx) + })? + .await?, + initial_project: envelope.payload.initial_project, + }; + this.update(&mut cx, |this, _| { + *this.incoming_call.0.borrow_mut() = Some(call); + })?; + + Ok(proto::Ack {}) + } + + async fn handle_call_canceled( + this: Model, + envelope: TypedEnvelope, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, _| { + let mut incoming_call = this.incoming_call.0.borrow_mut(); + if incoming_call + .as_ref() + .map_or(false, |call| call.room_id == envelope.payload.room_id) + { + incoming_call.take(); + } + })?; + Ok(()) + } + + pub fn global(cx: &AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn try_global(cx: &AppContext) -> Option> { + cx.try_global::() + .map(|call| call.0.clone()) + } + + pub fn invite( + &mut self, + called_user_id: u64, + initial_project: Option>, + cx: &mut ModelContext, + ) -> Task> { + if !self.pending_invites.insert(called_user_id) { + return Task::ready(Err(anyhow!("user was already invited"))); + } + cx.notify(); + + if self._join_debouncer.running() { + return Task::ready(Ok(())); + } + + let room = if let Some(room) = self.room().cloned() { + Some(Task::ready(Ok(room)).shared()) + } else { + self.pending_room_creation.clone() + }; + + let invite = if let Some(room) = room { + cx.spawn(move |_, mut cx| async move { + let room = room.await.map_err(|err| anyhow!("{:?}", err))?; + + let initial_project_id = if let Some(initial_project) = initial_project { + Some( + room.update(&mut cx, |room, cx| room.share_project(initial_project, cx))? + .await?, + ) + } else { + None + }; + + room.update(&mut cx, move |room, cx| { + room.call(called_user_id, initial_project_id, cx) + })? + .await?; + + anyhow::Ok(()) + }) + } else { + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let room = cx + .spawn(move |this, mut cx| async move { + let create_room = async { + let room = cx + .update(|cx| { + Room::create( + called_user_id, + initial_project, + client, + user_store, + cx, + ) + })? + .await?; + + this.update(&mut cx, |this, cx| this.set_room(Some(room.clone()), cx))? + .await?; + + anyhow::Ok(room) + }; + + let room = create_room.await; + this.update(&mut cx, |this, _| this.pending_room_creation = None)?; + room.map_err(Arc::new) + }) + .shared(); + self.pending_room_creation = Some(room.clone()); + cx.background_executor().spawn(async move { + room.await.map_err(|err| anyhow!("{:?}", err))?; + anyhow::Ok(()) + }) + }; + + cx.spawn(move |this, mut cx| async move { + let result = invite.await; + if result.is_ok() { + this.update(&mut cx, |this, cx| this.report_call_event("invite", cx))?; + } else { + //TODO: report collaboration error + log::error!("invite failed: {:?}", result); + } + + this.update(&mut cx, |this, cx| { + this.pending_invites.remove(&called_user_id); + cx.notify(); + })?; + result + }) + } + + pub fn cancel_invite( + &mut self, + called_user_id: u64, + cx: &mut ModelContext, + ) -> Task> { + let room_id = if let Some(room) = self.room() { + room.read(cx).id() + } else { + return Task::ready(Err(anyhow!("no active call"))); + }; + + let client = self.client.clone(); + cx.background_executor().spawn(async move { + client + .request(proto::CancelCall { + room_id, + called_user_id, + }) + .await?; + anyhow::Ok(()) + }) + } + + pub fn incoming(&self) -> watch::Receiver> { + self.incoming_call.1.clone() + } + + pub fn accept_incoming(&mut self, cx: &mut ModelContext) -> Task> { + if self.room.is_some() { + return Task::ready(Err(anyhow!("cannot join while on another call"))); + } + + let call = if let Some(call) = self.incoming_call.0.borrow_mut().take() { + call + } else { + return Task::ready(Err(anyhow!("no incoming call"))); + }; + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(())); + } + + let room_id = call.room_id; + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self + ._join_debouncer + .spawn(cx, move |cx| Room::join(room_id, client, user_store, cx)); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("accept incoming", cx) + })?; + Ok(()) + }) + } + + pub fn decline_incoming(&mut self, _: &mut ModelContext) -> Result<()> { + let call = self + .incoming_call + .0 + .borrow_mut() + .take() + .ok_or_else(|| anyhow!("no incoming call"))?; + report_call_event_for_room("decline incoming", call.room_id, None, &self.client); + self.client.send(proto::DeclineCall { + room_id: call.room_id, + })?; + Ok(()) + } + + pub fn join_channel( + &mut self, + channel_id: ChannelId, + cx: &mut ModelContext, + ) -> Task>>> { + if let Some(room) = self.room().cloned() { + if room.read(cx).channel_id() == Some(channel_id) { + return Task::ready(Ok(Some(room))); + } else { + room.update(cx, |room, cx| room.clear_state(cx)); + } + } + + if self.pending_room_creation.is_some() { + return Task::ready(Ok(None)); + } + + let client = self.client.clone(); + let user_store = self.user_store.clone(); + let join = self._join_debouncer.spawn(cx, move |cx| async move { + Room::join_channel(channel_id, client, user_store, cx).await + }); + + cx.spawn(|this, mut cx| async move { + let room = join.await?; + this.update(&mut cx, |this, cx| this.set_room(room.clone(), cx))? + .await?; + this.update(&mut cx, |this, cx| { + this.report_call_event("join channel", cx) + })?; + Ok(room) + }) + } + + pub fn hang_up(&mut self, cx: &mut ModelContext) -> Task> { + cx.notify(); + self.report_call_event("hang up", cx); + + Audio::end_call(cx); + + let channel_id = self.channel_id(cx); + if let Some((room, _)) = self.room.take() { + cx.emit(Event::RoomLeft { channel_id }); + room.update(cx, |room, cx| room.leave(cx)) + } else { + Task::ready(Ok(())) + } + } + + pub fn share_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Task> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("share project", cx); + room.update(cx, |room, cx| room.share_project(project, cx)) + } else { + Task::ready(Err(anyhow!("no active call"))) + } + } + + pub fn unshare_project( + &mut self, + project: Model, + cx: &mut ModelContext, + ) -> Result<()> { + if let Some((room, _)) = self.room.as_ref() { + self.report_call_event("unshare project", cx); + room.update(cx, |room, cx| room.unshare_project(project, cx)) + } else { + Err(anyhow!("no active call")) + } + } + + pub fn location(&self) -> Option<&WeakModel> { + self.location.as_ref() + } + + pub fn set_location( + &mut self, + project: Option<&Model>, + cx: &mut ModelContext, + ) -> Task> { + if project.is_some() || !*ZED_ALWAYS_ACTIVE { + self.location = project.map(|project| project.downgrade()); + if let Some((room, _)) = self.room.as_ref() { + return room.update(cx, |room, cx| room.set_location(project, cx)); + } + } + Task::ready(Ok(())) + } + + fn set_room( + &mut self, + room: Option>, + cx: &mut ModelContext, + ) -> Task> { + if room.as_ref() == self.room.as_ref().map(|room| &room.0) { + Task::ready(Ok(())) + } else { + cx.notify(); + if let Some(room) = room { + if room.read(cx).status().is_offline() { + self.room = None; + Task::ready(Ok(())) + } else { + let subscriptions = vec![ + cx.observe(&room, |this, room, cx| { + if room.read(cx).status().is_offline() { + this.set_room(None, cx).detach_and_log_err(cx); + } + + cx.notify(); + }), + cx.subscribe(&room, |_, _, event, cx| cx.emit(event.clone())), + ]; + self.room = Some((room.clone(), subscriptions)); + let location = self + .location + .as_ref() + .and_then(|location| location.upgrade()); + let channel_id = room.read(cx).channel_id(); + cx.emit(Event::RoomJoined { channel_id }); + room.update(cx, |room, cx| room.set_location(location.as_ref(), cx)) + } + } else { + self.room = None; + Task::ready(Ok(())) + } + } + } + + pub fn room(&self) -> Option<&Model> { + self.room.as_ref().map(|(room, _)| room) + } + + pub fn client(&self) -> Arc { + self.client.clone() + } + + pub fn pending_invites(&self) -> &HashSet { + &self.pending_invites + } + + pub fn report_call_event(&self, operation: &'static str, cx: &mut AppContext) { + if let Some(room) = self.room() { + let room = room.read(cx); + report_call_event_for_room(operation, room.id(), room.channel_id(), &self.client); + } + } +} + +pub fn report_call_event_for_room( + operation: &'static str, + room_id: u64, + channel_id: Option, + client: &Arc, +) { + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, Some(room_id), channel_id) +} + +pub fn report_call_event_for_channel( + operation: &'static str, + channel_id: ChannelId, + client: &Arc, + cx: &AppContext, +) { + let room = ActiveCall::global(cx).read(cx).room(); + + let telemetry = client.telemetry(); + + telemetry.report_call_event(operation, room.map(|r| r.read(cx).id()), Some(channel_id)) +} + +#[cfg(test)] +mod test { + use gpui::TestAppContext; + + use crate::OneAtATime; + + #[gpui::test] + async fn test_one_at_a_time(cx: &mut TestAppContext) { + let mut one_at_a_time = OneAtATime { cancel: None }; + + assert_eq!( + cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(1) })) + .await + .unwrap(), + Some(1) + ); + + let (a, b) = cx.update(|cx| { + ( + one_at_a_time.spawn(cx, |_| async { + panic!(""); + }), + one_at_a_time.spawn(cx, |_| async { Ok(3) }), + ) + }); + + assert_eq!(a.await.unwrap(), None::); + assert_eq!(b.await.unwrap(), Some(3)); + + let promise = cx.update(|cx| one_at_a_time.spawn(cx, |_| async { Ok(4) })); + drop(one_at_a_time); + + assert_eq!(promise.await.unwrap(), None); + } +} diff --git a/crates/call/src/participant.rs b/crates/call/src/macos/participant.rs similarity index 80% rename from crates/call/src/participant.rs rename to crates/call/src/macos/participant.rs index 9faefc63c3..82d946a928 100644 --- a/crates/call/src/participant.rs +++ b/crates/call/src/macos/participant.rs @@ -3,8 +3,8 @@ use client::ParticipantIndex; use client::{proto, User}; use collections::HashMap; use gpui::WeakModel; -pub use live_kit_client::Frame; -pub use live_kit_client::{RemoteAudioTrack, RemoteVideoTrack}; +pub use livekit_client_macos::Frame; +pub use livekit_client_macos::{RemoteAudioTrack, RemoteVideoTrack}; use project::Project; use std::sync::Arc; @@ -49,6 +49,12 @@ pub struct RemoteParticipant { pub participant_index: ParticipantIndex, pub muted: bool, pub speaking: bool, - pub video_tracks: HashMap>, - pub audio_tracks: HashMap>, + pub video_tracks: HashMap>, + pub audio_tracks: HashMap>, +} + +impl RemoteParticipant { + pub fn has_video_tracks(&self) -> bool { + !self.video_tracks.is_empty() + } } diff --git a/crates/call/src/room.rs b/crates/call/src/macos/room.rs similarity index 99% rename from crates/call/src/room.rs rename to crates/call/src/macos/room.rs index 3eb98f3109..6fd78570d8 100644 --- a/crates/call/src/room.rs +++ b/crates/call/src/macos/room.rs @@ -15,7 +15,7 @@ use gpui::{ AppContext, AsyncAppContext, Context, EventEmitter, Model, ModelContext, Task, WeakModel, }; use language::LanguageRegistry; -use live_kit_client::{LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RoomUpdate}; +use livekit_client_macos::{LocalAudioTrack, LocalTrackPublication, LocalVideoTrack, RoomUpdate}; use postage::{sink::Sink, stream::Stream, watch}; use project::Project; use settings::Settings as _; @@ -97,7 +97,7 @@ impl Room { if let Some(live_kit) = self.live_kit.as_ref() { matches!( *live_kit.room.status().borrow(), - live_kit_client::ConnectionState::Connected { .. } + livekit_client_macos::ConnectionState::Connected { .. } ) } else { false @@ -113,7 +113,7 @@ impl Room { cx: &mut ModelContext, ) -> Self { let live_kit_room = if let Some(connection_info) = live_kit_connection_info { - let room = live_kit_client::Room::new(); + let room = livekit_client_macos::Room::new(); let mut status = room.status(); // Consume the initial status of the room. let _ = status.try_recv(); @@ -125,7 +125,7 @@ impl Room { break; }; - if status == live_kit_client::ConnectionState::Disconnected { + if status == livekit_client_macos::ConnectionState::Disconnected { this.update(&mut cx, |this, cx| this.leave(cx).log_err()) .ok(); break; @@ -156,7 +156,7 @@ impl Room { cx.spawn(|this, mut cx| async move { connect.await?; this.update(&mut cx, |this, cx| { - if this.can_use_microphone() { + if this.can_use_microphone(cx) { if let Some(live_kit) = &this.live_kit { if !live_kit.muted_by_user && !live_kit.deafened { return this.share_microphone(cx); @@ -1317,7 +1317,7 @@ impl Room { self.live_kit.as_ref().map(|live_kit| live_kit.deafened) } - pub fn can_use_microphone(&self) -> bool { + pub fn can_use_microphone(&self, _cx: &AppContext) -> bool { use proto::ChannelRole::*; match self.local_participant.role { Admin | Member | Talker => true, @@ -1631,7 +1631,7 @@ impl Room { } #[cfg(any(test, feature = "test-support"))] - pub fn set_display_sources(&self, sources: Vec) { + pub fn set_display_sources(&self, sources: Vec) { self.live_kit .as_ref() .unwrap() @@ -1641,7 +1641,7 @@ impl Room { } struct LiveKitRoom { - room: Arc, + room: Arc, screen_track: LocalTrack, microphone_track: LocalTrack, /// Tracks whether we're currently in a muted state due to auto-mute from deafening or manual mute performed by user. diff --git a/crates/collab/.env.toml b/crates/collab/.env.toml index f542422e95..5d292387cb 100644 --- a/crates/collab/.env.toml +++ b/crates/collab/.env.toml @@ -5,9 +5,9 @@ HTTP_PORT = 8080 API_TOKEN = "secret" INVITE_LINK_PREFIX = "http://localhost:3000/invites/" ZED_ENVIRONMENT = "development" -LIVE_KIT_SERVER = "http://localhost:7880" -LIVE_KIT_KEY = "devkey" -LIVE_KIT_SECRET = "secret" +LIVEKIT_SERVER = "http://localhost:7880" +LIVEKIT_KEY = "devkey" +LIVEKIT_SECRET = "secret" BLOB_STORE_ACCESS_KEY = "the-blob-store-access-key" BLOB_STORE_SECRET_KEY = "the-blob-store-secret-key" BLOB_STORE_BUCKET = "the-extensions-bucket" diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index e56507c007..9c7f09bcf5 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -40,7 +40,7 @@ google_ai.workspace = true hex.workspace = true http_client.workspace = true jsonwebtoken.workspace = true -live_kit_server.workspace = true +livekit_server.workspace = true log.workspace = true nanoid.workspace = true open_ai.workspace = true @@ -77,6 +77,12 @@ tracing-subscriber = { version = "0.3.18", features = ["env-filter", "json", "re util.workspace = true uuid.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +livekit_client_macos = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +livekit_client = { workspace = true, features = ["test-support"] } + [dev-dependencies] assistant = { workspace = true, features = ["test-support"] } assistant_tool.workspace = true @@ -101,7 +107,6 @@ hyper.workspace = true indoc.workspace = true language = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } -live_kit_client = { workspace = true, features = ["test-support"] } lsp = { workspace = true, features = ["test-support"] } menu.workspace = true multi_buffer = { workspace = true, features = ["test-support"] } @@ -125,5 +130,11 @@ util.workspace = true workspace = { workspace = true, features = ["test-support"] } worktree = { workspace = true, features = ["test-support"] } +[target.'cfg(target_os = "macos")'.dev-dependencies] +livekit_client_macos = { workspace = true, features = ["test-support"] } + +[target.'cfg(not(target_os = "macos"))'.dev-dependencies] +livekit_client = {workspace = true, features = ["test-support"] } + [package.metadata.cargo-machete] ignored = ["async-stripe"] diff --git a/crates/collab/k8s/collab.template.yml b/crates/collab/k8s/collab.template.yml index fb5d4ed6ec..a2f89e5646 100644 --- a/crates/collab/k8s/collab.template.yml +++ b/crates/collab/k8s/collab.template.yml @@ -109,17 +109,17 @@ spec: secretKeyRef: name: zed-client key: checksum-seed - - name: LIVE_KIT_SERVER + - name: LIVEKIT_SERVER valueFrom: secretKeyRef: name: livekit key: server - - name: LIVE_KIT_KEY + - name: LIVEKIT_KEY valueFrom: secretKeyRef: name: livekit key: key - - name: LIVE_KIT_SECRET + - name: LIVEKIT_SECRET valueFrom: secretKeyRef: name: livekit diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 10120ea814..b107358eff 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -154,9 +154,9 @@ impl Database { } let role = role.unwrap(); - let live_kit_room = format!("channel-{}", nanoid::nanoid!(30)); + let livekit_room = format!("channel-{}", nanoid::nanoid!(30)); let room_id = self - .get_or_create_channel_room(channel_id, &live_kit_room, &tx) + .get_or_create_channel_room(channel_id, &livekit_room, &tx) .await?; self.join_channel_room_internal(room_id, user_id, connection, role, &tx) @@ -896,7 +896,7 @@ impl Database { pub(crate) async fn get_or_create_channel_room( &self, channel_id: ChannelId, - live_kit_room: &str, + livekit_room: &str, tx: &DatabaseTransaction, ) -> Result { let room = room::Entity::find() @@ -909,7 +909,7 @@ impl Database { } else { let result = room::Entity::insert(room::ActiveModel { channel_id: ActiveValue::Set(Some(channel_id)), - live_kit_room: ActiveValue::Set(live_kit_room.to_string()), + live_kit_room: ActiveValue::Set(livekit_room.to_string()), ..Default::default() }) .exec(tx) diff --git a/crates/collab/src/db/queries/rooms.rs b/crates/collab/src/db/queries/rooms.rs index 682c4ed389..a3a99bee71 100644 --- a/crates/collab/src/db/queries/rooms.rs +++ b/crates/collab/src/db/queries/rooms.rs @@ -103,11 +103,11 @@ impl Database { &self, user_id: UserId, connection: ConnectionId, - live_kit_room: &str, + livekit_room: &str, ) -> Result { self.transaction(|tx| async move { let room = room::ActiveModel { - live_kit_room: ActiveValue::set(live_kit_room.into()), + live_kit_room: ActiveValue::set(livekit_room.into()), ..Default::default() } .insert(&*tx) @@ -1316,7 +1316,7 @@ impl Database { channel, proto::Room { id: db_room.id.to_proto(), - live_kit_room: db_room.live_kit_room, + livekit_room: db_room.live_kit_room, participants: participants.into_values().collect(), pending_participants, followers, diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index f595cff890..cfa0e1631e 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -156,9 +156,9 @@ pub struct Config { pub clickhouse_password: Option, pub clickhouse_database: Option, pub invite_link_prefix: String, - pub live_kit_server: Option, - pub live_kit_key: Option, - pub live_kit_secret: Option, + pub livekit_server: Option, + pub livekit_key: Option, + pub livekit_secret: Option, pub llm_database_url: Option, pub llm_database_max_connections: Option, pub llm_database_migrations_path: Option, @@ -210,9 +210,9 @@ impl Config { database_max_connections: 0, api_token: "".into(), invite_link_prefix: "".into(), - live_kit_server: None, - live_kit_key: None, - live_kit_secret: None, + livekit_server: None, + livekit_key: None, + livekit_secret: None, llm_database_url: None, llm_database_max_connections: None, llm_database_migrations_path: None, @@ -277,7 +277,7 @@ impl ServiceMode { pub struct AppState { pub db: Arc, pub llm_db: Option>, - pub live_kit_client: Option>, + pub livekit_client: Option>, pub blob_store_client: Option, pub stripe_client: Option>, pub stripe_billing: Option>, @@ -309,17 +309,17 @@ impl AppState { None }; - let live_kit_client = if let Some(((server, key), secret)) = config - .live_kit_server + let livekit_client = if let Some(((server, key), secret)) = config + .livekit_server .as_ref() - .zip(config.live_kit_key.as_ref()) - .zip(config.live_kit_secret.as_ref()) + .zip(config.livekit_key.as_ref()) + .zip(config.livekit_secret.as_ref()) { - Some(Arc::new(live_kit_server::api::LiveKitClient::new( + Some(Arc::new(livekit_server::api::LiveKitClient::new( server.clone(), key.clone(), secret.clone(), - )) as Arc) + )) as Arc) } else { None }; @@ -329,7 +329,7 @@ impl AppState { let this = Self { db: db.clone(), llm_db, - live_kit_client, + livekit_client, blob_store_client: build_blob_store_client(&config).await.log_err(), stripe_billing: stripe_client .clone() diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 0d9cb2f6c2..8fa627d9e1 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -419,7 +419,7 @@ impl Server { let peer = self.peer.clone(); let timeout = self.app_state.executor.sleep(CLEANUP_TIMEOUT); let pool = self.connection_pool.clone(); - let live_kit_client = self.app_state.live_kit_client.clone(); + let livekit_client = self.app_state.livekit_client.clone(); let span = info_span!("start server"); self.app_state.executor.spawn_detached( @@ -464,8 +464,8 @@ impl Server { for room_id in room_ids { let mut contacts_to_update = HashSet::default(); let mut canceled_calls_to_user_ids = Vec::new(); - let mut live_kit_room = String::new(); - let mut delete_live_kit_room = false; + let mut livekit_room = String::new(); + let mut delete_livekit_room = false; if let Some(mut refreshed_room) = app_state .db @@ -488,8 +488,8 @@ impl Server { .extend(refreshed_room.canceled_calls_to_user_ids.iter().copied()); canceled_calls_to_user_ids = mem::take(&mut refreshed_room.canceled_calls_to_user_ids); - live_kit_room = mem::take(&mut refreshed_room.room.live_kit_room); - delete_live_kit_room = refreshed_room.room.participants.is_empty(); + livekit_room = mem::take(&mut refreshed_room.room.livekit_room); + delete_livekit_room = refreshed_room.room.participants.is_empty(); } { @@ -540,9 +540,9 @@ impl Server { } } - if let Some(live_kit) = live_kit_client.as_ref() { - if delete_live_kit_room { - live_kit.delete_room(live_kit_room).await.trace_err(); + if let Some(live_kit) = livekit_client.as_ref() { + if delete_livekit_room { + live_kit.delete_room(livekit_room).await.trace_err(); } } } @@ -1211,15 +1211,15 @@ async fn create_room( response: Response, session: Session, ) -> Result<()> { - let live_kit_room = nanoid::nanoid!(30); + let livekit_room = nanoid::nanoid!(30); let live_kit_connection_info = util::maybe!(async { - let live_kit = session.app_state.live_kit_client.as_ref(); + let live_kit = session.app_state.livekit_client.as_ref(); let live_kit = live_kit?; let user_id = session.user_id().to_string(); let token = live_kit - .room_token(&live_kit_room, &user_id.to_string()) + .room_token(&livekit_room, &user_id.to_string()) .trace_err()?; Some(proto::LiveKitConnectionInfo { @@ -1233,7 +1233,7 @@ async fn create_room( let room = session .db() .await - .create_room(session.user_id(), session.connection_id, &live_kit_room) + .create_room(session.user_id(), session.connection_id, &livekit_room) .await?; response.send(proto::CreateRoomResponse { @@ -1285,22 +1285,22 @@ async fn join_room( .trace_err(); } - let live_kit_connection_info = - if let Some(live_kit) = session.app_state.live_kit_client.as_ref() { - live_kit - .room_token( - &joined_room.room.live_kit_room, - &session.user_id().to_string(), - ) - .trace_err() - .map(|token| proto::LiveKitConnectionInfo { - server_url: live_kit.url().into(), - token, - can_publish: true, - }) - } else { - None - }; + let live_kit_connection_info = if let Some(live_kit) = session.app_state.livekit_client.as_ref() + { + live_kit + .room_token( + &joined_room.room.livekit_room, + &session.user_id().to_string(), + ) + .trace_err() + .map(|token| proto::LiveKitConnectionInfo { + server_url: live_kit.url().into(), + token, + can_publish: true, + }) + } else { + None + }; response.send(proto::JoinRoomResponse { room: Some(joined_room.room), @@ -1507,7 +1507,7 @@ async fn set_room_participant_role( let user_id = UserId::from_proto(request.user_id); let role = ChannelRole::from(request.role()); - let (live_kit_room, can_publish) = { + let (livekit_room, can_publish) = { let room = session .db() .await @@ -1519,18 +1519,18 @@ async fn set_room_participant_role( ) .await?; - let live_kit_room = room.live_kit_room.clone(); + let livekit_room = room.livekit_room.clone(); let can_publish = ChannelRole::from(request.role()).can_use_microphone(); room_updated(&room, &session.peer); - (live_kit_room, can_publish) + (livekit_room, can_publish) }; - if let Some(live_kit) = session.app_state.live_kit_client.as_ref() { + if let Some(live_kit) = session.app_state.livekit_client.as_ref() { live_kit .update_participant( - live_kit_room.clone(), + livekit_room.clone(), request.user_id.to_string(), - live_kit_server::proto::ParticipantPermission { + livekit_server::proto::ParticipantPermission { can_subscribe: true, can_publish, can_publish_data: can_publish, @@ -3092,7 +3092,7 @@ async fn join_channel_internal( let live_kit_connection_info = session .app_state - .live_kit_client + .livekit_client .as_ref() .and_then(|live_kit| { let (can_publish, token) = if role == ChannelRole::Guest { @@ -3100,7 +3100,7 @@ async fn join_channel_internal( false, live_kit .guest_token( - &joined_room.room.live_kit_room, + &joined_room.room.livekit_room, &session.user_id().to_string(), ) .trace_err()?, @@ -3110,7 +3110,7 @@ async fn join_channel_internal( true, live_kit .room_token( - &joined_room.room.live_kit_room, + &joined_room.room.livekit_room, &session.user_id().to_string(), ) .trace_err()?, @@ -4314,8 +4314,8 @@ async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) let room_id; let canceled_calls_to_user_ids; - let live_kit_room; - let delete_live_kit_room; + let livekit_room; + let delete_livekit_room; let room; let channel; @@ -4328,8 +4328,8 @@ async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) room_id = RoomId::from_proto(left_room.room.id); canceled_calls_to_user_ids = mem::take(&mut left_room.canceled_calls_to_user_ids); - live_kit_room = mem::take(&mut left_room.room.live_kit_room); - delete_live_kit_room = left_room.deleted; + livekit_room = mem::take(&mut left_room.room.livekit_room); + delete_livekit_room = left_room.deleted; room = mem::take(&mut left_room.room); channel = mem::take(&mut left_room.channel); @@ -4369,14 +4369,14 @@ async fn leave_room_for_session(session: &Session, connection_id: ConnectionId) update_user_contacts(contact_user_id, session).await?; } - if let Some(live_kit) = session.app_state.live_kit_client.as_ref() { + if let Some(live_kit) = session.app_state.livekit_client.as_ref() { live_kit - .remove_participant(live_kit_room.clone(), session.user_id().to_string()) + .remove_participant(livekit_room.clone(), session.user_id().to_string()) .await .trace_err(); - if delete_live_kit_room { - live_kit.delete_room(live_kit_room).await.trace_err(); + if delete_livekit_room { + live_kit.delete_room(livekit_room).await.trace_err(); } } diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index 29373bc6ea..2ce69efc9b 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -1,3 +1,6 @@ +// todo(windows): Actually run the tests +#![cfg(not(target_os = "windows"))] + use std::sync::Arc; use call::Room; diff --git a/crates/collab/src/tests/channel_guest_tests.rs b/crates/collab/src/tests/channel_guest_tests.rs index 5a091fe308..006a3e5d1c 100644 --- a/crates/collab/src/tests/channel_guest_tests.rs +++ b/crates/collab/src/tests/channel_guest_tests.rs @@ -107,7 +107,9 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test }); assert!(project_b.read_with(cx_b, |project, cx| project.is_read_only(cx))); assert!(editor_b.update(cx_b, |e, cx| e.read_only(cx))); - assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| !room.can_use_microphone(cx))); + }); assert!(room_b .update(cx_b, |room, cx| room.share_microphone(cx)) .await @@ -133,7 +135,9 @@ async fn test_channel_guest_promotion(cx_a: &mut TestAppContext, cx_b: &mut Test assert!(editor_b.update(cx_b, |editor, cx| !editor.read_only(cx))); // B sees themselves as muted, and can unmute. - assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| room.can_use_microphone(cx))); + }); room_b.read_with(cx_b, |room, _| assert!(room.is_muted())); room_b.update(cx_b, |room, cx| room.toggle_mute(cx)); cx_a.run_until_parked(); @@ -226,7 +230,9 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes let room_b = cx_b .read(ActiveCall::global) .update(cx_b, |call, _| call.room().unwrap().clone()); - assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| !room.can_use_microphone(cx))); + }); // A tries to grant write access to B, but cannot because B has not // yet signed the zed CLA. @@ -244,7 +250,9 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .unwrap_err(); cx_a.run_until_parked(); assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects())); - assert!(room_b.read_with(cx_b, |room, _| !room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| !room.can_use_microphone(cx))); + }); // A tries to grant write access to B, but cannot because B has not // yet signed the zed CLA. @@ -262,7 +270,9 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .unwrap(); cx_a.run_until_parked(); assert!(room_b.read_with(cx_b, |room, _| !room.can_share_projects())); - assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| room.can_use_microphone(cx))); + }); // User B signs the zed CLA. server @@ -287,5 +297,7 @@ async fn test_channel_requires_zed_cla(cx_a: &mut TestAppContext, cx_b: &mut Tes .unwrap(); cx_a.run_until_parked(); assert!(room_b.read_with(cx_b, |room, _| room.can_share_projects())); - assert!(room_b.read_with(cx_b, |room, _| room.can_use_microphone())); + cx_b.update(|cx_b| { + assert!(room_b.read_with(cx_b, |room, cx| room.can_use_microphone(cx))); + }); } diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index d708194f58..4de368d2ea 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1,5 +1,5 @@ #![allow(clippy::reversed_empty_ranges)] -use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; +use crate::tests::TestServer; use call::{ActiveCall, ParticipantLocation}; use client::ChannelId; use collab_ui::{ @@ -12,17 +12,11 @@ use gpui::{ View, VisualContext, VisualTestContext, }; use language::Capability; -use live_kit_client::MacOSDisplay; use project::WorktreeSettings; use rpc::proto::PeerId; use serde_json::json; use settings::SettingsStore; -use workspace::{ - dock::{test::TestPanel, DockPosition}, - item::{test::TestItem, ItemHandle as _}, - shared_screen::SharedScreen, - SplitDirection, Workspace, -}; +use workspace::{item::ItemHandle as _, SplitDirection, Workspace}; use super::TestClient; @@ -428,106 +422,118 @@ async fn test_basic_following( editor_a1.item_id() ); - // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. - let display = MacOSDisplay::new(); - active_call_b - .update(cx_b, |call, cx| call.set_location(None, cx)) - .await - .unwrap(); - active_call_b - .update(cx_b, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) + // TODO: Re-enable this test once we can replace our swift Livekit SDK with the rust SDK + #[cfg(not(target_os = "macos"))] + { + use crate::rpc::RECONNECT_TIMEOUT; + use gpui::TestScreenCaptureSource; + use workspace::{ + dock::{test::TestPanel, DockPosition}, + item::test::TestItem, + shared_screen::SharedScreen, + }; + + // Client B activates an external window, which causes a new screen-sharing item to be added to the pane. + let display = TestScreenCaptureSource::new(); + active_call_b + .update(cx_b, |call, cx| call.set_location(None, cx)) + .await + .unwrap(); + cx_b.set_screen_capture_sources(vec![display]); + active_call_b + .update(cx_b, |call, cx| { + call.room() + .unwrap() + .update(cx, |room, cx| room.share_screen(cx)) }) - }) - .await - .unwrap(); - executor.run_until_parked(); - let shared_screen = workspace_a.update(cx_a, |workspace, cx| { - workspace - .active_item(cx) - .expect("no active item") - .downcast::() - .expect("active item isn't a shared screen") - }); + .await + .unwrap(); // This is what breaks + executor.run_until_parked(); + let shared_screen = workspace_a.update(cx_a, |workspace, cx| { + workspace + .active_item(cx) + .expect("no active item") + .downcast::() + .expect("active item isn't a shared screen") + }); - // Client B activates Zed again, which causes the previous editor to become focused again. - active_call_b - .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) - .await - .unwrap(); - executor.run_until_parked(); - workspace_a.update(cx_a, |workspace, cx| { + // Client B activates Zed again, which causes the previous editor to become focused again. + active_call_b + .update(cx_b, |call, cx| call.set_location(Some(&project_b), cx)) + .await + .unwrap(); + executor.run_until_parked(); + 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. + workspace_b.update(cx_b, |workspace, cx| { + workspace.activate_item(&multibuffer_editor_b, true, true, cx) + }); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + 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 = cx_b.new_view(|cx| TestPanel::new(DockPosition::Left, cx)); + workspace_b.update(cx_b, |workspace, cx| { + workspace.add_panel(panel, cx); + workspace.toggle_panel_focus::(cx); + }); + executor.run_until_parked(); assert_eq!( - workspace.active_item(cx).unwrap().item_id(), - editor_a1.item_id() - ) - }); + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + shared_screen.item_id() + ); - // Client B activates a multibuffer that was created by following client A. Client A returns to that multibuffer. - workspace_b.update(cx_b, |workspace, cx| { - workspace.activate_item(&multibuffer_editor_b, true, true, cx) - }); - executor.run_until_parked(); - workspace_a.update(cx_a, |workspace, cx| { + // Toggling the focus back to the pane causes client A to return to the multibuffer. + workspace_b.update(cx_b, |workspace, cx| { + workspace.toggle_panel_focus::(cx); + }); + executor.run_until_parked(); + workspace_a.update(cx_a, |workspace, cx| { + assert_eq!( + 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 = cx_b.new_view(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) + }) + }); + executor.run_until_parked(); assert_eq!( - workspace.active_item(cx).unwrap().item_id(), - multibuffer_editor_a.item_id() - ) - }); + workspace_a.update(cx_a, |workspace, cx| workspace + .active_item(cx) + .unwrap() + .item_id()), + shared_screen.item_id() + ); - // Client B activates a panel, and the previously-opened screen-sharing item gets activated. - let panel = cx_b.new_view(|cx| TestPanel::new(DockPosition::Left, cx)); - 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.update(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .item_id()), - shared_screen.item_id() - ); - - // Toggling the focus back to the pane causes client A to return to the multibuffer. - workspace_b.update(cx_b, |workspace, cx| { - workspace.toggle_panel_focus::(cx); - }); - executor.run_until_parked(); - workspace_a.update(cx_a, |workspace, cx| { + // Following interrupts when client B disconnects. + client_b.disconnect(&cx_b.to_async()); + executor.advance_clock(RECONNECT_TIMEOUT); assert_eq!( - 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 = cx_b.new_view(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) - }) - }); - executor.run_until_parked(); - assert_eq!( - workspace_a.update(cx_a, |workspace, cx| workspace - .active_item(cx) - .unwrap() - .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.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), - None - ); + workspace_a.update(cx_a, |workspace, _| workspace.leader_for_pane(&pane_a)), + None + ); + } } #[gpui::test] diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index 04b9a36fc7..a0b36ce5cc 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -25,7 +25,6 @@ use language::{ tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticEntry, FakeLspAdapter, Language, LanguageConfig, LanguageMatcher, LineEnding, OffsetRangeExt, Point, Rope, }; -use live_kit_client::MacOSDisplay; use lsp::LanguageServerId; use parking_lot::Mutex; use project::lsp_store::FormatTarget; @@ -241,56 +240,60 @@ async fn test_basic_calls( } ); - // User A shares their screen - let display = MacOSDisplay::new(); - let events_b = active_call_events(cx_b); - let events_c = active_call_events(cx_c); - active_call_a - .update(cx_a, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) + // TODO: Re-enable this test once we can replace our swift Livekit SDK with the rust SDK + #[cfg(not(target_os = "macos"))] + { + // User A shares their screen + let display = gpui::TestScreenCaptureSource::new(); + let events_b = active_call_events(cx_b); + let events_c = active_call_events(cx_c); + cx_a.set_screen_capture_sources(vec![display]); + active_call_a + .update(cx_a, |call, cx| { + call.room() + .unwrap() + .update(cx, |room, cx| room.share_screen(cx)) }) - }) - .await - .unwrap(); + .await + .unwrap(); - executor.run_until_parked(); + executor.run_until_parked(); - // User B observes the remote screen sharing track. - assert_eq!(events_b.borrow().len(), 1); - let event_b = events_b.borrow().first().unwrap().clone(); - if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_b { - assert_eq!(participant_id, client_a.peer_id().unwrap()); + // User B observes the remote screen sharing track. + assert_eq!(events_b.borrow().len(), 1); + let event_b = events_b.borrow().first().unwrap().clone(); + if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_b { + assert_eq!(participant_id, client_a.peer_id().unwrap()); - room_b.read_with(cx_b, |room, _| { - assert_eq!( - room.remote_participants()[&client_a.user_id().unwrap()] - .video_tracks - .len(), - 1 - ); - }); - } else { - panic!("unexpected event") - } + room_b.read_with(cx_b, |room, _| { + assert_eq!( + room.remote_participants()[&client_a.user_id().unwrap()] + .video_tracks + .len(), + 1 + ); + }); + } else { + panic!("unexpected event") + } - // User C observes the remote screen sharing track. - assert_eq!(events_c.borrow().len(), 1); - let event_c = events_c.borrow().first().unwrap().clone(); - if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_c { - assert_eq!(participant_id, client_a.peer_id().unwrap()); + // User C observes the remote screen sharing track. + assert_eq!(events_c.borrow().len(), 1); + let event_c = events_c.borrow().first().unwrap().clone(); + if let call::room::Event::RemoteVideoTracksChanged { participant_id } = event_c { + assert_eq!(participant_id, client_a.peer_id().unwrap()); - room_c.read_with(cx_c, |room, _| { - assert_eq!( - room.remote_participants()[&client_a.user_id().unwrap()] - .video_tracks - .len(), - 1 - ); - }); - } else { - panic!("unexpected event") + room_c.read_with(cx_c, |room, _| { + assert_eq!( + room.remote_participants()[&client_a.user_id().unwrap()] + .video_tracks + .len(), + 1 + ); + }); + } else { + panic!("unexpected event") + } } // User A leaves the room. @@ -329,7 +332,7 @@ async fn test_basic_calls( // to automatically leave the room. User C leaves the room as well because // nobody else is in there. server - .test_live_kit_server + .test_livekit_server .disconnect_client(client_b.user_id().unwrap().to_string()) .await; executor.run_until_parked(); @@ -844,7 +847,7 @@ async fn test_client_disconnecting_from_room( // User B gets disconnected from the LiveKit server, which causes it // to automatically leave the room. server - .test_live_kit_server + .test_livekit_server .disconnect_client(client_b.user_id().unwrap().to_string()) .await; executor.run_until_parked(); @@ -1943,7 +1946,7 @@ async fn test_mute_deafen( room_a.read_with(cx_a, |room, _| assert!(!room.is_muted())); room_b.read_with(cx_b, |room, _| assert!(!room.is_muted())); - // Users A and B are both muted. + // Users A and B are both unmuted. assert_eq!( participant_audio_state(&room_a, cx_a), &[ParticipantAudioState { @@ -2075,7 +2078,17 @@ async fn test_mute_deafen( audio_tracks_playing: participant .audio_tracks .values() - .map(|track| track.is_playing()) + .map({ + #[cfg(target_os = "macos")] + { + |track| track.is_playing() + } + + #[cfg(not(target_os = "macos"))] + { + |(track, _)| track.rtc_track().enabled() + } + }) .collect(), }) .collect::>() @@ -6015,6 +6028,8 @@ async fn test_contact_requests( } } +// TODO: Re-enable this test once we can replace our swift Livekit SDK with the rust SDK +#[cfg(not(target_os = "macos"))] #[gpui::test(iterations = 10)] async fn test_join_call_after_screen_was_shared( executor: BackgroundExecutor, @@ -6057,13 +6072,13 @@ async fn test_join_call_after_screen_was_shared( assert_eq!(call_b.calling_user.github_login, "user_a"); // User A shares their screen - let display = MacOSDisplay::new(); + let display = gpui::TestScreenCaptureSource::new(); + cx_a.set_screen_capture_sources(vec![display]); active_call_a .update(cx_a, |call, cx| { - call.room().unwrap().update(cx, |room, cx| { - room.set_display_sources(vec![display.clone()]); - room.share_screen(cx) - }) + call.room() + .unwrap() + .update(cx, |room, cx| room.share_screen(cx)) }) .await .unwrap(); diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 1528da2ff0..e66a828a77 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -45,9 +45,15 @@ use std::{ }; use workspace::{Workspace, WorkspaceStore}; +#[cfg(not(target_os = "macos"))] +use livekit_client::test::TestServer as LivekitTestServer; + +#[cfg(target_os = "macos")] +use livekit_client_macos::TestServer as LivekitTestServer; + pub struct TestServer { pub app_state: Arc, - pub test_live_kit_server: Arc, + pub test_livekit_server: Arc, server: Arc, next_github_user_id: i32, connection_killers: Arc>>>, @@ -79,7 +85,7 @@ pub struct ContactsSummary { impl TestServer { pub async fn start(deterministic: BackgroundExecutor) -> Self { - static NEXT_LIVE_KIT_SERVER_ID: AtomicUsize = AtomicUsize::new(0); + static NEXT_LIVEKIT_SERVER_ID: AtomicUsize = AtomicUsize::new(0); let use_postgres = env::var("USE_POSTGRES").ok(); let use_postgres = use_postgres.as_deref(); @@ -88,16 +94,16 @@ impl TestServer { } else { TestDb::sqlite(deterministic.clone()) }; - let live_kit_server_id = NEXT_LIVE_KIT_SERVER_ID.fetch_add(1, SeqCst); - let live_kit_server = live_kit_client::TestServer::create( - format!("http://livekit.{}.test", live_kit_server_id), - format!("devkey-{}", live_kit_server_id), - format!("secret-{}", live_kit_server_id), + let livekit_server_id = NEXT_LIVEKIT_SERVER_ID.fetch_add(1, SeqCst); + let livekit_server = LivekitTestServer::create( + format!("http://livekit.{}.test", livekit_server_id), + format!("devkey-{}", livekit_server_id), + format!("secret-{}", livekit_server_id), deterministic.clone(), ) .unwrap(); let executor = Executor::Deterministic(deterministic.clone()); - let app_state = Self::build_app_state(&test_db, &live_kit_server, executor.clone()).await; + let app_state = Self::build_app_state(&test_db, &livekit_server, executor.clone()).await; let epoch = app_state .db .create_server(&app_state.config.zed_environment) @@ -114,7 +120,7 @@ impl TestServer { forbid_connections: Default::default(), next_github_user_id: 0, _test_db: test_db, - test_live_kit_server: live_kit_server, + test_livekit_server: livekit_server, } } @@ -500,13 +506,13 @@ impl TestServer { pub async fn build_app_state( test_db: &TestDb, - live_kit_test_server: &live_kit_client::TestServer, + livekit_test_server: &LivekitTestServer, executor: Executor, ) -> Arc { Arc::new(AppState { db: test_db.db().clone(), llm_db: None, - live_kit_client: Some(Arc::new(live_kit_test_server.create_api_client())), + livekit_client: Some(Arc::new(livekit_test_server.create_api_client())), blob_store_client: None, stripe_client: None, stripe_billing: None, @@ -520,9 +526,9 @@ impl TestServer { database_max_connections: 0, api_token: "".into(), invite_link_prefix: "".into(), - live_kit_server: None, - live_kit_key: None, - live_kit_secret: None, + livekit_server: None, + livekit_key: None, + livekit_secret: None, llm_database_url: None, llm_database_max_connections: None, llm_database_migrations_path: None, @@ -572,7 +578,7 @@ impl Deref for TestServer { impl Drop for TestServer { fn drop(&mut self) { self.server.teardown(); - self.test_live_kit_server.teardown().unwrap(); + self.test_livekit_server.teardown().unwrap(); } } diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index c93a48096a..fa3ab0219b 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -474,11 +474,10 @@ impl CollabPanel { 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(), + is_last: projects.peek().is_none() && !participant.has_video_tracks(), }); } - if !participant.video_tracks.is_empty() { + if participant.has_video_tracks() { self.entries.push(ListEntry::ParticipantScreen { peer_id: Some(participant.peer_id), is_last: true, diff --git a/crates/gpui/build.rs b/crates/gpui/build.rs index ef29d7cc82..045372b73c 100644 --- a/crates/gpui/build.rs +++ b/crates/gpui/build.rs @@ -50,6 +50,7 @@ mod macos { fn generate_dispatch_bindings() { println!("cargo:rustc-link-lib=framework=System"); + println!("cargo:rustc-link-lib=framework=ScreenCaptureKit"); println!("cargo:rerun-if-changed=src/platform/mac/dispatch.h"); let bindings = bindgen::Builder::default() diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index 87ee3942dd..ca787587b9 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -33,8 +33,8 @@ use crate::{ Entity, EventEmitter, ForegroundExecutor, Global, KeyBinding, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, Point, PromptBuilder, PromptHandle, PromptLevel, Render, RenderablePromptHandle, Reservation, - SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, View, ViewContext, - Window, WindowAppearance, WindowContext, WindowHandle, WindowId, + ScreenCaptureSource, SharedString, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, + View, ViewContext, Window, WindowAppearance, WindowContext, WindowHandle, WindowId, }; mod async_context; @@ -599,6 +599,13 @@ impl AppContext { self.platform.primary_display() } + /// Returns a list of available screen capture sources. + pub fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + self.platform.screen_capture_sources() + } + /// Returns the display with the given ID, if one exists. pub fn find_display(&self, id: DisplayId) -> Option> { self.displays() diff --git a/crates/gpui/src/app/test_context.rs b/crates/gpui/src/app/test_context.rs index 2fea804301..04ca7764c5 100644 --- a/crates/gpui/src/app/test_context.rs +++ b/crates/gpui/src/app/test_context.rs @@ -4,8 +4,8 @@ use crate::{ Element, Empty, Entity, EventEmitter, ForegroundExecutor, Global, InputEvent, Keystroke, Model, ModelContext, Modifiers, ModifiersChangedEvent, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, Platform, Point, Render, Result, Size, Task, TestDispatcher, - TestPlatform, TestWindow, TextSystem, View, ViewContext, VisualContext, WindowBounds, - WindowContext, WindowHandle, WindowOptions, + TestPlatform, TestScreenCaptureSource, TestWindow, TextSystem, View, ViewContext, + VisualContext, WindowBounds, WindowContext, WindowHandle, WindowOptions, }; use anyhow::{anyhow, bail}; use futures::{channel::oneshot, Stream, StreamExt}; @@ -287,6 +287,12 @@ impl TestAppContext { self.test_window(window_handle).simulate_resize(size); } + /// Causes the given sources to be returned if the application queries for screen + /// capture sources. + pub fn set_screen_capture_sources(&self, sources: Vec) { + self.test_platform.set_screen_capture_sources(sources); + } + /// Returns all windows open in the test. pub fn windows(&self) -> Vec { self.app.borrow().windows().clone() diff --git a/crates/gpui/src/geometry.rs b/crates/gpui/src/geometry.rs index 9e0b9b9014..b636c95a61 100644 --- a/crates/gpui/src/geometry.rs +++ b/crates/gpui/src/geometry.rs @@ -704,6 +704,11 @@ pub struct Bounds { pub size: Size, } +/// Create a bounds with the given origin and size +pub fn bounds(origin: Point, size: Size) -> Bounds { + Bounds { origin, size } +} + impl Bounds { /// Generate a centered bounds for the given display or primary display if none is provided pub fn centered(display_id: Option, size: Size, cx: &AppContext) -> Self { diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 8228d44bb4..f3ffa323d8 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -71,6 +71,9 @@ pub(crate) use test::*; #[cfg(target_os = "windows")] pub(crate) use windows::*; +#[cfg(any(test, feature = "test-support"))] +pub use test::TestScreenCaptureSource; + #[cfg(target_os = "macos")] pub(crate) fn current_platform(headless: bool) -> Rc { Rc::new(MacPlatform::new(headless)) @@ -150,6 +153,10 @@ pub(crate) trait Platform: 'static { None } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>>; + fn open_window( &self, handle: AnyWindowHandle, @@ -229,6 +236,25 @@ pub trait PlatformDisplay: Send + Sync + Debug { } } +/// A source of on-screen video content that can be captured. +pub trait ScreenCaptureSource { + /// Returns the video resolution of this source. + fn resolution(&self) -> Result>; + + /// Start capture video from this source, invoking the given callback + /// with each frame. + fn stream( + &self, + frame_callback: Box, + ) -> oneshot::Receiver>>; +} + +/// A video stream captured from a screen. +pub trait ScreenCaptureStream {} + +/// A frame of video captured from a screen. +pub struct ScreenCaptureFrame(pub PlatformScreenCaptureFrame); + /// An opaque identifier for a hardware display #[derive(PartialEq, Eq, Hash, Copy, Clone)] pub struct DisplayId(pub(crate) u32); diff --git a/crates/gpui/src/platform/linux.rs b/crates/gpui/src/platform/linux.rs index 0499869361..089b52cf1e 100644 --- a/crates/gpui/src/platform/linux.rs +++ b/crates/gpui/src/platform/linux.rs @@ -20,3 +20,5 @@ pub(crate) use text_system::*; pub(crate) use wayland::*; #[cfg(feature = "x11")] pub(crate) use x11::*; + +pub(crate) type PlatformScreenCaptureFrame = (); diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 650ed70af8..a85052a4f0 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -35,8 +35,8 @@ use crate::{ px, Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, Keystroke, LinuxDispatcher, Menu, MenuItem, Modifiers, OwnedMenu, PathPromptOptions, Pixels, Platform, PlatformDisplay, PlatformInputHandler, PlatformTextSystem, - PlatformWindow, Point, PromptLevel, Result, SemanticVersion, SharedString, Size, Task, - WindowAppearance, WindowOptions, WindowParams, + PlatformWindow, Point, PromptLevel, Result, ScreenCaptureSource, SemanticVersion, SharedString, + Size, Task, WindowAppearance, WindowOptions, WindowParams, }; pub(crate) const SCROLL_LINES: f32 = 3.0; @@ -242,6 +242,14 @@ impl Platform for P { self.displays() } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + let (mut tx, rx) = oneshot::channel(); + tx.send(Err(anyhow!("screen capture not implemented"))).ok(); + rx + } + fn active_window(&self) -> Option { self.active_window() } diff --git a/crates/gpui/src/platform/mac.rs b/crates/gpui/src/platform/mac.rs index 396fd49d04..bd3d8f35ac 100644 --- a/crates/gpui/src/platform/mac.rs +++ b/crates/gpui/src/platform/mac.rs @@ -4,12 +4,14 @@ mod dispatcher; mod display; mod display_link; mod events; +mod screen_capture; #[cfg(not(feature = "macos-blade"))] mod metal_atlas; #[cfg(not(feature = "macos-blade"))] pub mod metal_renderer; +use media::core_video::CVImageBuffer; #[cfg(not(feature = "macos-blade"))] use metal_renderer as renderer; @@ -49,6 +51,9 @@ pub(crate) use window::*; #[cfg(feature = "font-kit")] pub(crate) use text_system::*; +/// A frame of video captured from a screen. +pub(crate) type PlatformScreenCaptureFrame = CVImageBuffer; + trait BoolExt { fn to_objc(self) -> BOOL; } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 28f427af1b..f0fe560ca4 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,14 +1,14 @@ use super::{ attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, - BoolExt, + renderer, screen_capture, BoolExt, }; use crate::{ hash, Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, PathPromptOptions, Platform, PlatformDisplay, - PlatformTextSystem, PlatformWindow, Result, SemanticVersion, Task, WindowAppearance, - WindowParams, + PlatformTextSystem, PlatformWindow, Result, ScreenCaptureSource, SemanticVersion, Task, + WindowAppearance, WindowParams, }; use anyhow::anyhow; use block::ConcreteBlock; @@ -58,8 +58,6 @@ use std::{ }; use strum::IntoEnumIterator; -use super::renderer; - #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -552,6 +550,12 @@ impl Platform for MacPlatform { .collect() } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + screen_capture::get_sources() + } + fn active_window(&self) -> Option { MacWindow::active_window() } diff --git a/crates/gpui/src/platform/mac/screen_capture.rs b/crates/gpui/src/platform/mac/screen_capture.rs new file mode 100644 index 0000000000..a2b535996f --- /dev/null +++ b/crates/gpui/src/platform/mac/screen_capture.rs @@ -0,0 +1,239 @@ +use crate::{ + platform::{ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream}, + px, size, Pixels, Size, +}; +use anyhow::{anyhow, Result}; +use block::ConcreteBlock; +use cocoa::{ + base::{id, nil, YES}, + foundation::NSArray, +}; +use core_foundation::base::TCFType; +use ctor::ctor; +use futures::channel::oneshot; +use media::core_media::{CMSampleBuffer, CMSampleBufferRef}; +use metal::NSInteger; +use objc::{ + class, + declare::ClassDecl, + msg_send, + runtime::{Class, Object, Sel}, + sel, sel_impl, +}; +use std::{cell::RefCell, ffi::c_void, mem, ptr, rc::Rc}; + +#[derive(Clone)] +pub struct MacScreenCaptureSource { + sc_display: id, +} + +pub struct MacScreenCaptureStream { + sc_stream: id, + sc_stream_output: id, +} + +#[link(name = "ScreenCaptureKit", kind = "framework")] +extern "C" {} + +static mut DELEGATE_CLASS: *const Class = ptr::null(); +static mut OUTPUT_CLASS: *const Class = ptr::null(); +const FRAME_CALLBACK_IVAR: &str = "frame_callback"; + +#[allow(non_upper_case_globals)] +const SCStreamOutputTypeScreen: NSInteger = 0; + +impl ScreenCaptureSource for MacScreenCaptureSource { + fn resolution(&self) -> Result> { + unsafe { + let width: i64 = msg_send![self.sc_display, width]; + let height: i64 = msg_send![self.sc_display, height]; + Ok(size(px(width as f32), px(height as f32))) + } + } + + fn stream( + &self, + frame_callback: Box, + ) -> oneshot::Receiver>> { + unsafe { + let stream: id = msg_send![class!(SCStream), alloc]; + let filter: id = msg_send![class!(SCContentFilter), alloc]; + let configuration: id = msg_send![class!(SCStreamConfiguration), alloc]; + let delegate: id = msg_send![DELEGATE_CLASS, alloc]; + let output: id = msg_send![OUTPUT_CLASS, alloc]; + + let excluded_windows = NSArray::array(nil); + let filter: id = msg_send![filter, initWithDisplay:self.sc_display excludingWindows:excluded_windows]; + let configuration: id = msg_send![configuration, init]; + let delegate: id = msg_send![delegate, init]; + let output: id = msg_send![output, init]; + + output.as_mut().unwrap().set_ivar( + FRAME_CALLBACK_IVAR, + Box::into_raw(Box::new(frame_callback)) as *mut c_void, + ); + + let stream: id = msg_send![stream, initWithFilter:filter configuration:configuration delegate:delegate]; + + let (mut tx, rx) = oneshot::channel(); + + let mut error: id = nil; + let _: () = msg_send![stream, addStreamOutput:output type:SCStreamOutputTypeScreen sampleHandlerQueue:0 error:&mut error as *mut id]; + if error != nil { + let message: id = msg_send![error, localizedDescription]; + tx.send(Err(anyhow!("failed to add stream output {message:?}"))) + .ok(); + return rx; + } + + let tx = Rc::new(RefCell::new(Some(tx))); + let handler = ConcreteBlock::new({ + move |error: id| { + let result = if error == nil { + let stream = MacScreenCaptureStream { + sc_stream: stream, + sc_stream_output: output, + }; + Ok(Box::new(stream) as Box) + } else { + let message: id = msg_send![error, localizedDescription]; + Err(anyhow!("failed to stop screen capture stream {message:?}")) + }; + if let Some(tx) = tx.borrow_mut().take() { + tx.send(result).ok(); + } + } + }); + let handler = handler.copy(); + let _: () = msg_send![stream, startCaptureWithCompletionHandler:handler]; + rx + } + } +} + +impl Drop for MacScreenCaptureSource { + fn drop(&mut self) { + unsafe { + let _: () = msg_send![self.sc_display, release]; + } + } +} + +impl ScreenCaptureStream for MacScreenCaptureStream {} + +impl Drop for MacScreenCaptureStream { + fn drop(&mut self) { + unsafe { + let mut error: id = nil; + let _: () = msg_send![self.sc_stream, removeStreamOutput:self.sc_stream_output type:SCStreamOutputTypeScreen error:&mut error as *mut _]; + if error != nil { + let message: id = msg_send![error, localizedDescription]; + log::error!("failed to add stream output {message:?}"); + } + + let handler = ConcreteBlock::new(move |error: id| { + if error != nil { + let message: id = msg_send![error, localizedDescription]; + log::error!("failed to stop screen capture stream {message:?}"); + } + }); + let block = handler.copy(); + let _: () = msg_send![self.sc_stream, stopCaptureWithCompletionHandler:block]; + let _: () = msg_send![self.sc_stream, release]; + let _: () = msg_send![self.sc_stream_output, release]; + } + } +} + +pub(crate) fn get_sources() -> oneshot::Receiver>>> { + unsafe { + let (mut tx, rx) = oneshot::channel(); + let tx = Rc::new(RefCell::new(Some(tx))); + + let block = ConcreteBlock::new(move |shareable_content: id, error: id| { + let Some(mut tx) = tx.borrow_mut().take() else { + return; + }; + let result = if error == nil { + let displays: id = msg_send![shareable_content, displays]; + let mut result = Vec::new(); + for i in 0..displays.count() { + let display = displays.objectAtIndex(i); + let source = MacScreenCaptureSource { + sc_display: msg_send![display, retain], + }; + result.push(Box::new(source) as Box); + } + Ok(result) + } else { + let msg: id = msg_send![error, localizedDescription]; + Err(anyhow!("Failed to register: {:?}", msg)) + }; + tx.send(result).ok(); + }); + let block = block.copy(); + + let _: () = msg_send![ + class!(SCShareableContent), + getShareableContentExcludingDesktopWindows:YES + onScreenWindowsOnly:YES + completionHandler:block]; + rx + } +} + +#[ctor] +unsafe fn build_classes() { + let mut decl = ClassDecl::new("GPUIStreamDelegate", class!(NSObject)).unwrap(); + decl.add_method( + sel!(outputVideoEffectDidStartForStream:), + output_video_effect_did_start_for_stream as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(outputVideoEffectDidStopForStream:), + output_video_effect_did_stop_for_stream as extern "C" fn(&Object, Sel, id), + ); + decl.add_method( + sel!(stream:didStopWithError:), + stream_did_stop_with_error as extern "C" fn(&Object, Sel, id, id), + ); + DELEGATE_CLASS = decl.register(); + + let mut decl = ClassDecl::new("GPUIStreamOutput", class!(NSObject)).unwrap(); + decl.add_method( + sel!(stream:didOutputSampleBuffer:ofType:), + stream_did_output_sample_buffer_of_type as extern "C" fn(&Object, Sel, id, id, NSInteger), + ); + decl.add_ivar::<*mut c_void>(FRAME_CALLBACK_IVAR); + + OUTPUT_CLASS = decl.register(); +} + +extern "C" fn output_video_effect_did_start_for_stream(_this: &Object, _: Sel, _stream: id) {} + +extern "C" fn output_video_effect_did_stop_for_stream(_this: &Object, _: Sel, _stream: id) {} + +extern "C" fn stream_did_stop_with_error(_this: &Object, _: Sel, _stream: id, _error: id) {} + +extern "C" fn stream_did_output_sample_buffer_of_type( + this: &Object, + _: Sel, + _stream: id, + sample_buffer: id, + buffer_type: NSInteger, +) { + if buffer_type != SCStreamOutputTypeScreen { + return; + } + + unsafe { + let sample_buffer = sample_buffer as CMSampleBufferRef; + let sample_buffer = CMSampleBuffer::wrap_under_get_rule(sample_buffer); + if let Some(buffer) = sample_buffer.image_buffer() { + let callback: Box> = + Box::from_raw(*this.get_ivar::<*mut c_void>(FRAME_CALLBACK_IVAR) as *mut _); + callback(ScreenCaptureFrame(buffer)); + mem::forget(callback); + } + } +} diff --git a/crates/gpui/src/platform/test.rs b/crates/gpui/src/platform/test.rs index d17739239e..70462cb5e2 100644 --- a/crates/gpui/src/platform/test.rs +++ b/crates/gpui/src/platform/test.rs @@ -7,3 +7,5 @@ pub(crate) use dispatcher::*; pub(crate) use display::*; pub(crate) use platform::*; pub(crate) use window::*; + +pub use platform::TestScreenCaptureSource; diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index aadbe9b595..67227b60fe 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,7 +1,7 @@ use crate::{ - AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, Keymap, - Platform, PlatformDisplay, PlatformTextSystem, Task, TestDisplay, TestWindow, WindowAppearance, - WindowParams, + px, size, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, ForegroundExecutor, + Keymap, Platform, PlatformDisplay, PlatformTextSystem, ScreenCaptureFrame, ScreenCaptureSource, + ScreenCaptureStream, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, }; use anyhow::Result; use collections::VecDeque; @@ -31,6 +31,7 @@ pub(crate) struct TestPlatform { #[cfg(any(target_os = "linux", target_os = "freebsd"))] current_primary_item: Mutex>, pub(crate) prompts: RefCell, + screen_capture_sources: RefCell>, pub opened_url: RefCell>, pub text_system: Arc, #[cfg(target_os = "windows")] @@ -38,6 +39,31 @@ pub(crate) struct TestPlatform { weak: Weak, } +#[derive(Clone)] +/// A fake screen capture source, used for testing. +pub struct TestScreenCaptureSource {} + +pub struct TestScreenCaptureStream {} + +impl ScreenCaptureSource for TestScreenCaptureSource { + fn resolution(&self) -> Result> { + Ok(size(px(1.), px(1.))) + } + + fn stream( + &self, + _frame_callback: Box, + ) -> oneshot::Receiver>> { + let (mut tx, rx) = oneshot::channel(); + let stream = TestScreenCaptureStream {}; + tx.send(Ok(Box::new(stream) as Box)) + .ok(); + rx + } +} + +impl ScreenCaptureStream for TestScreenCaptureStream {} + #[derive(Default)] pub(crate) struct TestPrompts { multiple_choice: VecDeque>, @@ -72,6 +98,7 @@ impl TestPlatform { background_executor: executor, foreground_executor, prompts: Default::default(), + screen_capture_sources: Default::default(), active_cursor: Default::default(), active_display: Rc::new(TestDisplay::new()), active_window: Default::default(), @@ -114,6 +141,10 @@ impl TestPlatform { !self.prompts.borrow().multiple_choice.is_empty() } + pub(crate) fn set_screen_capture_sources(&self, sources: Vec) { + *self.screen_capture_sources.borrow_mut() = sources; + } + pub(crate) fn prompt(&self, msg: &str, detail: Option<&str>) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); self.background_executor() @@ -202,6 +233,20 @@ impl Platform for TestPlatform { Some(self.active_display.clone()) } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + let (mut tx, rx) = oneshot::channel(); + tx.send(Ok(self + .screen_capture_sources + .borrow() + .iter() + .map(|source| Box::new(source.clone()) as Box) + .collect())) + .ok(); + rx + } + fn active_window(&self) -> Option { self.active_window .borrow() @@ -330,6 +375,13 @@ impl Platform for TestPlatform { } } +impl TestScreenCaptureSource { + /// Create a fake screen capture source, for testing. + pub fn new() -> Self { + Self {} + } +} + #[cfg(target_os = "windows")] impl Drop for TestPlatform { fn drop(&mut self) { diff --git a/crates/gpui/src/platform/windows.rs b/crates/gpui/src/platform/windows.rs index 84cf107c70..51d09f0013 100644 --- a/crates/gpui/src/platform/windows.rs +++ b/crates/gpui/src/platform/windows.rs @@ -21,3 +21,5 @@ pub(crate) use window::*; pub(crate) use wrapper::*; pub(crate) use windows::Win32::Foundation::HWND; + +pub(crate) type PlatformScreenCaptureFrame = (); diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 389b90765d..0c23a4ef7a 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -325,6 +325,14 @@ impl Platform for WindowsPlatform { WindowsDisplay::primary_monitor().map(|display| Rc::new(display) as Rc) } + fn screen_capture_sources( + &self, + ) -> oneshot::Receiver>>> { + let (mut tx, rx) = oneshot::channel(); + tx.send(Err(anyhow!("screen capture not implemented"))).ok(); + rx + } + fn active_window(&self) -> Option { let active_window_hwnd = unsafe { GetActiveWindow() }; self.try_get_windows_inner_from_hwnd(active_window_hwnd) diff --git a/crates/http_client/Cargo.toml b/crates/http_client/Cargo.toml index ac8e254b84..a4f10cff18 100644 --- a/crates/http_client/Cargo.toml +++ b/crates/http_client/Cargo.toml @@ -20,7 +20,7 @@ bytes.workspace = true anyhow.workspace = true derive_more.workspace = true futures.workspace = true -http = "1.1" +http.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/live_kit_client/.cargo/config.toml b/crates/livekit_client/.cargo/config.toml similarity index 62% rename from crates/live_kit_client/.cargo/config.toml rename to crates/livekit_client/.cargo/config.toml index b33fe211bd..77f7c9dd6c 100644 --- a/crates/live_kit_client/.cargo/config.toml +++ b/crates/livekit_client/.cargo/config.toml @@ -1,2 +1,2 @@ -[live_kit_client_test] +[livekit_client_test] rustflags = ["-C", "link-args=-ObjC"] diff --git a/crates/livekit_client/Cargo.toml b/crates/livekit_client/Cargo.toml new file mode 100644 index 0000000000..ac0c3b5740 --- /dev/null +++ b/crates/livekit_client/Cargo.toml @@ -0,0 +1,65 @@ +[package] +name = "livekit_client" +version = "0.1.0" +edition = "2021" +description = "Logic for using LiveKit with GPUI" +publish = false +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/livekit_client.rs" +doctest = false + +[[example]] +name = "test_app" + +[features] +no-webrtc = [] +test-support = [ + "collections/test-support", + "gpui/test-support", + "nanoid", +] + +[dependencies] +anyhow.workspace = true +async-trait.workspace = true +collections.workspace = true +cpal = "0.15" +futures.workspace = true +gpui.workspace = true +http_2 = { package = "http", version = "0.2.1" } +livekit_server.workspace = true +log.workspace = true +media.workspace = true +nanoid = { workspace = true, optional = true} +parking_lot.workspace = true +postage.workspace = true +util.workspace = true +http_client.workspace = true +smallvec.workspace = true +image.workspace = true + +[target.'cfg(not(target_os = "windows"))'.dependencies] +livekit.workspace = true + +[target.'cfg(target_os = "macos")'.dependencies] +core-foundation.workspace = true +coreaudio-rs = "0.12.1" + +[dev-dependencies] +collections = { workspace = true, features = ["test-support"] } +gpui = { workspace = true, features = ["test-support"] } +nanoid.workspace = true +sha2.workspace = true +simplelog.workspace = true + +[build-dependencies] +serde.workspace = true +serde_json.workspace = true + +[package.metadata.cargo-machete] +ignored = ["serde_json"] diff --git a/crates/live_kit_client/LICENSE-GPL b/crates/livekit_client/LICENSE-GPL similarity index 100% rename from crates/live_kit_client/LICENSE-GPL rename to crates/livekit_client/LICENSE-GPL diff --git a/crates/livekit_client/examples/test_app.rs b/crates/livekit_client/examples/test_app.rs new file mode 100644 index 0000000000..ef7fc91d31 --- /dev/null +++ b/crates/livekit_client/examples/test_app.rs @@ -0,0 +1,442 @@ +#![cfg_attr(windows, allow(unused))] +// TODO: For some reason mac build complains about import of postage::stream::Stream, but removal of +// it causes compile errors. +#![cfg_attr(target_os = "macos", allow(unused_imports))] + +use gpui::{ + actions, bounds, div, point, + prelude::{FluentBuilder as _, IntoElement}, + px, rgb, size, AsyncAppContext, Bounds, InteractiveElement, KeyBinding, Menu, MenuItem, + ParentElement, Pixels, Render, ScreenCaptureStream, SharedString, + StatefulInteractiveElement as _, Styled, Task, View, ViewContext, VisualContext, WindowBounds, + WindowHandle, WindowOptions, +}; +#[cfg(not(target_os = "windows"))] +use livekit_client::{ + capture_local_audio_track, capture_local_video_track, + id::ParticipantIdentity, + options::{TrackPublishOptions, VideoCodec}, + participant::{Participant, RemoteParticipant}, + play_remote_audio_track, + publication::{LocalTrackPublication, RemoteTrackPublication}, + track::{LocalTrack, RemoteTrack, RemoteVideoTrack, TrackSource}, + AudioStream, RemoteVideoTrackView, Room, RoomEvent, RoomOptions, +}; +#[cfg(not(target_os = "windows"))] +use postage::stream::Stream; + +#[cfg(target_os = "windows")] +use livekit_client::{ + participant::{Participant, RemoteParticipant}, + publication::{LocalTrackPublication, RemoteTrackPublication}, + track::{LocalTrack, RemoteTrack, RemoteVideoTrack}, + AudioStream, RemoteVideoTrackView, Room, RoomEvent, +}; + +use livekit_server::token::{self, VideoGrant}; +use log::LevelFilter; +use simplelog::SimpleLogger; + +actions!(livekit_client, [Quit]); + +#[cfg(windows)] +fn main() {} + +#[cfg(not(windows))] +fn main() { + SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger"); + + gpui::App::new().run(|cx| { + livekit_client::init( + cx.background_executor().dispatcher.clone(), + cx.http_client(), + ); + + #[cfg(any(test, feature = "test-support"))] + println!("USING TEST LIVEKIT"); + + #[cfg(not(any(test, feature = "test-support")))] + println!("USING REAL LIVEKIT"); + + cx.activate(true); + cx.on_action(quit); + cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]); + cx.set_menus(vec![Menu { + name: "Zed".into(), + items: vec![MenuItem::Action { + name: "Quit".into(), + action: Box::new(Quit), + os_action: None, + }], + }]); + + let livekit_url = std::env::var("LIVEKIT_URL").unwrap_or("http://localhost:7880".into()); + let livekit_key = std::env::var("LIVEKIT_KEY").unwrap_or("devkey".into()); + let livekit_secret = std::env::var("LIVEKIT_SECRET").unwrap_or("secret".into()); + let height = px(800.); + let width = px(800.); + + cx.spawn(|cx| async move { + let mut windows = Vec::new(); + for i in 0..2 { + let token = token::create( + &livekit_key, + &livekit_secret, + Some(&format!("test-participant-{i}")), + VideoGrant::to_join("test-room"), + ) + .unwrap(); + + let bounds = bounds(point(width * i, px(0.0)), size(width, height)); + let window = + LivekitWindow::new(livekit_url.as_str(), token.as_str(), bounds, cx.clone()) + .await; + windows.push(window); + } + }) + .detach(); + }); +} + +fn quit(_: &Quit, cx: &mut gpui::AppContext) { + cx.quit(); +} + +struct LivekitWindow { + room: Room, + microphone_track: Option, + screen_share_track: Option, + microphone_stream: Option, + screen_share_stream: Option>, + #[cfg(not(target_os = "windows"))] + remote_participants: Vec<(ParticipantIdentity, ParticipantState)>, + _events_task: Task<()>, +} + +#[derive(Default)] +struct ParticipantState { + audio_output_stream: Option<(RemoteTrackPublication, AudioStream)>, + muted: bool, + screen_share_output_view: Option<(RemoteVideoTrack, View)>, + speaking: bool, +} + +#[cfg(not(windows))] +impl LivekitWindow { + async fn new( + url: &str, + token: &str, + bounds: Bounds, + cx: AsyncAppContext, + ) -> WindowHandle { + let (room, mut events) = Room::connect(url, token, RoomOptions::default()) + .await + .unwrap(); + + cx.update(|cx| { + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + ..Default::default() + }, + |cx| { + cx.new_view(|cx| { + let _events_task = cx.spawn(|this, mut cx| async move { + while let Some(event) = events.recv().await { + this.update(&mut cx, |this: &mut LivekitWindow, cx| { + this.handle_room_event(event, cx) + }) + .ok(); + } + }); + + Self { + room, + microphone_track: None, + microphone_stream: None, + screen_share_track: None, + screen_share_stream: None, + remote_participants: Vec::new(), + _events_task, + } + }) + }, + ) + .unwrap() + }) + .unwrap() + } + + fn handle_room_event(&mut self, event: RoomEvent, cx: &mut ViewContext) { + eprintln!("event: {event:?}"); + + match event { + RoomEvent::TrackUnpublished { + publication, + participant, + } => { + let output = self.remote_participant(participant); + let unpublish_sid = publication.sid(); + if output + .audio_output_stream + .as_ref() + .map_or(false, |(track, _)| track.sid() == unpublish_sid) + { + output.audio_output_stream.take(); + } + if output + .screen_share_output_view + .as_ref() + .map_or(false, |(track, _)| track.sid() == unpublish_sid) + { + output.screen_share_output_view.take(); + } + cx.notify(); + } + + RoomEvent::TrackSubscribed { + publication, + participant, + track, + } => { + let output = self.remote_participant(participant); + match track { + RemoteTrack::Audio(track) => { + output.audio_output_stream = Some(( + publication.clone(), + play_remote_audio_track(&track, cx.background_executor()).unwrap(), + )); + } + RemoteTrack::Video(track) => { + output.screen_share_output_view = Some(( + track.clone(), + cx.new_view(|cx| RemoteVideoTrackView::new(track, cx)), + )); + } + } + cx.notify(); + } + + RoomEvent::TrackMuted { participant, .. } => { + if let Participant::Remote(participant) = participant { + self.remote_participant(participant).muted = true; + cx.notify(); + } + } + + RoomEvent::TrackUnmuted { participant, .. } => { + if let Participant::Remote(participant) = participant { + self.remote_participant(participant).muted = false; + cx.notify(); + } + } + + RoomEvent::ActiveSpeakersChanged { speakers } => { + for (identity, output) in &mut self.remote_participants { + output.speaking = speakers.iter().any(|speaker| { + if let Participant::Remote(speaker) = speaker { + speaker.identity() == *identity + } else { + false + } + }); + } + cx.notify(); + } + + _ => {} + } + + cx.notify(); + } + + fn remote_participant(&mut self, participant: RemoteParticipant) -> &mut ParticipantState { + match self + .remote_participants + .binary_search_by_key(&&participant.identity(), |row| &row.0) + { + Ok(ix) => &mut self.remote_participants[ix].1, + Err(ix) => { + self.remote_participants + .insert(ix, (participant.identity(), ParticipantState::default())); + &mut self.remote_participants[ix].1 + } + } + } + + fn toggle_mute(&mut self, cx: &mut ViewContext) { + if let Some(track) = &self.microphone_track { + if track.is_muted() { + track.unmute(); + } else { + track.mute(); + } + cx.notify(); + } else { + let participant = self.room.local_participant(); + cx.spawn(|this, mut cx| async move { + let (track, stream) = capture_local_audio_track(cx.background_executor())?.await; + let publication = participant + .publish_track( + LocalTrack::Audio(track), + TrackPublishOptions { + source: TrackSource::Microphone, + ..Default::default() + }, + ) + .await + .unwrap(); + this.update(&mut cx, |this, cx| { + this.microphone_track = Some(publication); + this.microphone_stream = Some(stream); + cx.notify(); + }) + }) + .detach(); + } + } + + fn toggle_screen_share(&mut self, cx: &mut ViewContext) { + if let Some(track) = self.screen_share_track.take() { + self.screen_share_stream.take(); + let participant = self.room.local_participant(); + cx.background_executor() + .spawn(async move { + participant.unpublish_track(&track.sid()).await.unwrap(); + }) + .detach(); + cx.notify(); + } else { + let participant = self.room.local_participant(); + let sources = cx.screen_capture_sources(); + cx.spawn(|this, mut cx| async move { + let sources = sources.await.unwrap()?; + let source = sources.into_iter().next().unwrap(); + let (track, stream) = capture_local_video_track(&*source).await?; + let publication = participant + .publish_track( + LocalTrack::Video(track), + TrackPublishOptions { + source: TrackSource::Screenshare, + video_codec: VideoCodec::H264, + ..Default::default() + }, + ) + .await + .unwrap(); + this.update(&mut cx, |this, cx| { + this.screen_share_track = Some(publication); + this.screen_share_stream = Some(stream); + cx.notify(); + }) + }) + .detach(); + } + } + + fn toggle_remote_audio_for_participant( + &mut self, + identity: &ParticipantIdentity, + cx: &mut ViewContext, + ) -> Option<()> { + let participant = self.remote_participants.iter().find_map(|(id, state)| { + if id == identity { + Some(state) + } else { + None + } + })?; + let publication = &participant.audio_output_stream.as_ref()?.0; + publication.set_enabled(!publication.is_enabled()); + cx.notify(); + Some(()) + } +} + +#[cfg(not(windows))] +impl Render for LivekitWindow { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + fn button() -> gpui::Div { + div() + .w(px(180.0)) + .h(px(30.0)) + .px_2() + .m_2() + .bg(rgb(0x8888ff)) + } + + div() + .bg(rgb(0xffffff)) + .size_full() + .flex() + .flex_col() + .child( + div().bg(rgb(0xffd4a8)).flex().flex_row().children([ + button() + .id("toggle-mute") + .child(if let Some(track) = &self.microphone_track { + if track.is_muted() { + "Unmute" + } else { + "Mute" + } + } else { + "Publish mic" + }) + .on_click(cx.listener(|this, _, cx| this.toggle_mute(cx))), + button() + .id("toggle-screen-share") + .child(if self.screen_share_track.is_none() { + "Share screen" + } else { + "Unshare screen" + }) + .on_click(cx.listener(|this, _, cx| this.toggle_screen_share(cx))), + ]), + ) + .child( + div() + .id("remote-participants") + .overflow_y_scroll() + .flex() + .flex_col() + .flex_grow() + .children(self.remote_participants.iter().map(|(identity, state)| { + div() + .h(px(300.0)) + .flex() + .flex_col() + .m_2() + .px_2() + .bg(rgb(0x8888ff)) + .child(SharedString::from(if state.speaking { + format!("{} (speaking)", &identity.0) + } else if state.muted { + format!("{} (muted)", &identity.0) + } else { + identity.0.clone() + })) + .when_some(state.audio_output_stream.as_ref(), |el, state| { + el.child( + button() + .id(SharedString::from(identity.0.clone())) + .child(if state.0.is_enabled() { + "Deafen" + } else { + "Undeafen" + }) + .on_click(cx.listener({ + let identity = identity.clone(); + move |this, _, cx| { + this.toggle_remote_audio_for_participant( + &identity, cx, + ); + } + })), + ) + }) + .children(state.screen_share_output_view.as_ref().map(|e| e.1.clone())) + })), + ) + } +} diff --git a/crates/livekit_client/src/livekit_client.rs b/crates/livekit_client/src/livekit_client.rs new file mode 100644 index 0000000000..5031dfdb33 --- /dev/null +++ b/crates/livekit_client/src/livekit_client.rs @@ -0,0 +1,661 @@ +#![cfg_attr(target_os = "windows", allow(unused))] + +mod remote_video_track_view; +#[cfg(any(test, feature = "test-support", target_os = "windows"))] +pub mod test; + +use anyhow::{anyhow, Context as _, Result}; +use cpal::traits::{DeviceTrait, HostTrait, StreamTrait as _}; +use futures::{io, Stream, StreamExt as _}; +use gpui::{ + BackgroundExecutor, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, Task, +}; +use parking_lot::Mutex; +use std::{borrow::Cow, collections::VecDeque, future::Future, pin::Pin, sync::Arc, thread}; +use util::{debug_panic, ResultExt as _}; +#[cfg(not(target_os = "windows"))] +use webrtc::{ + audio_frame::AudioFrame, + audio_source::{native::NativeAudioSource, AudioSourceOptions, RtcAudioSource}, + audio_stream::native::NativeAudioStream, + video_frame::{VideoBuffer, VideoFrame, VideoRotation}, + video_source::{native::NativeVideoSource, RtcVideoSource, VideoResolution}, + video_stream::native::NativeVideoStream, +}; + +#[cfg(all(not(any(test, feature = "test-support")), not(target_os = "windows")))] +use livekit::track::RemoteAudioTrack; +#[cfg(all(not(any(test, feature = "test-support")), not(target_os = "windows")))] +pub use livekit::*; +#[cfg(any(test, feature = "test-support", target_os = "windows"))] +use test::track::RemoteAudioTrack; +#[cfg(any(test, feature = "test-support", target_os = "windows"))] +pub use test::*; + +pub use remote_video_track_view::{RemoteVideoTrackView, RemoteVideoTrackViewEvent}; + +pub enum AudioStream { + Input { + _thread_handle: std::sync::mpsc::Sender<()>, + _transmit_task: Task<()>, + }, + Output { + _task: Task<()>, + }, +} + +struct Dispatcher(Arc); + +#[cfg(not(target_os = "windows"))] +impl livekit::dispatcher::Dispatcher for Dispatcher { + fn dispatch(&self, runnable: livekit::dispatcher::Runnable) { + self.0.dispatch(runnable, None); + } + + fn dispatch_after( + &self, + duration: std::time::Duration, + runnable: livekit::dispatcher::Runnable, + ) { + self.0.dispatch_after(duration, runnable); + } +} + +struct HttpClientAdapter(Arc); + +fn http_2_status(status: http_client::http::StatusCode) -> http_2::StatusCode { + http_2::StatusCode::from_u16(status.as_u16()) + .expect("valid status code to status code conversion") +} + +#[cfg(not(target_os = "windows"))] +impl livekit::dispatcher::HttpClient for HttpClientAdapter { + fn get( + &self, + url: &str, + ) -> Pin> + Send>> { + let http_client = self.0.clone(); + let url = url.to_string(); + Box::pin(async move { + let response = http_client + .get(&url, http_client::AsyncBody::empty(), false) + .await + .map_err(io::Error::other)?; + Ok(livekit::dispatcher::Response { + status: http_2_status(response.status()), + body: Box::pin(response.into_body()), + }) + }) + } + + fn send_async( + &self, + request: http_2::Request>, + ) -> Pin> + Send>> { + let http_client = self.0.clone(); + let mut builder = http_client::http::Request::builder() + .method(request.method().as_str()) + .uri(request.uri().to_string()); + + for (key, value) in request.headers().iter() { + builder = builder.header(key.as_str(), value.as_bytes()); + } + + if !request.extensions().is_empty() { + debug_panic!( + "Livekit sent an HTTP request with a protocol extension that Zed doesn't support!" + ); + } + + let request = builder + .body(http_client::AsyncBody::from_bytes( + request.into_body().into(), + )) + .unwrap(); + + Box::pin(async move { + let response = http_client.send(request).await.map_err(io::Error::other)?; + Ok(livekit::dispatcher::Response { + status: http_2_status(response.status()), + body: Box::pin(response.into_body()), + }) + }) + } +} + +#[cfg(target_os = "windows")] +pub fn init( + dispatcher: Arc, + http_client: Arc, +) { +} + +#[cfg(not(target_os = "windows"))] +pub fn init( + dispatcher: Arc, + http_client: Arc, +) { + livekit::dispatcher::set_dispatcher(Dispatcher(dispatcher)); + livekit::dispatcher::set_http_client(HttpClientAdapter(http_client)); +} + +#[cfg(not(target_os = "windows"))] +pub async fn capture_local_video_track( + capture_source: &dyn ScreenCaptureSource, +) -> Result<(track::LocalVideoTrack, Box)> { + let resolution = capture_source.resolution()?; + let track_source = NativeVideoSource::new(VideoResolution { + width: resolution.width.0 as u32, + height: resolution.height.0 as u32, + }); + + let capture_stream = capture_source + .stream({ + let track_source = track_source.clone(); + Box::new(move |frame| { + if let Some(buffer) = video_frame_buffer_to_webrtc(frame) { + track_source.capture_frame(&VideoFrame { + rotation: VideoRotation::VideoRotation0, + timestamp_us: 0, + buffer, + }); + } + }) + }) + .await??; + + Ok(( + track::LocalVideoTrack::create_video_track( + "screen share", + RtcVideoSource::Native(track_source), + ), + capture_stream, + )) +} + +#[cfg(not(target_os = "windows"))] +pub fn capture_local_audio_track( + background_executor: &BackgroundExecutor, +) -> Result> { + use util::maybe; + + let (frame_tx, mut frame_rx) = futures::channel::mpsc::unbounded(); + let (thread_handle, thread_kill_rx) = std::sync::mpsc::channel::<()>(); + let sample_rate; + let channels; + + if cfg!(any(test, feature = "test-support")) { + sample_rate = 2; + channels = 1; + } else { + let (device, config) = default_device(true)?; + sample_rate = config.sample_rate().0; + channels = config.channels() as u32; + thread::spawn(move || { + maybe!({ + if let Some(name) = device.name().ok() { + log::info!("Using microphone: {}", name) + } else { + log::info!("Using microphone: "); + } + + let stream = device + .build_input_stream_raw( + &config.config(), + cpal::SampleFormat::I16, + move |data, _: &_| { + frame_tx + .unbounded_send(AudioFrame { + data: Cow::Owned(data.as_slice::().unwrap().to_vec()), + sample_rate, + num_channels: channels, + samples_per_channel: data.len() as u32 / channels, + }) + .ok(); + }, + |err| log::error!("error capturing audio track: {:?}", err), + None, + ) + .context("failed to build input stream")?; + + stream.play()?; + // Keep the thread alive and holding onto the `stream` + thread_kill_rx.recv().ok(); + anyhow::Ok(Some(())) + }) + .log_err(); + }); + } + + Ok(background_executor.spawn({ + let background_executor = background_executor.clone(); + async move { + let source = NativeAudioSource::new( + AudioSourceOptions { + echo_cancellation: true, + noise_suppression: true, + auto_gain_control: true, + }, + sample_rate, + channels, + 100, + ); + let transmit_task = background_executor.spawn({ + let source = source.clone(); + async move { + while let Some(frame) = frame_rx.next().await { + source.capture_frame(&frame).await.log_err(); + } + } + }); + + let track = track::LocalAudioTrack::create_audio_track( + "microphone", + RtcAudioSource::Native(source), + ); + + ( + track, + AudioStream::Input { + _thread_handle: thread_handle, + _transmit_task: transmit_task, + }, + ) + } + })) +} + +#[cfg(not(target_os = "windows"))] +pub fn play_remote_audio_track( + track: &RemoteAudioTrack, + background_executor: &BackgroundExecutor, +) -> Result { + let track = track.clone(); + // We track device changes in our output because Livekit has a resampler built in, + // and it's easy to create a new native audio stream when the device changes. + if cfg!(any(test, feature = "test-support")) { + Ok(AudioStream::Output { + _task: background_executor.spawn(async {}), + }) + } else { + let mut default_change_listener = DeviceChangeListener::new(false)?; + let (output_device, output_config) = default_device(false)?; + + let _task = background_executor.spawn({ + let background_executor = background_executor.clone(); + async move { + let (mut _receive_task, mut _thread) = + start_output_stream(output_config, output_device, &track, &background_executor); + + while let Some(_) = default_change_listener.next().await { + let Some((output_device, output_config)) = get_default_output().log_err() + else { + continue; + }; + + if let Ok(name) = output_device.name() { + log::info!("Using speaker: {}", name) + } else { + log::info!("Using speaker: ") + } + + (_receive_task, _thread) = start_output_stream( + output_config, + output_device, + &track, + &background_executor, + ); + } + + futures::future::pending::<()>().await; + } + }); + + Ok(AudioStream::Output { _task }) + } +} + +fn default_device(input: bool) -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> { + let device; + let config; + if input { + device = cpal::default_host() + .default_input_device() + .ok_or_else(|| anyhow!("no audio input device available"))?; + config = device + .default_input_config() + .context("failed to get default input config")?; + } else { + device = cpal::default_host() + .default_output_device() + .ok_or_else(|| anyhow!("no audio output device available"))?; + config = device + .default_output_config() + .context("failed to get default output config")?; + } + Ok((device, config)) +} + +#[cfg(not(target_os = "windows"))] +fn get_default_output() -> anyhow::Result<(cpal::Device, cpal::SupportedStreamConfig)> { + let host = cpal::default_host(); + let output_device = host + .default_output_device() + .context("failed to read default output device")?; + let output_config = output_device.default_output_config()?; + Ok((output_device, output_config)) +} + +#[cfg(not(target_os = "windows"))] +fn start_output_stream( + output_config: cpal::SupportedStreamConfig, + output_device: cpal::Device, + track: &track::RemoteAudioTrack, + background_executor: &BackgroundExecutor, +) -> (Task<()>, std::sync::mpsc::Sender<()>) { + let buffer = Arc::new(Mutex::new(VecDeque::::new())); + let sample_rate = output_config.sample_rate(); + + let mut stream = NativeAudioStream::new( + track.rtc_track(), + sample_rate.0 as i32, + output_config.channels() as i32, + ); + + let receive_task = background_executor.spawn({ + let buffer = buffer.clone(); + async move { + const MS_OF_BUFFER: u32 = 100; + const MS_IN_SEC: u32 = 1000; + while let Some(frame) = stream.next().await { + let frame_size = frame.samples_per_channel * frame.num_channels; + debug_assert!(frame.data.len() == frame_size as usize); + + let buffer_size = + ((frame.sample_rate * frame.num_channels) / MS_IN_SEC * MS_OF_BUFFER) as usize; + + let mut buffer = buffer.lock(); + let new_size = buffer.len() + frame.data.len(); + if new_size > buffer_size { + let overflow = new_size - buffer_size; + buffer.drain(0..overflow); + } + + buffer.extend(frame.data.iter()); + } + } + }); + + // The _output_stream needs to be on it's own thread because it's !Send + // and we experienced a deadlock when it's created on the main thread. + let (thread, end_on_drop_rx) = std::sync::mpsc::channel::<()>(); + thread::spawn(move || { + if cfg!(any(test, feature = "test-support")) { + // Can't play audio in tests + return; + } + + let output_stream = output_device.build_output_stream( + &output_config.config(), + { + let buffer = buffer.clone(); + move |data, _info| { + let mut buffer = buffer.lock(); + if buffer.len() < data.len() { + // Instead of partially filling a buffer, output silence. If a partial + // buffer was outputted then this could lead to a perpetual state of + // outputting partial buffers as it never gets filled enough for a full + // frame. + data.fill(0); + } else { + // SAFETY: We know that buffer has at least data.len() values in it. + // because we just checked + let mut drain = buffer.drain(..data.len()); + data.fill_with(|| unsafe { drain.next().unwrap_unchecked() }); + } + } + }, + |error| log::error!("error playing audio track: {:?}", error), + None, + ); + + let Some(output_stream) = output_stream.log_err() else { + return; + }; + + output_stream.play().log_err(); + // Block forever to keep the output stream alive + end_on_drop_rx.recv().ok(); + }); + + (receive_task, thread) +} + +#[cfg(target_os = "windows")] +pub fn play_remote_video_track( + track: &track::RemoteVideoTrack, +) -> impl Stream { + futures::stream::empty() +} + +#[cfg(not(target_os = "windows"))] +pub fn play_remote_video_track( + track: &track::RemoteVideoTrack, +) -> impl Stream { + NativeVideoStream::new(track.rtc_track()) + .filter_map(|frame| async move { video_frame_buffer_from_webrtc(frame.buffer) }) +} + +#[cfg(target_os = "macos")] +pub type RemoteVideoFrame = media::core_video::CVImageBuffer; + +#[cfg(target_os = "macos")] +fn video_frame_buffer_from_webrtc(buffer: Box) -> Option { + use core_foundation::base::TCFType as _; + use media::core_video::CVImageBuffer; + + let buffer = buffer.as_native()?; + let pixel_buffer = buffer.get_cv_pixel_buffer(); + if pixel_buffer.is_null() { + return None; + } + + unsafe { Some(CVImageBuffer::wrap_under_get_rule(pixel_buffer as _)) } +} + +#[cfg(not(target_os = "macos"))] +pub type RemoteVideoFrame = Arc; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn video_frame_buffer_from_webrtc(buffer: Box) -> Option { + use gpui::RenderImage; + use image::{Frame, RgbaImage}; + use livekit::webrtc::prelude::VideoFormatType; + use smallvec::SmallVec; + use std::alloc::{alloc, Layout}; + + let width = buffer.width(); + let height = buffer.height(); + let stride = width * 4; + let byte_len = (stride * height) as usize; + let argb_image = unsafe { + // Motivation for this unsafe code is to avoid initializing the frame data, since to_argb + // will write all bytes anyway. + let start_ptr = alloc(Layout::array::(byte_len).log_err()?); + if start_ptr.is_null() { + return None; + } + let bgra_frame_slice = std::slice::from_raw_parts_mut(start_ptr, byte_len); + buffer.to_argb( + VideoFormatType::ARGB, // For some reason, this displays correctly while RGBA (the correct format) does not + bgra_frame_slice, + stride, + width as i32, + height as i32, + ); + Vec::from_raw_parts(start_ptr, byte_len, byte_len) + }; + + Some(Arc::new(RenderImage::new(SmallVec::from_elem( + Frame::new( + RgbaImage::from_raw(width, height, argb_image) + .with_context(|| "Bug: not enough bytes allocated for image.") + .log_err()?, + ), + 1, + )))) +} + +#[cfg(target_os = "macos")] +fn video_frame_buffer_to_webrtc(frame: ScreenCaptureFrame) -> Option> { + use core_foundation::base::TCFType as _; + + let pixel_buffer = frame.0.as_concrete_TypeRef(); + std::mem::forget(frame.0); + unsafe { + Some(webrtc::video_frame::native::NativeBuffer::from_cv_pixel_buffer(pixel_buffer as _)) + } +} + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] +fn video_frame_buffer_to_webrtc(_frame: ScreenCaptureFrame) -> Option> { + None as Option> +} + +trait DeviceChangeListenerApi: Stream + Sized { + fn new(input: bool) -> Result; +} + +#[cfg(target_os = "macos")] +mod macos { + + use coreaudio::sys::{ + kAudioHardwarePropertyDefaultInputDevice, kAudioHardwarePropertyDefaultOutputDevice, + kAudioObjectPropertyElementMaster, kAudioObjectPropertyScopeGlobal, + kAudioObjectSystemObject, AudioObjectAddPropertyListener, AudioObjectID, + AudioObjectPropertyAddress, AudioObjectRemovePropertyListener, OSStatus, + }; + use futures::{channel::mpsc::UnboundedReceiver, StreamExt}; + + use crate::DeviceChangeListenerApi; + + /// Implementation from: https://github.com/zed-industries/cpal/blob/fd8bc2fd39f1f5fdee5a0690656caff9a26d9d50/src/host/coreaudio/macos/property_listener.rs#L15 + pub struct CoreAudioDefaultDeviceChangeListener { + rx: UnboundedReceiver<()>, + callback: Box, + input: bool, + } + + trait _AssertSend: Send {} + impl _AssertSend for CoreAudioDefaultDeviceChangeListener {} + + struct PropertyListenerCallbackWrapper(Box); + + unsafe extern "C" fn property_listener_handler_shim( + _: AudioObjectID, + _: u32, + _: *const AudioObjectPropertyAddress, + callback: *mut ::std::os::raw::c_void, + ) -> OSStatus { + let wrapper = callback as *mut PropertyListenerCallbackWrapper; + (*wrapper).0(); + 0 + } + + impl DeviceChangeListenerApi for CoreAudioDefaultDeviceChangeListener { + fn new(input: bool) -> gpui::Result { + let (tx, rx) = futures::channel::mpsc::unbounded(); + + let callback = Box::new(PropertyListenerCallbackWrapper(Box::new(move || { + tx.unbounded_send(()).ok(); + }))); + + unsafe { + coreaudio::Error::from_os_status(AudioObjectAddPropertyListener( + kAudioObjectSystemObject, + &AudioObjectPropertyAddress { + mSelector: if input { + kAudioHardwarePropertyDefaultInputDevice + } else { + kAudioHardwarePropertyDefaultOutputDevice + }, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }, + Some(property_listener_handler_shim), + &*callback as *const _ as *mut _, + ))?; + } + + Ok(Self { + rx, + callback, + input, + }) + } + } + + impl Drop for CoreAudioDefaultDeviceChangeListener { + fn drop(&mut self) { + unsafe { + AudioObjectRemovePropertyListener( + kAudioObjectSystemObject, + &AudioObjectPropertyAddress { + mSelector: if self.input { + kAudioHardwarePropertyDefaultInputDevice + } else { + kAudioHardwarePropertyDefaultOutputDevice + }, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMaster, + }, + Some(property_listener_handler_shim), + &*self.callback as *const _ as *mut _, + ); + } + } + } + + impl futures::Stream for CoreAudioDefaultDeviceChangeListener { + type Item = (); + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + self.rx.poll_next_unpin(cx) + } + } +} + +#[cfg(target_os = "macos")] +type DeviceChangeListener = macos::CoreAudioDefaultDeviceChangeListener; + +#[cfg(not(target_os = "macos"))] +mod noop_change_listener { + use std::task::Poll; + + use crate::DeviceChangeListenerApi; + + pub struct NoopOutputDeviceChangelistener {} + + impl DeviceChangeListenerApi for NoopOutputDeviceChangelistener { + fn new(_input: bool) -> anyhow::Result { + Ok(NoopOutputDeviceChangelistener {}) + } + } + + impl futures::Stream for NoopOutputDeviceChangelistener { + type Item = (); + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> Poll> { + Poll::Pending + } + } +} + +#[cfg(not(target_os = "macos"))] +type DeviceChangeListener = noop_change_listener::NoopOutputDeviceChangelistener; diff --git a/crates/livekit_client/src/remote_video_track_view.rs b/crates/livekit_client/src/remote_video_track_view.rs new file mode 100644 index 0000000000..d7618391d6 --- /dev/null +++ b/crates/livekit_client/src/remote_video_track_view.rs @@ -0,0 +1,99 @@ +use crate::track::RemoteVideoTrack; +use anyhow::Result; +use futures::StreamExt as _; +use gpui::{Empty, EventEmitter, IntoElement, Render, Task, View, ViewContext, VisualContext as _}; + +pub struct RemoteVideoTrackView { + track: RemoteVideoTrack, + latest_frame: Option, + #[cfg(not(target_os = "macos"))] + current_rendered_frame: Option, + #[cfg(not(target_os = "macos"))] + previous_rendered_frame: Option, + _maintain_frame: Task>, +} + +#[derive(Debug)] +pub enum RemoteVideoTrackViewEvent { + Close, +} + +impl RemoteVideoTrackView { + pub fn new(track: RemoteVideoTrack, cx: &mut ViewContext) -> Self { + cx.focus_handle(); + let frames = super::play_remote_video_track(&track); + + Self { + track, + latest_frame: None, + _maintain_frame: cx.spawn(|this, mut cx| async move { + futures::pin_mut!(frames); + while let Some(frame) = frames.next().await { + this.update(&mut cx, |this, cx| { + this.latest_frame = Some(frame); + cx.notify(); + })?; + } + this.update(&mut cx, |_this, cx| { + #[cfg(not(target_os = "macos"))] + { + use util::ResultExt as _; + if let Some(frame) = _this.previous_rendered_frame.take() { + cx.window_context().drop_image(frame).log_err(); + } + // TODO(mgsloan): This might leak the last image of the screenshare if + // render is called after the screenshare ends. + if let Some(frame) = _this.current_rendered_frame.take() { + cx.window_context().drop_image(frame).log_err(); + } + } + cx.emit(RemoteVideoTrackViewEvent::Close) + })?; + Ok(()) + }), + #[cfg(not(target_os = "macos"))] + current_rendered_frame: None, + #[cfg(not(target_os = "macos"))] + previous_rendered_frame: None, + } + } + + pub fn clone(&self, cx: &mut ViewContext) -> View { + cx.new_view(|cx| Self::new(self.track.clone(), cx)) + } +} + +impl EventEmitter for RemoteVideoTrackView {} + +impl Render for RemoteVideoTrackView { + fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { + #[cfg(target_os = "macos")] + if let Some(latest_frame) = &self.latest_frame { + use gpui::Styled as _; + return gpui::surface(latest_frame.clone()) + .size_full() + .into_any_element(); + } + + #[cfg(not(target_os = "macos"))] + if let Some(latest_frame) = &self.latest_frame { + use gpui::Styled as _; + if let Some(current_rendered_frame) = self.current_rendered_frame.take() { + if let Some(frame) = self.previous_rendered_frame.take() { + // Only drop the frame if it's not also the current frame. + if frame.id != current_rendered_frame.id { + use util::ResultExt as _; + _cx.window_context().drop_image(frame).log_err(); + } + } + self.previous_rendered_frame = Some(current_rendered_frame) + } + self.current_rendered_frame = Some(latest_frame.clone()); + return gpui::img(latest_frame.clone()) + .size_full() + .into_any_element(); + } + + Empty.into_any_element() + } +} diff --git a/crates/livekit_client/src/test.rs b/crates/livekit_client/src/test.rs new file mode 100644 index 0000000000..e67189c09c --- /dev/null +++ b/crates/livekit_client/src/test.rs @@ -0,0 +1,825 @@ +pub mod participant; +pub mod publication; +pub mod track; + +#[cfg(not(windows))] +pub mod webrtc; + +#[cfg(not(windows))] +use self::id::*; +use self::{participant::*, publication::*, track::*}; +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use collections::{btree_map::Entry as BTreeEntry, hash_map::Entry, BTreeMap, HashMap, HashSet}; +use gpui::BackgroundExecutor; +#[cfg(not(windows))] +use livekit::options::TrackPublishOptions; +use livekit_server::{proto, token}; +use parking_lot::Mutex; +use postage::{mpsc, sink::Sink}; +use std::sync::{ + atomic::{AtomicBool, Ordering::SeqCst}, + Arc, Weak, +}; + +#[cfg(not(windows))] +pub use livekit::{id, options, ConnectionState, DisconnectReason, RoomOptions}; + +static SERVERS: Mutex>> = Mutex::new(BTreeMap::new()); + +pub struct TestServer { + pub url: String, + pub api_key: String, + pub secret_key: String, + #[cfg(not(target_os = "windows"))] + rooms: Mutex>, + executor: BackgroundExecutor, +} + +#[cfg(not(target_os = "windows"))] +impl TestServer { + pub fn create( + url: String, + api_key: String, + secret_key: String, + executor: BackgroundExecutor, + ) -> Result> { + let mut servers = SERVERS.lock(); + if let BTreeEntry::Vacant(e) = servers.entry(url.clone()) { + let server = Arc::new(TestServer { + url, + api_key, + secret_key, + rooms: Default::default(), + executor, + }); + e.insert(server.clone()); + Ok(server) + } else { + Err(anyhow!("a server with url {:?} already exists", url)) + } + } + + fn get(url: &str) -> Result> { + Ok(SERVERS + .lock() + .get(url) + .ok_or_else(|| anyhow!("no server found for url"))? + .clone()) + } + + pub fn teardown(&self) -> Result<()> { + SERVERS + .lock() + .remove(&self.url) + .ok_or_else(|| anyhow!("server with url {:?} does not exist", self.url))?; + Ok(()) + } + + pub fn create_api_client(&self) -> TestApiClient { + TestApiClient { + url: self.url.clone(), + } + } + + pub async fn create_room(&self, room: String) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + if let Entry::Vacant(e) = server_rooms.entry(room.clone()) { + e.insert(Default::default()); + Ok(()) + } else { + Err(anyhow!("room {:?} already exists", room)) + } + } + + async fn delete_room(&self, room: String) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + server_rooms + .remove(&room) + .ok_or_else(|| anyhow!("room {:?} does not exist", room))?; + Ok(()) + } + + async fn join_room(&self, token: String, client_room: Room) -> Result { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + let mut server_rooms = self.rooms.lock(); + let room = (*server_rooms).entry(room_name.to_string()).or_default(); + + if let Entry::Vacant(e) = room.client_rooms.entry(identity.clone()) { + for server_track in &room.video_tracks { + let track = RemoteTrack::Video(RemoteVideoTrack { + server_track: server_track.clone(), + _room: client_room.downgrade(), + }); + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track: track.clone(), + publication: RemoteTrackPublication { + sid: server_track.sid.clone(), + room: client_room.downgrade(), + track, + }, + participant: RemoteParticipant { + room: client_room.downgrade(), + identity: server_track.publisher_id.clone(), + }, + }) + .unwrap(); + } + for server_track in &room.audio_tracks { + let track = RemoteTrack::Audio(RemoteAudioTrack { + server_track: server_track.clone(), + room: client_room.downgrade(), + }); + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track: track.clone(), + publication: RemoteTrackPublication { + sid: server_track.sid.clone(), + room: client_room.downgrade(), + track, + }, + participant: RemoteParticipant { + room: client_room.downgrade(), + identity: server_track.publisher_id.clone(), + }, + }) + .unwrap(); + } + e.insert(client_room); + Ok(identity) + } else { + Err(anyhow!( + "{:?} attempted to join room {:?} twice", + identity, + room_name + )) + } + } + + async fn leave_room(&self, token: String) -> Result<()> { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + room.client_rooms.remove(&identity).ok_or_else(|| { + anyhow!( + "{:?} attempted to leave room {:?} before joining it", + identity, + room_name + ) + })?; + Ok(()) + } + + fn remote_participants( + &self, + token: String, + ) -> Result> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let local_identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap().to_string(); + + if let Some(server_room) = self.rooms.lock().get(&room_name) { + let room = server_room + .client_rooms + .get(&local_identity) + .unwrap() + .downgrade(); + Ok(server_room + .client_rooms + .iter() + .filter(|(identity, _)| *identity != &local_identity) + .map(|(identity, _)| { + ( + identity.clone(), + RemoteParticipant { + room: room.clone(), + identity: identity.clone(), + }, + ) + }) + .collect()) + } else { + Ok(Default::default()) + } + } + + async fn remove_participant( + &self, + room_name: String, + identity: ParticipantIdentity, + ) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + room.client_rooms.remove(&identity).ok_or_else(|| { + anyhow!( + "participant {:?} did not join room {:?}", + identity, + room_name + ) + })?; + Ok(()) + } + + async fn update_participant( + &self, + room_name: String, + identity: String, + permission: proto::ParticipantPermission, + ) -> Result<()> { + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + room.participant_permissions + .insert(ParticipantIdentity(identity), permission); + Ok(()) + } + + pub async fn disconnect_client(&self, client_identity: String) { + let client_identity = ParticipantIdentity(client_identity); + + self.executor.simulate_random_delay().await; + + let mut server_rooms = self.rooms.lock(); + for room in server_rooms.values_mut() { + if let Some(room) = room.client_rooms.remove(&client_identity) { + let mut room = room.0.lock(); + room.connection_state = ConnectionState::Disconnected; + room.updates_tx + .blocking_send(RoomEvent::Disconnected { + reason: DisconnectReason::SignalClose, + }) + .ok(); + } + } + } + + async fn publish_video_track( + &self, + token: String, + _local_track: LocalVideoTrack, + ) -> Result { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + + let can_publish = room + .participant_permissions + .get(&identity) + .map(|permission| permission.can_publish) + .or(claims.video.can_publish) + .unwrap_or(true); + + if !can_publish { + return Err(anyhow!("user is not allowed to publish")); + } + + let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap(); + let server_track = Arc::new(TestServerVideoTrack { + sid: sid.clone(), + publisher_id: identity.clone(), + }); + + room.video_tracks.push(server_track.clone()); + + for (room_identity, client_room) in &room.client_rooms { + if *room_identity != identity { + let track = RemoteTrack::Video(RemoteVideoTrack { + server_track: server_track.clone(), + _room: client_room.downgrade(), + }); + let publication = RemoteTrackPublication { + sid: sid.clone(), + room: client_room.downgrade(), + track: track.clone(), + }; + let participant = RemoteParticipant { + identity: identity.clone(), + room: client_room.downgrade(), + }; + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track, + publication, + participant, + }) + .unwrap(); + } + } + + Ok(sid) + } + + async fn publish_audio_track( + &self, + token: String, + _local_track: &LocalAudioTrack, + ) -> Result { + self.executor.simulate_random_delay().await; + + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + + let can_publish = room + .participant_permissions + .get(&identity) + .map(|permission| permission.can_publish) + .or(claims.video.can_publish) + .unwrap_or(true); + + if !can_publish { + return Err(anyhow!("user is not allowed to publish")); + } + + let sid: TrackSid = format!("TR_{}", nanoid::nanoid!(17)).try_into().unwrap(); + let server_track = Arc::new(TestServerAudioTrack { + sid: sid.clone(), + publisher_id: identity.clone(), + muted: AtomicBool::new(false), + }); + + room.audio_tracks.push(server_track.clone()); + + for (room_identity, client_room) in &room.client_rooms { + if *room_identity != identity { + let track = RemoteTrack::Audio(RemoteAudioTrack { + server_track: server_track.clone(), + room: client_room.downgrade(), + }); + let publication = RemoteTrackPublication { + sid: sid.clone(), + room: client_room.downgrade(), + track: track.clone(), + }; + let participant = RemoteParticipant { + identity: identity.clone(), + room: client_room.downgrade(), + }; + client_room + .0 + .lock() + .updates_tx + .blocking_send(RoomEvent::TrackSubscribed { + track, + publication, + participant, + }) + .ok(); + } + } + + Ok(sid) + } + + async fn unpublish_track(&self, _token: String, _track: &TrackSid) -> Result<()> { + Ok(()) + } + + fn set_track_muted(&self, token: &str, track_sid: &TrackSid, muted: bool) -> Result<()> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + if let Some(track) = room + .audio_tracks + .iter_mut() + .find(|track| track.sid == *track_sid) + { + track.muted.store(muted, SeqCst); + for (id, client_room) in room.client_rooms.iter() { + if *id != identity { + let participant = Participant::Remote(RemoteParticipant { + identity: identity.clone(), + room: client_room.downgrade(), + }); + let track = RemoteTrack::Audio(RemoteAudioTrack { + server_track: track.clone(), + room: client_room.downgrade(), + }); + let publication = TrackPublication::Remote(RemoteTrackPublication { + sid: track_sid.clone(), + room: client_room.downgrade(), + track, + }); + + let event = if muted { + RoomEvent::TrackMuted { + participant, + publication, + } + } else { + RoomEvent::TrackUnmuted { + participant, + publication, + } + }; + + client_room + .0 + .lock() + .updates_tx + .blocking_send(event) + .unwrap(); + } + } + } + Ok(()) + } + + fn is_track_muted(&self, token: &str, track_sid: &TrackSid) -> Option { + let claims = livekit_server::token::validate(&token, &self.secret_key).ok()?; + let room_name = claims.video.room.unwrap(); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms.get_mut(&*room_name)?; + room.audio_tracks.iter().find_map(|track| { + if track.sid == *track_sid { + Some(track.muted.load(SeqCst)) + } else { + None + } + }) + } + + fn video_tracks(&self, token: String) -> Result> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + let client_room = room + .client_rooms + .get(&identity) + .ok_or_else(|| anyhow!("not a participant in room"))?; + Ok(room + .video_tracks + .iter() + .map(|track| RemoteVideoTrack { + server_track: track.clone(), + _room: client_room.downgrade(), + }) + .collect()) + } + + fn audio_tracks(&self, token: String) -> Result> { + let claims = livekit_server::token::validate(&token, &self.secret_key)?; + let room_name = claims.video.room.unwrap(); + let identity = ParticipantIdentity(claims.sub.unwrap().to_string()); + + let mut server_rooms = self.rooms.lock(); + let room = server_rooms + .get_mut(&*room_name) + .ok_or_else(|| anyhow!("room {} does not exist", room_name))?; + let client_room = room + .client_rooms + .get(&identity) + .ok_or_else(|| anyhow!("not a participant in room"))?; + Ok(room + .audio_tracks + .iter() + .map(|track| RemoteAudioTrack { + server_track: track.clone(), + room: client_room.downgrade(), + }) + .collect()) + } +} + +#[cfg(not(target_os = "windows"))] +#[derive(Default, Debug)] +struct TestServerRoom { + client_rooms: HashMap, + video_tracks: Vec>, + audio_tracks: Vec>, + participant_permissions: HashMap, +} + +#[cfg(not(target_os = "windows"))] +#[derive(Debug)] +struct TestServerVideoTrack { + sid: TrackSid, + publisher_id: ParticipantIdentity, + // frames_rx: async_broadcast::Receiver, +} + +#[cfg(not(target_os = "windows"))] +#[derive(Debug)] +struct TestServerAudioTrack { + sid: TrackSid, + publisher_id: ParticipantIdentity, + muted: AtomicBool, +} + +pub struct TestApiClient { + url: String, +} + +#[derive(Clone, Debug)] +#[non_exhaustive] +pub enum RoomEvent { + ParticipantConnected(RemoteParticipant), + ParticipantDisconnected(RemoteParticipant), + LocalTrackPublished { + publication: LocalTrackPublication, + track: LocalTrack, + participant: LocalParticipant, + }, + LocalTrackUnpublished { + publication: LocalTrackPublication, + participant: LocalParticipant, + }, + TrackSubscribed { + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant, + }, + TrackUnsubscribed { + track: RemoteTrack, + publication: RemoteTrackPublication, + participant: RemoteParticipant, + }, + TrackSubscriptionFailed { + participant: RemoteParticipant, + error: String, + #[cfg(not(target_os = "windows"))] + track_sid: TrackSid, + }, + TrackPublished { + publication: RemoteTrackPublication, + participant: RemoteParticipant, + }, + TrackUnpublished { + publication: RemoteTrackPublication, + participant: RemoteParticipant, + }, + TrackMuted { + participant: Participant, + publication: TrackPublication, + }, + TrackUnmuted { + participant: Participant, + publication: TrackPublication, + }, + RoomMetadataChanged { + old_metadata: String, + metadata: String, + }, + ParticipantMetadataChanged { + participant: Participant, + old_metadata: String, + metadata: String, + }, + ParticipantNameChanged { + participant: Participant, + old_name: String, + name: String, + }, + ActiveSpeakersChanged { + speakers: Vec, + }, + #[cfg(not(target_os = "windows"))] + ConnectionStateChanged(ConnectionState), + Connected { + participants_with_tracks: Vec<(RemoteParticipant, Vec)>, + }, + #[cfg(not(target_os = "windows"))] + Disconnected { + reason: DisconnectReason, + }, + Reconnecting, + Reconnected, +} + +#[cfg(not(target_os = "windows"))] +#[async_trait] +impl livekit_server::api::Client for TestApiClient { + fn url(&self) -> &str { + &self.url + } + + async fn create_room(&self, name: String) -> Result<()> { + let server = TestServer::get(&self.url)?; + server.create_room(name).await?; + Ok(()) + } + + async fn delete_room(&self, name: String) -> Result<()> { + let server = TestServer::get(&self.url)?; + server.delete_room(name).await?; + Ok(()) + } + + async fn remove_participant(&self, room: String, identity: String) -> Result<()> { + let server = TestServer::get(&self.url)?; + server + .remove_participant(room, ParticipantIdentity(identity)) + .await?; + Ok(()) + } + + async fn update_participant( + &self, + room: String, + identity: String, + permission: livekit_server::proto::ParticipantPermission, + ) -> Result<()> { + let server = TestServer::get(&self.url)?; + server + .update_participant(room, identity, permission) + .await?; + Ok(()) + } + + fn room_token(&self, room: &str, identity: &str) -> Result { + let server = TestServer::get(&self.url)?; + token::create( + &server.api_key, + &server.secret_key, + Some(identity), + token::VideoGrant::to_join(room), + ) + } + + fn guest_token(&self, room: &str, identity: &str) -> Result { + let server = TestServer::get(&self.url)?; + token::create( + &server.api_key, + &server.secret_key, + Some(identity), + token::VideoGrant::for_guest(room), + ) + } +} + +struct RoomState { + url: String, + token: String, + #[cfg(not(target_os = "windows"))] + local_identity: ParticipantIdentity, + #[cfg(not(target_os = "windows"))] + connection_state: ConnectionState, + #[cfg(not(target_os = "windows"))] + paused_audio_tracks: HashSet, + updates_tx: mpsc::Sender, +} + +#[derive(Clone, Debug)] +pub struct Room(Arc>); + +#[derive(Clone, Debug)] +pub(crate) struct WeakRoom(Weak>); + +#[cfg(not(target_os = "windows"))] +impl std::fmt::Debug for RoomState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Room") + .field("url", &self.url) + .field("token", &self.token) + .field("local_identity", &self.local_identity) + .field("connection_state", &self.connection_state) + .field("paused_audio_tracks", &self.paused_audio_tracks) + .finish() + } +} + +#[cfg(target_os = "windows")] +impl std::fmt::Debug for RoomState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Room") + .field("url", &self.url) + .field("token", &self.token) + .finish() + } +} + +#[cfg(not(target_os = "windows"))] +impl Room { + fn downgrade(&self) -> WeakRoom { + WeakRoom(Arc::downgrade(&self.0)) + } + + pub fn connection_state(&self) -> ConnectionState { + self.0.lock().connection_state + } + + pub fn local_participant(&self) -> LocalParticipant { + let identity = self.0.lock().local_identity.clone(); + LocalParticipant { + identity, + room: self.clone(), + } + } + + pub async fn connect( + url: &str, + token: &str, + _options: RoomOptions, + ) -> Result<(Self, mpsc::Receiver)> { + let server = TestServer::get(&url)?; + let (updates_tx, updates_rx) = mpsc::channel(1024); + let this = Self(Arc::new(Mutex::new(RoomState { + local_identity: ParticipantIdentity(String::new()), + url: url.to_string(), + token: token.to_string(), + connection_state: ConnectionState::Disconnected, + paused_audio_tracks: Default::default(), + updates_tx, + }))); + + let identity = server + .join_room(token.to_string(), this.clone()) + .await + .context("room join")?; + { + let mut state = this.0.lock(); + state.local_identity = identity; + state.connection_state = ConnectionState::Connected; + } + + Ok((this, updates_rx)) + } + + pub fn remote_participants(&self) -> HashMap { + self.test_server() + .remote_participants(self.0.lock().token.clone()) + .unwrap() + } + + fn test_server(&self) -> Arc { + TestServer::get(&self.0.lock().url).unwrap() + } + + fn token(&self) -> String { + self.0.lock().token.clone() + } +} + +#[cfg(not(target_os = "windows"))] +impl Drop for RoomState { + fn drop(&mut self) { + if self.connection_state == ConnectionState::Connected { + if let Ok(server) = TestServer::get(&self.url) { + let executor = server.executor.clone(); + let token = self.token.clone(); + executor + .spawn(async move { server.leave_room(token).await.ok() }) + .detach(); + } + } + } +} + +impl WeakRoom { + fn upgrade(&self) -> Option { + self.0.upgrade().map(Room) + } +} diff --git a/crates/livekit_client/src/test/participant.rs b/crates/livekit_client/src/test/participant.rs new file mode 100644 index 0000000000..8d476b1537 --- /dev/null +++ b/crates/livekit_client/src/test/participant.rs @@ -0,0 +1,111 @@ +use super::*; + +#[derive(Clone, Debug)] +pub enum Participant { + Local(LocalParticipant), + Remote(RemoteParticipant), +} + +#[derive(Clone, Debug)] +pub struct LocalParticipant { + #[cfg(not(target_os = "windows"))] + pub(super) identity: ParticipantIdentity, + pub(super) room: Room, +} + +#[derive(Clone, Debug)] +pub struct RemoteParticipant { + #[cfg(not(target_os = "windows"))] + pub(super) identity: ParticipantIdentity, + pub(super) room: WeakRoom, +} + +#[cfg(not(target_os = "windows"))] +impl Participant { + pub fn identity(&self) -> ParticipantIdentity { + match self { + Participant::Local(participant) => participant.identity.clone(), + Participant::Remote(participant) => participant.identity.clone(), + } + } +} + +#[cfg(not(target_os = "windows"))] +impl LocalParticipant { + pub async fn unpublish_track(&self, track: &TrackSid) -> Result<()> { + self.room + .test_server() + .unpublish_track(self.room.token(), track) + .await + } + + pub async fn publish_track( + &self, + track: LocalTrack, + _options: TrackPublishOptions, + ) -> Result { + let this = self.clone(); + let track = track.clone(); + let server = this.room.test_server(); + let sid = match track { + LocalTrack::Video(track) => { + server.publish_video_track(this.room.token(), track).await? + } + LocalTrack::Audio(track) => { + server + .publish_audio_track(this.room.token(), &track) + .await? + } + }; + Ok(LocalTrackPublication { + room: self.room.downgrade(), + sid, + }) + } +} + +#[cfg(not(target_os = "windows"))] +impl RemoteParticipant { + pub fn track_publications(&self) -> HashMap { + if let Some(room) = self.room.upgrade() { + let server = room.test_server(); + let audio = server + .audio_tracks(room.token()) + .unwrap() + .into_iter() + .filter(|track| track.publisher_id() == self.identity) + .map(|track| { + ( + track.sid(), + RemoteTrackPublication { + sid: track.sid(), + room: self.room.clone(), + track: RemoteTrack::Audio(track), + }, + ) + }); + let video = server + .video_tracks(room.token()) + .unwrap() + .into_iter() + .filter(|track| track.publisher_id() == self.identity) + .map(|track| { + ( + track.sid(), + RemoteTrackPublication { + sid: track.sid(), + room: self.room.clone(), + track: RemoteTrack::Video(track), + }, + ) + }); + audio.chain(video).collect() + } else { + HashMap::default() + } + } + + pub fn identity(&self) -> ParticipantIdentity { + self.identity.clone() + } +} diff --git a/crates/livekit_client/src/test/publication.rs b/crates/livekit_client/src/test/publication.rs new file mode 100644 index 0000000000..6a3dfa0a51 --- /dev/null +++ b/crates/livekit_client/src/test/publication.rs @@ -0,0 +1,116 @@ +use super::*; + +#[derive(Clone, Debug)] +pub enum TrackPublication { + Local(LocalTrackPublication), + Remote(RemoteTrackPublication), +} + +#[derive(Clone, Debug)] +pub struct LocalTrackPublication { + #[cfg(not(target_os = "windows"))] + pub(crate) sid: TrackSid, + pub(crate) room: WeakRoom, +} + +#[derive(Clone, Debug)] +pub struct RemoteTrackPublication { + #[cfg(not(target_os = "windows"))] + pub(crate) sid: TrackSid, + pub(crate) room: WeakRoom, + pub(crate) track: RemoteTrack, +} + +#[cfg(not(target_os = "windows"))] +impl TrackPublication { + pub fn sid(&self) -> TrackSid { + match self { + TrackPublication::Local(track) => track.sid(), + TrackPublication::Remote(track) => track.sid(), + } + } + + pub fn is_muted(&self) -> bool { + match self { + TrackPublication::Local(track) => track.is_muted(), + TrackPublication::Remote(track) => track.is_muted(), + } + } +} + +#[cfg(not(target_os = "windows"))] +impl LocalTrackPublication { + pub fn sid(&self) -> TrackSid { + self.sid.clone() + } + + pub fn mute(&self) { + self.set_mute(true) + } + + pub fn unmute(&self) { + self.set_mute(false) + } + + fn set_mute(&self, mute: bool) { + if let Some(room) = self.room.upgrade() { + room.test_server() + .set_track_muted(&room.token(), &self.sid, mute) + .ok(); + } + } + + pub fn is_muted(&self) -> bool { + if let Some(room) = self.room.upgrade() { + room.test_server() + .is_track_muted(&room.token(), &self.sid) + .unwrap_or(false) + } else { + false + } + } +} + +#[cfg(not(target_os = "windows"))] +impl RemoteTrackPublication { + pub fn sid(&self) -> TrackSid { + self.sid.clone() + } + + pub fn track(&self) -> Option { + Some(self.track.clone()) + } + + pub fn kind(&self) -> TrackKind { + self.track.kind() + } + + pub fn is_muted(&self) -> bool { + if let Some(room) = self.room.upgrade() { + room.test_server() + .is_track_muted(&room.token(), &self.sid) + .unwrap_or(false) + } else { + false + } + } + + pub fn is_enabled(&self) -> bool { + if let Some(room) = self.room.upgrade() { + !room.0.lock().paused_audio_tracks.contains(&self.sid) + } else { + false + } + } + + pub fn set_enabled(&self, enabled: bool) { + if let Some(room) = self.room.upgrade() { + let paused_audio_tracks = &mut room.0.lock().paused_audio_tracks; + if enabled { + paused_audio_tracks.remove(&self.sid); + } else { + paused_audio_tracks.insert(self.sid.clone()); + } + } + } +} diff --git a/crates/livekit_client/src/test/track.rs b/crates/livekit_client/src/test/track.rs new file mode 100644 index 0000000000..302177a10a --- /dev/null +++ b/crates/livekit_client/src/test/track.rs @@ -0,0 +1,201 @@ +use super::*; +#[cfg(not(windows))] +use webrtc::{audio_source::RtcAudioSource, video_source::RtcVideoSource}; + +#[cfg(not(windows))] +pub use livekit::track::{TrackKind, TrackSource}; + +#[derive(Clone, Debug)] +pub enum LocalTrack { + Audio(LocalAudioTrack), + Video(LocalVideoTrack), +} + +#[derive(Clone, Debug)] +pub enum RemoteTrack { + Audio(RemoteAudioTrack), + Video(RemoteVideoTrack), +} + +#[derive(Clone, Debug)] +pub struct LocalVideoTrack {} + +#[derive(Clone, Debug)] +pub struct LocalAudioTrack {} + +#[derive(Clone, Debug)] +pub struct RemoteVideoTrack { + #[cfg(not(target_os = "windows"))] + pub(super) server_track: Arc, + pub(super) _room: WeakRoom, +} + +#[derive(Clone, Debug)] +pub struct RemoteAudioTrack { + #[cfg(not(target_os = "windows"))] + pub(super) server_track: Arc, + pub(super) room: WeakRoom, +} + +pub enum RtcTrack { + Audio(RtcAudioTrack), + Video(RtcVideoTrack), +} + +pub struct RtcAudioTrack { + #[cfg(not(target_os = "windows"))] + pub(super) server_track: Arc, + pub(super) room: WeakRoom, +} + +pub struct RtcVideoTrack { + #[cfg(not(target_os = "windows"))] + pub(super) _server_track: Arc, +} + +#[cfg(not(target_os = "windows"))] +impl RemoteTrack { + pub fn sid(&self) -> TrackSid { + match self { + RemoteTrack::Audio(track) => track.sid(), + RemoteTrack::Video(track) => track.sid(), + } + } + + pub fn kind(&self) -> TrackKind { + match self { + RemoteTrack::Audio(_) => TrackKind::Audio, + RemoteTrack::Video(_) => TrackKind::Video, + } + } + + pub fn publisher_id(&self) -> ParticipantIdentity { + match self { + RemoteTrack::Audio(track) => track.publisher_id(), + RemoteTrack::Video(track) => track.publisher_id(), + } + } + + pub fn rtc_track(&self) -> RtcTrack { + match self { + RemoteTrack::Audio(track) => RtcTrack::Audio(track.rtc_track()), + RemoteTrack::Video(track) => RtcTrack::Video(track.rtc_track()), + } + } +} + +#[cfg(not(windows))] +impl LocalVideoTrack { + pub fn create_video_track(_name: &str, _source: RtcVideoSource) -> Self { + Self {} + } +} + +#[cfg(not(windows))] +impl LocalAudioTrack { + pub fn create_audio_track(_name: &str, _source: RtcAudioSource) -> Self { + Self {} + } +} + +#[cfg(not(target_os = "windows"))] +impl RemoteAudioTrack { + pub fn sid(&self) -> TrackSid { + self.server_track.sid.clone() + } + + pub fn publisher_id(&self) -> ParticipantIdentity { + self.server_track.publisher_id.clone() + } + + pub fn start(&self) { + if let Some(room) = self.room.upgrade() { + room.0 + .lock() + .paused_audio_tracks + .remove(&self.server_track.sid); + } + } + + pub fn stop(&self) { + if let Some(room) = self.room.upgrade() { + room.0 + .lock() + .paused_audio_tracks + .insert(self.server_track.sid.clone()); + } + } + + pub fn rtc_track(&self) -> RtcAudioTrack { + RtcAudioTrack { + server_track: self.server_track.clone(), + room: self.room.clone(), + } + } +} + +#[cfg(not(target_os = "windows"))] +impl RemoteVideoTrack { + pub fn sid(&self) -> TrackSid { + self.server_track.sid.clone() + } + + pub fn publisher_id(&self) -> ParticipantIdentity { + self.server_track.publisher_id.clone() + } + + pub fn rtc_track(&self) -> RtcVideoTrack { + RtcVideoTrack { + _server_track: self.server_track.clone(), + } + } +} + +#[cfg(not(target_os = "windows"))] +impl RtcTrack { + pub fn enabled(&self) -> bool { + match self { + RtcTrack::Audio(track) => track.enabled(), + RtcTrack::Video(track) => track.enabled(), + } + } + + pub fn set_enabled(&self, enabled: bool) { + match self { + RtcTrack::Audio(track) => track.set_enabled(enabled), + RtcTrack::Video(_) => {} + } + } +} + +#[cfg(not(target_os = "windows"))] +impl RtcAudioTrack { + pub fn set_enabled(&self, enabled: bool) { + if let Some(room) = self.room.upgrade() { + let paused_audio_tracks = &mut room.0.lock().paused_audio_tracks; + if enabled { + paused_audio_tracks.remove(&self.server_track.sid); + } else { + paused_audio_tracks.insert(self.server_track.sid.clone()); + } + } + } + + pub fn enabled(&self) -> bool { + if let Some(room) = self.room.upgrade() { + !room + .0 + .lock() + .paused_audio_tracks + .contains(&self.server_track.sid) + } else { + false + } + } +} + +impl RtcVideoTrack { + pub fn enabled(&self) -> bool { + true + } +} diff --git a/crates/livekit_client/src/test/webrtc.rs b/crates/livekit_client/src/test/webrtc.rs new file mode 100644 index 0000000000..6ac06e0484 --- /dev/null +++ b/crates/livekit_client/src/test/webrtc.rs @@ -0,0 +1,136 @@ +use super::track::{RtcAudioTrack, RtcVideoTrack}; +use futures::Stream; +use livekit::webrtc as real; +use std::{ + pin::Pin, + task::{Context, Poll}, +}; + +pub mod video_stream { + use super::*; + + pub mod native { + use super::*; + use real::video_frame::BoxVideoFrame; + + pub struct NativeVideoStream { + pub track: RtcVideoTrack, + } + + impl NativeVideoStream { + pub fn new(track: RtcVideoTrack) -> Self { + Self { track } + } + } + + impl Stream for NativeVideoStream { + type Item = BoxVideoFrame; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context) -> Poll> { + Poll::Pending + } + } + } +} + +pub mod audio_stream { + use super::*; + + pub mod native { + use super::*; + use real::audio_frame::AudioFrame; + + pub struct NativeAudioStream { + pub track: RtcAudioTrack, + } + + impl NativeAudioStream { + pub fn new(track: RtcAudioTrack, _sample_rate: i32, _num_channels: i32) -> Self { + Self { track } + } + } + + impl Stream for NativeAudioStream { + type Item = AudioFrame<'static>; + + fn poll_next(self: Pin<&mut Self>, _cx: &mut Context) -> Poll> { + Poll::Pending + } + } + } +} + +pub mod audio_source { + use super::*; + + pub use real::audio_source::AudioSourceOptions; + + pub mod native { + use std::sync::Arc; + + use super::*; + use real::{audio_frame::AudioFrame, RtcError}; + + #[derive(Clone)] + pub struct NativeAudioSource { + pub options: Arc, + pub sample_rate: u32, + pub num_channels: u32, + } + + impl NativeAudioSource { + pub fn new( + options: AudioSourceOptions, + sample_rate: u32, + num_channels: u32, + _queue_size_ms: u32, + ) -> Self { + Self { + options: Arc::new(options), + sample_rate, + num_channels, + } + } + + pub async fn capture_frame(&self, _frame: &AudioFrame<'_>) -> Result<(), RtcError> { + Ok(()) + } + } + } + + pub enum RtcAudioSource { + Native(native::NativeAudioSource), + } +} + +pub use livekit::webrtc::audio_frame; +pub use livekit::webrtc::video_frame; + +pub mod video_source { + use super::*; + pub use real::video_source::VideoResolution; + + pub struct RTCVideoSource; + + pub mod native { + use super::*; + use real::video_frame::{VideoBuffer, VideoFrame}; + + #[derive(Clone)] + pub struct NativeVideoSource { + pub resolution: VideoResolution, + } + + impl NativeVideoSource { + pub fn new(resolution: super::VideoResolution) -> Self { + Self { resolution } + } + + pub fn capture_frame>(&self, _frame: &VideoFrame) {} + } + } + + pub enum RtcVideoSource { + Native(native::NativeVideoSource), + } +} diff --git a/crates/livekit_client_macos/.cargo/config.toml b/crates/livekit_client_macos/.cargo/config.toml new file mode 100644 index 0000000000..77f7c9dd6c --- /dev/null +++ b/crates/livekit_client_macos/.cargo/config.toml @@ -0,0 +1,2 @@ +[livekit_client_test] +rustflags = ["-C", "link-args=-ObjC"] diff --git a/crates/live_kit_client/Cargo.toml b/crates/livekit_client_macos/Cargo.toml similarity index 87% rename from crates/live_kit_client/Cargo.toml rename to crates/livekit_client_macos/Cargo.toml index e23c63453e..6a5a8d0ea2 100644 --- a/crates/live_kit_client/Cargo.toml +++ b/crates/livekit_client_macos/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "live_kit_client" +name = "livekit_client_macos" version = "0.1.0" edition = "2021" description = "Bindings to LiveKit Swift client SDK" @@ -10,7 +10,7 @@ license = "GPL-3.0-or-later" workspace = true [lib] -path = "src/live_kit_client.rs" +path = "src/livekit_client.rs" doctest = false [[example]] @@ -22,7 +22,7 @@ test-support = [ "async-trait", "collections/test-support", "gpui/test-support", - "live_kit_server", + "livekit_server", "nanoid", ] @@ -33,7 +33,7 @@ async-trait = { workspace = true, optional = true } collections = { workspace = true, optional = true } futures.workspace = true gpui = { workspace = true, optional = true } -live_kit_server = { workspace = true, optional = true } +livekit_server = { workspace = true, optional = true } log.workspace = true media.workspace = true nanoid = { workspace = true, optional = true} @@ -47,14 +47,14 @@ core-foundation.workspace = true async-trait = { workspace = true } collections = { workspace = true } gpui = { workspace = true } -live_kit_server.workspace = true +livekit_server.workspace = true nanoid.workspace = true [dev-dependencies] async-trait.workspace = true collections = { workspace = true, features = ["test-support"] } gpui = { workspace = true, features = ["test-support"] } -live_kit_server.workspace = true +livekit_server.workspace = true nanoid.workspace = true sha2.workspace = true simplelog.workspace = true diff --git a/crates/livekit_client_macos/LICENSE-GPL b/crates/livekit_client_macos/LICENSE-GPL new file mode 120000 index 0000000000..89e542f750 --- /dev/null +++ b/crates/livekit_client_macos/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/live_kit_client/LiveKitBridge/Package.resolved b/crates/livekit_client_macos/LiveKitBridge/Package.resolved similarity index 100% rename from crates/live_kit_client/LiveKitBridge/Package.resolved rename to crates/livekit_client_macos/LiveKitBridge/Package.resolved diff --git a/crates/live_kit_client/LiveKitBridge/Package.swift b/crates/livekit_client_macos/LiveKitBridge/Package.swift similarity index 100% rename from crates/live_kit_client/LiveKitBridge/Package.swift rename to crates/livekit_client_macos/LiveKitBridge/Package.swift diff --git a/crates/live_kit_client/LiveKitBridge/README.md b/crates/livekit_client_macos/LiveKitBridge/README.md similarity index 100% rename from crates/live_kit_client/LiveKitBridge/README.md rename to crates/livekit_client_macos/LiveKitBridge/README.md diff --git a/crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift b/crates/livekit_client_macos/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift similarity index 100% rename from crates/live_kit_client/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift rename to crates/livekit_client_macos/LiveKitBridge/Sources/LiveKitBridge/LiveKitBridge.swift diff --git a/crates/live_kit_client/build.rs b/crates/livekit_client_macos/build.rs similarity index 100% rename from crates/live_kit_client/build.rs rename to crates/livekit_client_macos/build.rs diff --git a/crates/live_kit_client/examples/test_app.rs b/crates/livekit_client_macos/examples/test_app.rs similarity index 97% rename from crates/live_kit_client/examples/test_app.rs rename to crates/livekit_client_macos/examples/test_app.rs index de8be97e86..c6ae2cc478 100644 --- a/crates/live_kit_client/examples/test_app.rs +++ b/crates/livekit_client_macos/examples/test_app.rs @@ -2,12 +2,12 @@ use std::time::Duration; use futures::StreamExt; use gpui::{actions, KeyBinding, Menu, MenuItem}; -use live_kit_client::{LocalAudioTrack, LocalVideoTrack, Room, RoomUpdate}; -use live_kit_server::token::{self, VideoGrant}; +use livekit_client_macos::{LocalAudioTrack, LocalVideoTrack, Room, RoomUpdate}; +use livekit_server::token::{self, VideoGrant}; use log::LevelFilter; use simplelog::SimpleLogger; -actions!(live_kit_client, [Quit]); +actions!(livekit_client_macos, [Quit]); fn main() { SimpleLogger::init(LevelFilter::Info, Default::default()).expect("could not initialize logger"); diff --git a/crates/live_kit_client/src/live_kit_client.rs b/crates/livekit_client_macos/src/livekit_client.rs similarity index 100% rename from crates/live_kit_client/src/live_kit_client.rs rename to crates/livekit_client_macos/src/livekit_client.rs diff --git a/crates/live_kit_client/src/prod.rs b/crates/livekit_client_macos/src/prod.rs similarity index 100% rename from crates/live_kit_client/src/prod.rs rename to crates/livekit_client_macos/src/prod.rs diff --git a/crates/live_kit_client/src/test.rs b/crates/livekit_client_macos/src/test.rs similarity index 97% rename from crates/live_kit_client/src/test.rs rename to crates/livekit_client_macos/src/test.rs index 2c26c88f72..6db24174ff 100644 --- a/crates/live_kit_client/src/test.rs +++ b/crates/livekit_client_macos/src/test.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use collections::{btree_map::Entry as BTreeEntry, hash_map::Entry, BTreeMap, HashMap, HashSet}; use futures::Stream; use gpui::{BackgroundExecutor, SurfaceSource}; -use live_kit_server::{proto, token}; +use livekit_server::{proto, token}; use parking_lot::Mutex; use postage::watch; @@ -102,7 +102,7 @@ impl TestServer { #[cfg(any(test, feature = "test-support"))] self.executor.simulate_random_delay().await; - let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let claims = livekit_server::token::validate(&token, &self.secret_key)?; let identity = claims.sub.unwrap().to_string(); let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -150,7 +150,7 @@ impl TestServer { // todo(linux): Remove this once the cross-platform LiveKit implementation is merged #[cfg(any(test, feature = "test-support"))] self.executor.simulate_random_delay().await; - let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let claims = livekit_server::token::validate(&token, &self.secret_key)?; let identity = claims.sub.unwrap().to_string(); let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -224,7 +224,7 @@ impl TestServer { // todo(linux): Remove this once the cross-platform LiveKit implementation is merged #[cfg(any(test, feature = "test-support"))] self.executor.simulate_random_delay().await; - let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let claims = livekit_server::token::validate(&token, &self.secret_key)?; let identity = claims.sub.unwrap().to_string(); let room_name = claims.video.room.unwrap(); @@ -280,7 +280,7 @@ impl TestServer { #[cfg(any(test, feature = "test-support"))] self.executor.simulate_random_delay().await; - let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let claims = livekit_server::token::validate(&token, &self.secret_key)?; let identity = claims.sub.unwrap().to_string(); let room_name = claims.video.room.unwrap(); @@ -332,7 +332,7 @@ impl TestServer { } fn set_track_muted(&self, token: &str, track_sid: &str, muted: bool) -> Result<()> { - let claims = live_kit_server::token::validate(token, &self.secret_key)?; + let claims = livekit_server::token::validate(token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); let identity = claims.sub.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -363,7 +363,7 @@ impl TestServer { } fn is_track_muted(&self, token: &str, track_sid: &str) -> Option { - let claims = live_kit_server::token::validate(token, &self.secret_key).ok()?; + let claims = livekit_server::token::validate(token, &self.secret_key).ok()?; let room_name = claims.video.room.unwrap(); let mut server_rooms = self.rooms.lock(); @@ -378,7 +378,7 @@ impl TestServer { } fn video_tracks(&self, token: String) -> Result>> { - let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let claims = livekit_server::token::validate(&token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); let identity = claims.sub.unwrap(); @@ -401,7 +401,7 @@ impl TestServer { } fn audio_tracks(&self, token: String) -> Result>> { - let claims = live_kit_server::token::validate(&token, &self.secret_key)?; + let claims = livekit_server::token::validate(&token, &self.secret_key)?; let room_name = claims.video.room.unwrap(); let identity = claims.sub.unwrap(); @@ -455,7 +455,7 @@ pub struct TestApiClient { } #[async_trait] -impl live_kit_server::api::Client for TestApiClient { +impl livekit_server::api::Client for TestApiClient { fn url(&self) -> &str { &self.url } @@ -482,7 +482,7 @@ impl live_kit_server::api::Client for TestApiClient { &self, room: String, identity: String, - permission: live_kit_server::proto::ParticipantPermission, + permission: livekit_server::proto::ParticipantPermission, ) -> Result<()> { let server = TestServer::get(&self.url)?; server diff --git a/crates/live_kit_server/Cargo.toml b/crates/livekit_server/Cargo.toml similarity index 90% rename from crates/live_kit_server/Cargo.toml rename to crates/livekit_server/Cargo.toml index 4b4b5e13da..c76cb1580c 100644 --- a/crates/live_kit_server/Cargo.toml +++ b/crates/livekit_server/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "live_kit_server" +name = "livekit_server" version = "0.1.0" edition = "2021" description = "SDK for the LiveKit server API" @@ -10,7 +10,7 @@ license = "AGPL-3.0-or-later" workspace = true [lib] -path = "src/live_kit_server.rs" +path = "src/livekit_server.rs" doctest = false [dependencies] diff --git a/crates/live_kit_server/LICENSE-AGPL b/crates/livekit_server/LICENSE-AGPL similarity index 100% rename from crates/live_kit_server/LICENSE-AGPL rename to crates/livekit_server/LICENSE-AGPL diff --git a/crates/live_kit_server/build.rs b/crates/livekit_server/build.rs similarity index 100% rename from crates/live_kit_server/build.rs rename to crates/livekit_server/build.rs diff --git a/crates/live_kit_server/src/api.rs b/crates/livekit_server/src/api.rs similarity index 100% rename from crates/live_kit_server/src/api.rs rename to crates/livekit_server/src/api.rs diff --git a/crates/live_kit_server/src/live_kit_server.rs b/crates/livekit_server/src/livekit_server.rs similarity index 100% rename from crates/live_kit_server/src/live_kit_server.rs rename to crates/livekit_server/src/livekit_server.rs diff --git a/crates/live_kit_server/src/proto.rs b/crates/livekit_server/src/proto.rs similarity index 100% rename from crates/live_kit_server/src/proto.rs rename to crates/livekit_server/src/proto.rs diff --git a/crates/live_kit_server/src/token.rs b/crates/livekit_server/src/token.rs similarity index 100% rename from crates/live_kit_server/src/token.rs rename to crates/livekit_server/src/token.rs diff --git a/crates/live_kit_server/vendored/protocol/README.md b/crates/livekit_server/vendored/protocol/README.md similarity index 100% rename from crates/live_kit_server/vendored/protocol/README.md rename to crates/livekit_server/vendored/protocol/README.md diff --git a/crates/live_kit_server/vendored/protocol/livekit_analytics.proto b/crates/livekit_server/vendored/protocol/livekit_analytics.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_analytics.proto rename to crates/livekit_server/vendored/protocol/livekit_analytics.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_egress.proto b/crates/livekit_server/vendored/protocol/livekit_egress.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_egress.proto rename to crates/livekit_server/vendored/protocol/livekit_egress.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_ingress.proto b/crates/livekit_server/vendored/protocol/livekit_ingress.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_ingress.proto rename to crates/livekit_server/vendored/protocol/livekit_ingress.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_internal.proto b/crates/livekit_server/vendored/protocol/livekit_internal.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_internal.proto rename to crates/livekit_server/vendored/protocol/livekit_internal.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_models.proto b/crates/livekit_server/vendored/protocol/livekit_models.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_models.proto rename to crates/livekit_server/vendored/protocol/livekit_models.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_room.proto b/crates/livekit_server/vendored/protocol/livekit_room.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_room.proto rename to crates/livekit_server/vendored/protocol/livekit_room.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_rpc_internal.proto b/crates/livekit_server/vendored/protocol/livekit_rpc_internal.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_rpc_internal.proto rename to crates/livekit_server/vendored/protocol/livekit_rpc_internal.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_rtc.proto b/crates/livekit_server/vendored/protocol/livekit_rtc.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_rtc.proto rename to crates/livekit_server/vendored/protocol/livekit_rtc.proto diff --git a/crates/live_kit_server/vendored/protocol/livekit_webhook.proto b/crates/livekit_server/vendored/protocol/livekit_webhook.proto similarity index 100% rename from crates/live_kit_server/vendored/protocol/livekit_webhook.proto rename to crates/livekit_server/vendored/protocol/livekit_webhook.proto diff --git a/crates/media/Cargo.toml b/crates/media/Cargo.toml index 92940d1c52..70478eeb75 100644 --- a/crates/media/Cargo.toml +++ b/crates/media/Cargo.toml @@ -17,6 +17,7 @@ anyhow.workspace = true [target.'cfg(target_os = "macos")'.dependencies] core-foundation.workspace = true +ctor.workspace = true foreign-types = "0.5" metal = "0.29" objc = "0.2" diff --git a/crates/media/src/media.rs b/crates/media/src/media.rs index 8757249c31..3f55475589 100644 --- a/crates/media/src/media.rs +++ b/crates/media/src/media.rs @@ -253,11 +253,14 @@ pub mod core_media { } } - pub fn image_buffer(&self) -> CVImageBuffer { + pub fn image_buffer(&self) -> Option { unsafe { - CVImageBuffer::wrap_under_get_rule(CMSampleBufferGetImageBuffer( - self.as_concrete_TypeRef(), - )) + let ptr = CMSampleBufferGetImageBuffer(self.as_concrete_TypeRef()); + if ptr.is_null() { + None + } else { + Some(CVImageBuffer::wrap_under_get_rule(ptr)) + } } } diff --git a/crates/proto/proto/zed.proto b/crates/proto/proto/zed.proto index f0d8f27131..e177dc2763 100644 --- a/crates/proto/proto/zed.proto +++ b/crates/proto/proto/zed.proto @@ -432,7 +432,7 @@ message Room { repeated Participant participants = 2; repeated PendingParticipant pending_participants = 3; repeated Follower followers = 4; - string live_kit_room = 5; + string livekit_room = 5; } message Participant { diff --git a/crates/title_bar/src/collab.rs b/crates/title_bar/src/collab.rs index 649dfb34f7..7d977bb458 100644 --- a/crates/title_bar/src/collab.rs +++ b/crates/title_bar/src/collab.rs @@ -294,9 +294,9 @@ impl TitleBar { let is_muted = room.is_muted(); let is_deafened = room.is_deafened().unwrap_or(false); let is_screen_sharing = room.is_screen_sharing(); - let can_use_microphone = room.can_use_microphone(); + let can_use_microphone = room.can_use_microphone(cx); let can_share_projects = room.can_share_projects(); - let platform_supported = match self.platform_style { + let screen_sharing_supported = match self.platform_style { PlatformStyle::Mac => true, PlatformStyle::Linux | PlatformStyle::Windows => false, }; @@ -363,9 +363,7 @@ impl TitleBar { ) .tooltip(move |cx| { Tooltip::text( - if !platform_supported { - "Cannot share microphone" - } else if is_muted { + if is_muted { "Unmute microphone" } else { "Mute microphone" @@ -375,56 +373,45 @@ impl TitleBar { }) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) - .selected(platform_supported && is_muted) - .disabled(!platform_supported) + .selected(is_muted) .selected_style(ButtonStyle::Tinted(TintColor::Negative)) .on_click(move |_, cx| { toggle_mute(&Default::default(), cx); }) .into_any_element(), ); + + children.push( + IconButton::new( + "mute-sound", + if is_deafened { + ui::IconName::AudioOff + } else { + ui::IconName::AudioOn + }, + ) + .style(ButtonStyle::Subtle) + .selected_style(ButtonStyle::Tinted(TintColor::Negative)) + .icon_size(IconSize::Small) + .selected(is_deafened) + .tooltip(move |cx| { + Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx) + }) + .on_click(move |_, cx| toggle_deafen(&Default::default(), cx)) + .into_any_element(), + ); } - children.push( - IconButton::new( - "mute-sound", - if is_deafened { - ui::IconName::AudioOff - } else { - ui::IconName::AudioOn - }, - ) - .style(ButtonStyle::Subtle) - .selected_style(ButtonStyle::Tinted(TintColor::Negative)) - .icon_size(IconSize::Small) - .selected(is_deafened) - .disabled(!platform_supported) - .tooltip(move |cx| { - if !platform_supported { - Tooltip::text("Cannot share microphone", cx) - } else if can_use_microphone { - Tooltip::with_meta("Deafen Audio", None, "Mic will be muted", cx) - } else { - Tooltip::text("Deafen Audio", cx) - } - }) - .on_click(move |_, cx| toggle_deafen(&Default::default(), cx)) - .into_any_element(), - ); - - if can_share_projects { + if screen_sharing_supported { children.push( IconButton::new("screen-share", ui::IconName::Screen) .style(ButtonStyle::Subtle) .icon_size(IconSize::Small) .selected(is_screen_sharing) - .disabled(!platform_supported) .selected_style(ButtonStyle::Tinted(TintColor::Accent)) .tooltip(move |cx| { Tooltip::text( - if !platform_supported { - "Cannot share screen" - } else if is_screen_sharing { + if is_screen_sharing { "Stop Sharing Screen" } else { "Share Screen" diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 3b17ed8dab..be2dfb06bd 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -24,6 +24,8 @@ test-support = [ "gpui/test-support", "fs/test-support", ] +livekit-macos = ["call/livekit-macos"] +livekit-cross-platform = ["call/livekit-cross-platform"] [dependencies] anyhow.workspace = true diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index 59df859488..f7a1ccf760 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -1,126 +1,282 @@ -use crate::{ - item::{Item, ItemEvent}, - ItemNavHistory, WorkspaceId, -}; -use anyhow::Result; -use call::participant::{Frame, RemoteVideoTrack}; -use client::{proto::PeerId, User}; -use futures::StreamExt; -use gpui::{ - div, surface, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, - ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, - WindowContext, -}; -use std::sync::{Arc, Weak}; -use ui::{prelude::*, Icon, IconName}; +#[cfg(any( + all( + target_os = "macos", + feature = "livekit-cross-platform", + not(feature = "livekit-macos"), + ), + all(not(target_os = "macos"), feature = "livekit-cross-platform"), +))] +mod cross_platform { + use crate::{ + item::{Item, ItemEvent}, + ItemNavHistory, WorkspaceId, + }; + use call::{RemoteVideoTrack, RemoteVideoTrackView}; + use client::{proto::PeerId, User}; + use gpui::{ + div, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + ParentElement, Render, SharedString, Styled, View, ViewContext, VisualContext, + WindowContext, + }; + use std::sync::Arc; + use ui::{prelude::*, Icon, IconName}; -pub enum Event { - Close, -} + pub enum Event { + Close, + } -pub struct SharedScreen { - track: Weak, - frame: Option, - pub peer_id: PeerId, - user: Arc, - nav_history: Option, - _maintain_frame: Task>, - focus: FocusHandle, -} - -impl SharedScreen { - pub fn new( - track: &Arc, - peer_id: PeerId, + pub struct SharedScreen { + pub peer_id: PeerId, user: Arc, - cx: &mut ViewContext, - ) -> Self { - cx.focus_handle(); - let mut frames = track.frames(); - Self { - track: Arc::downgrade(track), - frame: None, - peer_id, - user, - nav_history: Default::default(), - _maintain_frame: cx.spawn(|this, mut cx| async move { - while let Some(frame) = frames.next().await { - this.update(&mut cx, |this, cx| { - this.frame = Some(frame); - cx.notify(); - })?; - } - this.update(&mut cx, |_, cx| cx.emit(Event::Close))?; - Ok(()) - }), - focus: cx.focus_handle(), + nav_history: Option, + view: View, + focus: FocusHandle, + } + + impl SharedScreen { + pub fn new( + track: RemoteVideoTrack, + peer_id: PeerId, + user: Arc, + cx: &mut ViewContext, + ) -> Self { + let view = cx.new_view(|cx| RemoteVideoTrackView::new(track.clone(), cx)); + cx.subscribe(&view, |_, _, ev, cx| match ev { + call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), + }) + .detach(); + Self { + view, + peer_id, + user, + nav_history: Default::default(), + focus: cx.focus_handle(), + } + } + } + + impl EventEmitter for SharedScreen {} + + impl FocusableView for SharedScreen { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus.clone() + } + } + impl Render for SharedScreen { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .key_context("SharedScreen") + .size_full() + .child(self.view.clone()) + } + } + + impl Item for SharedScreen { + type Event = Event; + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, cx); + } + } + + fn tab_icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::new(IconName::Screen)) + } + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { + self.nav_history = Some(history); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> { + Some(cx.new_view(|cx| Self { + view: self.view.update(cx, |view, cx| view.clone(cx)), + peer_id: self.peer_id, + user: self.user.clone(), + nav_history: Default::default(), + focus: cx.focus_handle(), + })) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } } } } -impl EventEmitter for SharedScreen {} +#[cfg(any( + all( + target_os = "macos", + feature = "livekit-cross-platform", + not(feature = "livekit-macos"), + ), + all(not(target_os = "macos"), feature = "livekit-cross-platform"), +))] +pub use cross_platform::*; -impl FocusableView for SharedScreen { - fn focus_handle(&self, _: &AppContext) -> FocusHandle { - self.focus.clone() - } -} -impl Render for SharedScreen { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .bg(cx.theme().colors().editor_background) - .track_focus(&self.focus) - .key_context("SharedScreen") - .size_full() - .children( - self.frame - .as_ref() - .map(|frame| surface(frame.image()).size_full()), - ) - } -} +#[cfg(any( + all(target_os = "macos", feature = "livekit-macos"), + all( + not(target_os = "macos"), + feature = "livekit-macos", + not(feature = "livekit-cross-platform") + ) +))] +mod macos { + use crate::{ + item::{Item, ItemEvent}, + ItemNavHistory, WorkspaceId, + }; + use anyhow::Result; + use call::participant::{Frame, RemoteVideoTrack}; + use client::{proto::PeerId, User}; + use futures::StreamExt; + use gpui::{ + div, surface, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, + WindowContext, + }; + use std::sync::{Arc, Weak}; + use ui::{prelude::*, Icon, IconName}; -impl Item for SharedScreen { - type Event = Event; - - fn tab_tooltip_text(&self, _: &AppContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) + pub enum Event { + Close, } - fn deactivated(&mut self, cx: &mut ViewContext) { - if let Some(nav_history) = self.nav_history.as_mut() { - nav_history.push::<()>(None, cx); + pub struct SharedScreen { + track: Weak, + frame: Option, + pub peer_id: PeerId, + user: Arc, + nav_history: Option, + _maintain_frame: Task>, + focus: FocusHandle, + } + + impl SharedScreen { + pub fn new( + track: Arc, + peer_id: PeerId, + user: Arc, + cx: &mut ViewContext, + ) -> Self { + cx.focus_handle(); + let mut frames = track.frames(); + Self { + track: Arc::downgrade(&track), + frame: None, + peer_id, + user, + nav_history: Default::default(), + _maintain_frame: cx.spawn(|this, mut cx| async move { + while let Some(frame) = frames.next().await { + this.update(&mut cx, |this, cx| { + this.frame = Some(frame); + cx.notify(); + })?; + } + this.update(&mut cx, |_, cx| cx.emit(Event::Close))?; + Ok(()) + }), + focus: cx.focus_handle(), + } } } - fn tab_icon(&self, _cx: &WindowContext) -> Option { - Some(Icon::new(IconName::Screen)) + impl EventEmitter for SharedScreen {} + + impl FocusableView for SharedScreen { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus.clone() + } + } + impl Render for SharedScreen { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .key_context("SharedScreen") + .size_full() + .children( + self.frame + .as_ref() + .map(|frame| surface(frame.image()).size_full()), + ) + } } - fn tab_content_text(&self, _cx: &WindowContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } + impl Item for SharedScreen { + type Event = Event; - fn telemetry_event_text(&self) -> Option<&'static str> { - None - } + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } - fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { - self.nav_history = Some(history); - } + fn deactivated(&mut self, cx: &mut ViewContext) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, cx); + } + } - fn clone_on_split( - &self, - _workspace_id: Option, - cx: &mut ViewContext, - ) -> Option> { - let track = self.track.upgrade()?; - Some(cx.new_view(|cx| Self::new(&track, self.peer_id, self.user.clone(), cx))) - } + fn tab_icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::new(IconName::Screen)) + } - fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { - match event { - Event::Close => f(ItemEvent::CloseItem), + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { + self.nav_history = Some(history); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> { + let track = self.track.upgrade()?; + Some(cx.new_view(|cx| Self::new(track, self.peer_id, self.user.clone(), cx))) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } } } } + +#[cfg(any( + all(target_os = "macos", feature = "livekit-macos"), + all( + not(target_os = "macos"), + feature = "livekit-macos", + not(feature = "livekit-cross-platform") + ) +))] +pub use macos::*; diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0d47cec441..ec6e9015d4 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3944,6 +3944,17 @@ impl Workspace { None } + #[cfg(target_os = "windows")] + fn shared_screen_for_peer( + &self, + _peer_id: PeerId, + _pane: &View, + _cx: &mut WindowContext, + ) -> Option> { + None + } + + #[cfg(not(target_os = "windows"))] fn shared_screen_for_peer( &self, peer_id: PeerId, @@ -3962,7 +3973,7 @@ impl Workspace { } } - Some(cx.new_view(|cx| SharedScreen::new(&track, peer_id, user.clone(), cx))) + Some(cx.new_view(|cx| SharedScreen::new(track, peer_id, user.clone(), cx))) } pub fn on_window_activation_changed(&mut self, cx: &mut ViewContext) { diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 74dd2601ad..6b26a01f27 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -126,6 +126,12 @@ welcome.workspace = true workspace.workspace = true zed_actions.workspace = true +[target.'cfg(target_os = "macos")'.dependencies] +workspace = { workspace = true, features = ["livekit-macos"] } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +workspace = { workspace = true, features = ["livekit-cross-platform"] } + [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/crates/zed/build.rs b/crates/zed/build.rs index 3013773f91..bf2a0d99fe 100644 --- a/crates/zed/build.rs +++ b/crates/zed/build.rs @@ -13,6 +13,14 @@ fn main() { println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path"); } + if std::env::var("ZED_BUNDLE").ok().as_deref() == Some("true") { + // Find WebRTC.framework in the Frameworks folder when running as part of an application bundle. + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path/../Frameworks"); + } else { + // Find WebRTC.framework as a sibling of the executable when running outside of an application bundle. + println!("cargo:rustc-link-arg=-Wl,-rpath,@executable_path"); + } + // Weakly link ReplayKit to ensure Zed can be used on macOS 10.15+. println!("cargo:rustc-link-arg=-Wl,-weak_framework,ReplayKit"); diff --git a/script/bundle-linux b/script/bundle-linux index 98b49ae4da..c05037b6cc 100755 --- a/script/bundle-linux +++ b/script/bundle-linux @@ -92,7 +92,7 @@ cp "${target_dir}/${target_triple}/release/cli" "${zed_dir}/bin/zed" find_libs() { ldd ${target_dir}/${target_triple}/release/zed |\ cut -d' ' -f3 |\ - grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\)' + grep -v '\<\(libstdc++.so\|libc.so\|libgcc_s.so\|libm.so\|libpthread.so\|libdl.so\|libasound.so\)' } mkdir -p "${zed_dir}/lib" diff --git a/typos.toml b/typos.toml index 0682d0a3a9..dc724dd50d 100644 --- a/typos.toml +++ b/typos.toml @@ -22,7 +22,7 @@ extend-exclude = [ # Stripe IDs are flagged as typos. "crates/collab/src/db/tests/processed_stripe_event_tests.rs", # Not our typos. - "crates/live_kit_server/", + "crates/livekit_server/", # Vim makes heavy use of partial typing tables. "crates/vim/", # Editor and file finder rely on partial typing and custom in-string syntax. From 28650b2fac0bd8c782e1b962784ed7edafa3909e Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:23:08 -0800 Subject: [PATCH 177/215] Update Rust crate blake3 to v1.5.5 (#21554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [blake3](https://redirect.github.com/BLAKE3-team/BLAKE3) | workspace.dependencies | patch | `1.5.4` -> `1.5.5` | --- ### Release Notes
BLAKE3-team/BLAKE3 (blake3) ### [`v1.5.5`](https://redirect.github.com/BLAKE3-team/BLAKE3/releases/tag/1.5.5) [Compare Source](https://redirect.github.com/BLAKE3-team/BLAKE3/compare/1.5.4...1.5.5) version 1.5.5 Changes since 1.5.4: - `b3sum --check` now supports checkfiles with Windows-style newlines. `b3sum` still emits Unix-style newlines, even on Windows, but sometimes text editors or version control tools will swap them. - The "digest" feature (deleted in v1.5.2) has been added back to the `blake3` crate. This is for backwards compatibility only, and it's insta-deprecated. All callers should prefer the "traits-preview" feature.
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4d040c581c..ac1e4edabe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1838,9 +1838,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.5.4" +version = "1.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82033247fd8e890df8f740e407ad4d038debb9eb1f40533fffb32e7d17dc6f7" +checksum = "b8ee0c1824c4dea5b5f81736aff91bae041d2c07ee1192bec91054e10e3e601e" dependencies = [ "arrayref", "arrayvec", From aff17322f31fa3c1037bf8ffcdc6b6aa470027dc Mon Sep 17 00:00:00 2001 From: Nick Breaton Date: Thu, 5 Dec 2024 18:23:37 -0500 Subject: [PATCH 178/215] Detect wider variety of usernames for SSH-based remotes (#21508) Closes #21507 Release Notes: - Fixed detection of git remotes when using SSH and username is not "git". --- Cargo.lock | 1 + crates/git/Cargo.toml | 1 + crates/git/src/remote.rs | 16 ++++++++++++++-- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ac1e4edabe..3167456349 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5131,6 +5131,7 @@ dependencies = [ "log", "parking_lot", "pretty_assertions", + "regex", "rope", "serde", "serde_json", diff --git a/crates/git/Cargo.toml b/crates/git/Cargo.toml index c0f43e08a8..d31538353e 100644 --- a/crates/git/Cargo.toml +++ b/crates/git/Cargo.toml @@ -21,6 +21,7 @@ gpui.workspace = true http_client.workspace = true log.workspace = true parking_lot.workspace = true +regex.workspace = true rope.workspace = true serde.workspace = true smol.workspace = true diff --git a/crates/git/src/remote.rs b/crates/git/src/remote.rs index 430836fcf3..e9814afc51 100644 --- a/crates/git/src/remote.rs +++ b/crates/git/src/remote.rs @@ -1,17 +1,23 @@ +use std::sync::LazyLock; + use derive_more::Deref; +use regex::Regex; use url::Url; /// The URL to a Git remote. #[derive(Debug, PartialEq, Eq, Clone, Deref)] pub struct RemoteUrl(Url); +static USERNAME_REGEX: LazyLock = + LazyLock::new(|| Regex::new(r"^[0-9a-zA-Z\-_]+@").expect("Failed to create USERNAME_REGEX")); + impl std::str::FromStr for RemoteUrl { type Err = url::ParseError; fn from_str(input: &str) -> Result { - if input.starts_with("git@") { + if USERNAME_REGEX.is_match(input) { // Rewrite remote URLs like `git@github.com:user/repo.git` to `ssh://git@github.com/user/repo.git` - let ssh_url = input.replacen(':', "/", 1).replace("git@", "ssh://git@"); + let ssh_url = format!("ssh://{}", input.replacen(':', "/", 1)); Ok(RemoteUrl(Url::parse(&ssh_url)?)) } else { Ok(RemoteUrl(Url::parse(input)?)) @@ -40,6 +46,12 @@ mod tests { "github.com", "/octocat/zed.git", ), + ( + "org-000000@github.com:octocat/zed.git", + "ssh", + "github.com", + "/octocat/zed.git", + ), ( "ssh://git@github.com/octocat/zed.git", "ssh", From cf4e847c62e435a8c2daa8d750386609c9b67461 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Thu, 5 Dec 2024 16:32:17 -0800 Subject: [PATCH 179/215] Implement session-global include_warnings in the diagnostic item (#21618) Release Notes: - Make the include warnings toggle in the diagnostic tab global for a zed session. --- crates/diagnostics/src/diagnostics.rs | 38 ++++++++++++++++++--- crates/diagnostics/src/diagnostics_tests.rs | 32 ++++++++++++++--- 2 files changed, 61 insertions(+), 9 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 48a92d906e..9f02033237 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -16,8 +16,8 @@ use editor::{ }; use gpui::{ actions, div, svg, AnyElement, AnyView, AppContext, Context, EventEmitter, FocusHandle, - FocusableView, HighlightStyle, InteractiveElement, IntoElement, Model, ParentElement, Render, - SharedString, Styled, StyledText, Subscription, Task, View, ViewContext, VisualContext, + FocusableView, Global, HighlightStyle, InteractiveElement, IntoElement, Model, ParentElement, + Render, SharedString, Styled, StyledText, Subscription, Task, View, ViewContext, VisualContext, WeakView, WindowContext, }; use language::{ @@ -46,6 +46,9 @@ use workspace::{ actions!(diagnostics, [Deploy, ToggleWarnings]); +struct IncludeWarnings(bool); +impl Global for IncludeWarnings {} + pub fn init(cx: &mut AppContext) { ProjectDiagnosticsSettings::register(cx); cx.observe_new_views(ProjectDiagnosticsEditor::register) @@ -117,6 +120,7 @@ impl ProjectDiagnosticsEditor { fn new_with_context( context: u32, + include_warnings: bool, project_handle: Model, workspace: WeakView, cx: &mut ViewContext, @@ -186,19 +190,24 @@ impl ProjectDiagnosticsEditor { } }) .detach(); + cx.observe_global::(|this, cx| { + this.include_warnings = cx.global::().0; + this.update_all_excerpts(cx); + }) + .detach(); let project = project_handle.read(cx); let mut this = Self { project: project_handle.clone(), context, summary: project.diagnostic_summary(false, cx), + include_warnings, workspace, excerpts, focus_handle, editor, path_states: Default::default(), paths_to_update: Default::default(), - include_warnings: ProjectDiagnosticsSettings::get_global(cx).include_warnings, update_excerpts_task: None, _subscription: project_event_subscription, }; @@ -243,11 +252,13 @@ impl ProjectDiagnosticsEditor { fn new( project_handle: Model, + include_warnings: bool, workspace: WeakView, cx: &mut ViewContext, ) -> Self { Self::new_with_context( editor::DEFAULT_MULTIBUFFER_CONTEXT, + include_warnings, project_handle, workspace, cx, @@ -259,8 +270,19 @@ impl ProjectDiagnosticsEditor { workspace.activate_item(&existing, true, true, cx); } else { let workspace_handle = cx.view().downgrade(); + + let include_warnings = match cx.try_global::() { + Some(include_warnings) => include_warnings.0, + None => ProjectDiagnosticsSettings::get_global(cx).include_warnings, + }; + let diagnostics = cx.new_view(|cx| { - ProjectDiagnosticsEditor::new(workspace.project().clone(), workspace_handle, cx) + ProjectDiagnosticsEditor::new( + workspace.project().clone(), + include_warnings, + workspace_handle, + cx, + ) }); workspace.add_item_to_active_pane(Box::new(diagnostics), None, true, cx); } @@ -268,6 +290,7 @@ impl ProjectDiagnosticsEditor { fn toggle_warnings(&mut self, _: &ToggleWarnings, cx: &mut ViewContext) { self.include_warnings = !self.include_warnings; + cx.set_global(IncludeWarnings(self.include_warnings)); self.update_all_excerpts(cx); cx.notify(); } @@ -740,7 +763,12 @@ impl Item for ProjectDiagnosticsEditor { Self: Sized, { Some(cx.new_view(|cx| { - ProjectDiagnosticsEditor::new(self.project.clone(), self.workspace.clone(), cx) + ProjectDiagnosticsEditor::new( + self.project.clone(), + self.include_warnings, + self.workspace.clone(), + cx, + ) })) } diff --git a/crates/diagnostics/src/diagnostics_tests.rs b/crates/diagnostics/src/diagnostics_tests.rs index ff305e45a2..6ee1a90511 100644 --- a/crates/diagnostics/src/diagnostics_tests.rs +++ b/crates/diagnostics/src/diagnostics_tests.rs @@ -151,7 +151,13 @@ async fn test_diagnostics(cx: &mut TestAppContext) { // Open the project diagnostics view while there are already diagnostics. let view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); let editor = view.update(cx, |view, _| view.editor.clone()); @@ -459,7 +465,13 @@ async fn test_diagnostics_multiple_servers(cx: &mut TestAppContext) { let workspace = window.root(cx).unwrap(); let view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); let editor = view.update(cx, |view, _| view.editor.clone()); @@ -720,7 +732,13 @@ async fn test_random_diagnostics(cx: &mut TestAppContext, mut rng: StdRng) { let workspace = window.root(cx).unwrap(); let mutated_view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); workspace.update(cx, |workspace, cx| { @@ -816,7 +834,13 @@ async fn test_random_diagnostics(cx: &mut TestAppContext, mut rng: StdRng) { log::info!("constructing reference diagnostics view"); let reference_view = window.build_view(cx, |cx| { - ProjectDiagnosticsEditor::new_with_context(1, project.clone(), workspace.downgrade(), cx) + ProjectDiagnosticsEditor::new_with_context( + 1, + true, + project.clone(), + workspace.downgrade(), + cx, + ) }); cx.run_until_parked(); From f6b5e1734ea6f92781aca8f4a17afad9ab709cc1 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 5 Dec 2024 16:52:14 -0800 Subject: [PATCH 180/215] Get unstaged changes when excerpts of new buffers are added (#21619) This fixes an error on nightly, introduced in https://github.com/zed-industries/zed/pull/21258, where diffs were not shown for buffers that were added to multi-buffers after construction. Release Notes: - N/A --- crates/editor/src/editor.rs | 54 ++++++++++++++++++++++--------------- 1 file changed, 32 insertions(+), 22 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bb4a2788a7..132a5e04fb 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2023,28 +2023,7 @@ impl Editor { let mut code_action_providers = Vec::new(); if let Some(project) = project.clone() { - let mut tasks = Vec::new(); - buffer.update(cx, |multibuffer, cx| { - project.update(cx, |project, cx| { - multibuffer.for_each_buffer(|buffer| { - tasks.push(project.open_unstaged_changes(buffer.clone(), cx)) - }); - }); - }); - - cx.spawn(|this, mut cx| async move { - let change_sets = futures::future::join_all(tasks).await; - this.update(&mut cx, |this, cx| { - for change_set in change_sets { - if let Some(change_set) = change_set.log_err() { - this.diff_map.add_change_set(change_set, cx); - } - } - }) - .ok(); - }) - .detach(); - + get_unstaged_changes_for_buffers(&project, buffer.read(cx).all_buffers(), cx); code_action_providers.push(Arc::new(project) as Arc<_>); } @@ -12646,6 +12625,12 @@ impl Editor { excerpts, } => { self.tasks_update_task = Some(self.refresh_runnables(cx)); + let buffer_id = buffer.read(cx).remote_id(); + if !self.diff_map.diff_bases.contains_key(&buffer_id) { + if let Some(project) = &self.project { + get_unstaged_changes_for_buffers(project, [buffer.clone()], cx); + } + } cx.emit(EditorEvent::ExcerptsAdded { buffer: buffer.clone(), predecessor: *predecessor, @@ -13342,6 +13327,31 @@ impl Editor { } } +fn get_unstaged_changes_for_buffers( + project: &Model, + buffers: impl IntoIterator>, + cx: &mut ViewContext, +) { + let mut tasks = Vec::new(); + project.update(cx, |project, cx| { + for buffer in buffers { + tasks.push(project.open_unstaged_changes(buffer.clone(), cx)) + } + }); + cx.spawn(|this, mut cx| async move { + let change_sets = futures::future::join_all(tasks).await; + this.update(&mut cx, |this, cx| { + for change_set in change_sets { + if let Some(change_set) = change_set.log_err() { + this.diff_map.add_change_set(change_set, cx); + } + } + }) + .ok(); + }) + .detach(); +} + fn char_len_with_expanded_tabs(offset: usize, text: &str, tab_size: NonZeroU32) -> usize { let tab_size = tab_size.get() as usize; let mut width = offset; From 7e40addb5fd33a460978fe6f5ffe1118a15b1571 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 6 Dec 2024 10:01:57 +0100 Subject: [PATCH 181/215] markdown preview: Fix panic when parsing empty image tag (#21616) Closes #21534 While investigating the panic, I noticed that the code was pretty complicated and decided to refactor parts of it to reduce redundancy. Release Notes: - Fixed an issue where the app could crash when opening the markdown preview with a malformed image tag --- .../markdown_preview/src/markdown_elements.rs | 118 ++-------- .../markdown_preview/src/markdown_parser.rs | 103 ++++----- .../src/markdown_preview_view.rs | 17 +- .../markdown_preview/src/markdown_renderer.rs | 206 +++--------------- 4 files changed, 104 insertions(+), 340 deletions(-) diff --git a/crates/markdown_preview/src/markdown_elements.rs b/crates/markdown_preview/src/markdown_elements.rs index ff43fab08a..256ce6ee4a 100644 --- a/crates/markdown_preview/src/markdown_elements.rs +++ b/crates/markdown_preview/src/markdown_elements.rs @@ -18,22 +18,19 @@ pub enum ParsedMarkdownElement { } impl ParsedMarkdownElement { - pub fn source_range(&self) -> Range { - match self { + pub fn source_range(&self) -> Option> { + Some(match self { Self::Heading(heading) => heading.source_range.clone(), Self::ListItem(list_item) => list_item.source_range.clone(), Self::Table(table) => table.source_range.clone(), Self::BlockQuote(block_quote) => block_quote.source_range.clone(), Self::CodeBlock(code_block) => code_block.source_range.clone(), - Self::Paragraph(text) => match &text[0] { + Self::Paragraph(text) => match text.get(0)? { MarkdownParagraphChunk::Text(t) => t.source_range.clone(), - MarkdownParagraphChunk::Image(image) => match image { - Image::Web { source_range, .. } => source_range.clone(), - Image::Path { source_range, .. } => source_range.clone(), - }, + MarkdownParagraphChunk::Image(image) => image.source_range.clone(), }, Self::HorizontalRule(range) => range.clone(), - } + }) } pub fn is_list_item(&self) -> bool { @@ -289,104 +286,27 @@ impl Display for Link { /// A Markdown Image #[derive(Debug, Clone)] #[cfg_attr(test, derive(PartialEq))] -pub enum Image { - Web { - source_range: Range, - /// The URL of the Image. - url: String, - /// Link URL if exists. - link: Option, - /// alt text if it exists - alt_text: Option, - }, - /// Image path on the filesystem. - Path { - source_range: Range, - /// The path as provided in the Markdown document. - display_path: PathBuf, - /// The absolute path to the item. - path: PathBuf, - /// Link URL if exists. - link: Option, - /// alt text if it exists - alt_text: Option, - }, +pub struct Image { + pub link: Link, + pub source_range: Range, + pub alt_text: Option, } impl Image { pub fn identify( + text: String, source_range: Range, file_location_directory: Option, - text: String, - link: Option, - ) -> Option { - if text.starts_with("http") { - return Some(Image::Web { - source_range, - url: text, - link, - alt_text: None, - }); - } - let path = PathBuf::from(&text); - if path.is_absolute() { - return Some(Image::Path { - source_range, - display_path: path.clone(), - path, - link, - alt_text: None, - }); - } - if let Some(file_location_directory) = file_location_directory { - let display_path = path; - let path = file_location_directory.join(text); - return Some(Image::Path { - source_range, - display_path, - path, - link, - alt_text: None, - }); - } - None + ) -> Option { + let link = Link::identify(file_location_directory, text)?; + Some(Self { + source_range, + link, + alt_text: None, + }) } - pub fn with_alt_text(&self, alt_text: ParsedMarkdownText) -> Self { - match self { - Image::Web { - ref source_range, - ref url, - ref link, - .. - } => Image::Web { - source_range: source_range.clone(), - url: url.clone(), - link: link.clone(), - alt_text: Some(alt_text), - }, - Image::Path { - ref source_range, - ref display_path, - ref path, - ref link, - .. - } => Image::Path { - source_range: source_range.clone(), - display_path: display_path.clone(), - path: path.clone(), - link: link.clone(), - alt_text: Some(alt_text), - }, - } - } -} - -impl Display for Image { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Image::Web { url, .. } => write!(f, "{}", url), - Image::Path { display_path, .. } => write!(f, "{}", display_path.display()), - } + pub fn set_alt_text(&mut self, alt_text: SharedString) { + self.alt_text = Some(alt_text); } } diff --git a/crates/markdown_preview/src/markdown_parser.rs b/crates/markdown_preview/src/markdown_parser.rs index 211cca2494..f433edf8b3 100644 --- a/crates/markdown_preview/src/markdown_parser.rs +++ b/crates/markdown_preview/src/markdown_parser.rs @@ -214,7 +214,7 @@ impl<'a> MarkdownParser<'a> { break; } - let (current, _source_range) = self.current().unwrap(); + let (current, _) = self.current().unwrap(); let prev_len = text.len(); match current { Event::SoftBreak => { @@ -314,56 +314,29 @@ impl<'a> MarkdownParser<'a> { )); } } - if let Some(mut image) = image.clone() { - let is_valid_image = match image.clone() { - Image::Path { display_path, .. } => { - gpui::ImageSource::try_from(display_path).is_ok() - } - Image::Web { url, .. } => gpui::ImageSource::try_from(url).is_ok(), - }; - if is_valid_image { - text.truncate(text.len() - t.len()); - if !t.is_empty() { - let alt_text = ParsedMarkdownText { - source_range: source_range.clone(), - contents: t.to_string(), - highlights: highlights.clone(), - region_ranges: region_ranges.clone(), - regions: regions.clone(), - }; - image = image.with_alt_text(alt_text); - } else { - let alt_text = ParsedMarkdownText { - source_range: source_range.clone(), - contents: "img".to_string(), - highlights: highlights.clone(), - region_ranges: region_ranges.clone(), - regions: regions.clone(), - }; - image = image.with_alt_text(alt_text); - } - if !text.is_empty() { - let parsed_regions = - MarkdownParagraphChunk::Text(ParsedMarkdownText { - source_range: source_range.clone(), - contents: text.clone(), - highlights: highlights.clone(), - region_ranges: region_ranges.clone(), - regions: regions.clone(), - }); - text = String::new(); - highlights = vec![]; - region_ranges = vec![]; - regions = vec![]; - markdown_text_like.push(parsed_regions); - } - - let parsed_image = MarkdownParagraphChunk::Image(image.clone()); - markdown_text_like.push(parsed_image); - style = MarkdownHighlightStyle::default(); + if let Some(image) = image.as_mut() { + text.truncate(text.len() - t.len()); + image.set_alt_text(t.to_string().into()); + if !text.is_empty() { + let parsed_regions = MarkdownParagraphChunk::Text(ParsedMarkdownText { + source_range: source_range.clone(), + contents: text.clone(), + highlights: highlights.clone(), + region_ranges: region_ranges.clone(), + regions: regions.clone(), + }); + text = String::new(); + highlights = vec![]; + region_ranges = vec![]; + regions = vec![]; + markdown_text_like.push(parsed_regions); } + + let parsed_image = MarkdownParagraphChunk::Image(image.clone()); + markdown_text_like.push(parsed_image); + style = MarkdownHighlightStyle::default(); style.underline = true; - }; + } } Event::Code(t) => { text.push_str(t.as_ref()); @@ -395,10 +368,9 @@ impl<'a> MarkdownParser<'a> { } Tag::Image { dest_url, .. } => { image = Image::identify( + dest_url.to_string(), source_range.clone(), self.file_location_directory.clone(), - dest_url.to_string(), - link.clone(), ); } _ => { @@ -926,6 +898,18 @@ mod tests { ); } + #[gpui::test] + async fn test_empty_image() { + let parsed = parse("![]()").await; + + let paragraph = if let ParsedMarkdownElement::Paragraph(text) = &parsed.children[0] { + text + } else { + panic!("Expected a paragraph"); + }; + assert_eq!(paragraph.len(), 0); + } + #[gpui::test] async fn test_image_links_detection() { let parsed = parse("![test](https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png)").await; @@ -937,19 +921,12 @@ mod tests { }; assert_eq!( paragraph[0], - MarkdownParagraphChunk::Image(Image::Web { + MarkdownParagraphChunk::Image(Image { source_range: 0..111, - url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), - link: None, - alt_text: Some( - ParsedMarkdownText { - source_range: 0..111, - contents: "test".to_string(), - highlights: vec![], - region_ranges: vec![], - regions: vec![], - }, - ), + link: Link::Web { + url: "https://blog.logrocket.com/wp-content/uploads/2024/04/exploring-zed-open-source-code-editor-rust-2.png".to_string(), + }, + alt_text: Some("test".into()), },) ); } diff --git a/crates/markdown_preview/src/markdown_preview_view.rs b/crates/markdown_preview/src/markdown_preview_view.rs index 07fbd94b29..8d9c7e4145 100644 --- a/crates/markdown_preview/src/markdown_preview_view.rs +++ b/crates/markdown_preview/src/markdown_preview_view.rs @@ -192,11 +192,16 @@ impl MarkdownPreviewView { .group("markdown-block") .on_click(cx.listener(move |this, event: &ClickEvent, cx| { if event.down.click_count == 2 { - if let Some(block) = - this.contents.as_ref().and_then(|c| c.children.get(ix)) + if let Some(source_range) = this + .contents + .as_ref() + .and_then(|c| c.children.get(ix)) + .and_then(|block| block.source_range()) { - let start = block.source_range().start; - this.move_cursor_to_block(cx, start..start); + this.move_cursor_to_block( + cx, + source_range.start..source_range.start, + ); } } })) @@ -410,7 +415,9 @@ impl MarkdownPreviewView { let mut last_end = 0; if let Some(content) = &self.contents { for (i, block) in content.children.iter().enumerate() { - let Range { start, end } = block.source_range(); + let Some(Range { start, end }) = block.source_range() else { + continue; + }; // Check if the cursor is between the last block and the current block if last_end <= cursor && cursor < start { diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 39bcd546df..7a13077194 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -1,8 +1,8 @@ use crate::markdown_elements::{ - HeadingLevel, Image, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, + HeadingLevel, Link, MarkdownParagraph, MarkdownParagraphChunk, ParsedMarkdown, ParsedMarkdownBlockQuote, ParsedMarkdownCodeBlock, ParsedMarkdownElement, ParsedMarkdownHeading, ParsedMarkdownListItem, ParsedMarkdownListItemType, ParsedMarkdownTable, - ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, ParsedMarkdownText, + ParsedMarkdownTableAlignment, ParsedMarkdownTableRow, }; use gpui::{ div, img, px, rems, AbsoluteLength, AnyElement, ClipboardItem, DefiniteLength, Div, Element, @@ -13,7 +13,6 @@ use gpui::{ use settings::Settings; use std::{ ops::{Mul, Range}, - path::Path, sync::Arc, vec, }; @@ -505,103 +504,41 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) } MarkdownParagraphChunk::Image(image) => { - let (link, source_range, image_source, alt_text) = match image { - Image::Web { - link, - source_range, - url, - alt_text, - } => ( - link, - source_range, - Resource::Uri(url.clone().into()), - alt_text, - ), - Image::Path { - link, - source_range, - path, - alt_text, - .. - } => { - let image_path = Path::new(path.to_str().unwrap()); - ( - link, - source_range, - Resource::Path(Arc::from(image_path)), - alt_text, - ) - } + let image_resource = match image.link.clone() { + Link::Web { url } => Resource::Uri(url.into()), + Link::Path { path, .. } => Resource::Path(Arc::from(path)), }; - let element_id = cx.next_id(source_range); + let element_id = cx.next_id(&image.source_range); - match link { - None => { - let fallback_workspace = workspace_clone.clone(); - let fallback_syntax_theme = syntax_theme.clone(); - let fallback_text_style = text_style.clone(); - let fallback_alt_text = alt_text.clone(); - let element_id_new = element_id.clone(); - let element = div() - .child(img(ImageSource::Resource(image_source)).with_fallback({ - move || { - fallback_text( - fallback_alt_text.clone().unwrap(), - element_id.clone(), - &fallback_syntax_theme, - code_span_bg_color, - fallback_workspace.clone(), - &fallback_text_style, - ) + let image_element = div() + .id(element_id) + .child(img(ImageSource::Resource(image_resource)).with_fallback({ + let alt_text = image.alt_text.clone(); + { + move || div().children(alt_text.clone()).into_any_element() + } + })) + .tooltip({ + let link = image.link.clone(); + move |cx| LinkPreview::new(&link.to_string(), cx) + }) + .on_click({ + let workspace = workspace_clone.clone(); + let link = image.link.clone(); + move |_event, window_cx| match &link { + Link::Web { url } => window_cx.open_url(url), + Link::Path { path, .. } => { + if let Some(workspace) = &workspace { + _ = workspace.update(window_cx, |workspace, cx| { + workspace.open_abs_path(path.clone(), false, cx).detach(); + }); } - })) - .id(element_id_new) - .into_any(); - any_element.push(element); - } - Some(link) => { - let link_click = link.clone(); - let link_tooltip = link.clone(); - let fallback_workspace = workspace_clone.clone(); - let fallback_syntax_theme = syntax_theme.clone(); - let fallback_text_style = text_style.clone(); - let fallback_alt_text = alt_text.clone(); - let element_id_new = element_id.clone(); - let image_element = div() - .child(img(ImageSource::Resource(image_source)).with_fallback({ - move || { - fallback_text( - fallback_alt_text.clone().unwrap(), - element_id.clone(), - &fallback_syntax_theme, - code_span_bg_color, - fallback_workspace.clone(), - &fallback_text_style, - ) - } - })) - .id(element_id_new) - .tooltip(move |cx| LinkPreview::new(&link_tooltip.to_string(), cx)) - .on_click({ - let workspace = workspace_clone.clone(); - move |_event, window_cx| match &link_click { - Link::Web { url } => window_cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(window_cx, |workspace, cx| { - workspace - .open_abs_path(path.clone(), false, cx) - .detach(); - }); - } - } - } - }) - .into_any(); - any_element.push(image_element); - } - } + } + } + }) + .into_any(); + any_element.push(image_element); } } } @@ -613,80 +550,3 @@ fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { let rule = div().w_full().h(px(2.)).bg(cx.border_color); div().pt_3().pb_3().child(rule).into_any() } - -fn fallback_text( - parsed: ParsedMarkdownText, - source_range: ElementId, - syntax_theme: &theme::SyntaxTheme, - code_span_bg_color: Hsla, - workspace: Option>, - text_style: &TextStyle, -) -> AnyElement { - let element_id = source_range; - - let highlights = gpui::combine_highlights( - parsed.highlights.iter().filter_map(|(range, highlight)| { - let highlight = highlight.to_highlight_style(syntax_theme)?; - Some((range.clone(), highlight)) - }), - parsed - .regions - .iter() - .zip(&parsed.region_ranges) - .filter_map(|(region, range)| { - if region.code { - Some(( - range.clone(), - HighlightStyle { - background_color: Some(code_span_bg_color), - ..Default::default() - }, - )) - } else { - None - } - }), - ); - let mut links = Vec::new(); - let mut link_ranges = Vec::new(); - for (range, region) in parsed.region_ranges.iter().zip(&parsed.regions) { - if let Some(link) = region.link.clone() { - links.push(link); - link_ranges.push(range.clone()); - } - } - let element = div() - .child( - InteractiveText::new( - element_id, - StyledText::new(parsed.contents.clone()).with_highlights(text_style, highlights), - ) - .tooltip({ - let links = links.clone(); - let link_ranges = link_ranges.clone(); - move |idx, cx| { - for (ix, range) in link_ranges.iter().enumerate() { - if range.contains(&idx) { - return Some(LinkPreview::new(&links[ix].to_string(), cx)); - } - } - None - } - }) - .on_click( - link_ranges, - move |clicked_range_ix, window_cx| match &links[clicked_range_ix] { - Link::Web { url } => window_cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(window_cx, |workspace, cx| { - workspace.open_abs_path(path.clone(), false, cx).detach(); - }); - } - } - }, - ), - ) - .into_any(); - return element; -} From 4b16b73f8003be8d26d3452f8008930b936154da Mon Sep 17 00:00:00 2001 From: Nils Koch Date: Fri, 6 Dec 2024 12:17:24 +0000 Subject: [PATCH 182/215] Fix panel.background color override (#21559) Closes #21266 Release Notes: - Fixes not using the `panel.background` color in the file tree See comments in https://github.com/zed-industries/zed/issues/21266 for more details. Co-authored-by: Danilo Leal --- crates/project_panel/src/project_panel.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ca6f89f69a..12c90e2195 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -268,7 +268,7 @@ fn get_item_color(cx: &ViewContext) -> ItemColors { let colors = cx.theme().colors(); ItemColors { - default: colors.surface_background, + default: colors.panel_background, hover: colors.ghost_element_hover, drag_over: colors.drop_target_background, marked_active: colors.ghost_element_selected, From 7b1d1bf79e7faa1b2024c58b3afdb9363710b588 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:17:34 -0300 Subject: [PATCH 183/215] Update `panel.focused_border` token across themes (#21612) Follow up to https://github.com/zed-industries/zed/pull/21593 This PR updates all built-in themes `panel.focused_border` tokens using the same HEX code used for `text_accent`. There shouldn't be any visual change here given the project panel item, when focused, was using `Color::Selected`, which maps to `text_accent`, to color its border. In the linked PR above, the project panel item was updated to use the dedicated token for that. This is good because now theme markers will be able to customize them separately (e.g., having a different `text_accent` color than `panel.focused_border`). Release Notes: - N/A --- assets/themes/andromeda/andromeda.json | 2 +- assets/themes/atelier/atelier.json | 4 ++-- assets/themes/ayu/ayu.json | 4 ++-- assets/themes/gruvbox/gruvbox.json | 4 ++-- assets/themes/rose_pine/rose_pine.json | 6 +++--- assets/themes/sandcastle/sandcastle.json | 2 +- assets/themes/solarized/solarized.json | 4 ++-- assets/themes/summercamp/summercamp.json | 2 +- 8 files changed, 14 insertions(+), 14 deletions(-) diff --git a/assets/themes/andromeda/andromeda.json b/assets/themes/andromeda/andromeda.json index 633b5c308f..9a9ab5356e 100644 --- a/assets/themes/andromeda/andromeda.json +++ b/assets/themes/andromeda/andromeda.json @@ -46,7 +46,7 @@ "tab.active_background": "#1e2025ff", "search.match_background": "#11a79366", "panel.background": "#21242bff", - "panel.focused_border": null, + "panel.focused_border": "#10a793ff", "pane.focused_border": null, "scrollbar.thumb.background": "#f7f7f84c", "scrollbar.thumb.hover_background": "#252931ff", diff --git a/assets/themes/atelier/atelier.json b/assets/themes/atelier/atelier.json index f72e8e84ee..cbfb6bea85 100644 --- a/assets/themes/atelier/atelier.json +++ b/assets/themes/atelier/atelier.json @@ -46,7 +46,7 @@ "tab.active_background": "#19171cff", "search.match_background": "#576dda66", "panel.background": "#221f26ff", - "panel.focused_border": null, + "panel.focused_border": "#566ddaff", "pane.focused_border": null, "scrollbar.thumb.background": "#efecf44c", "scrollbar.thumb.hover_background": "#332f38ff", @@ -431,7 +431,7 @@ "tab.active_background": "#efecf4ff", "search.match_background": "#586dda66", "panel.background": "#e6e3ebff", - "panel.focused_border": null, + "panel.focused_border": "#586cdaff", "pane.focused_border": null, "scrollbar.thumb.background": "#19171c4c", "scrollbar.thumb.hover_background": "#cbc8d1ff", diff --git a/assets/themes/ayu/ayu.json b/assets/themes/ayu/ayu.json index d511ebf84a..a7c86ef0ba 100644 --- a/assets/themes/ayu/ayu.json +++ b/assets/themes/ayu/ayu.json @@ -46,7 +46,7 @@ "tab.active_background": "#0d1016ff", "search.match_background": "#5ac2fe66", "panel.background": "#1f2127ff", - "panel.focused_border": null, + "panel.focused_border": "#5ac1feff", "pane.focused_border": null, "scrollbar.thumb.background": "#bfbdb64c", "scrollbar.thumb.hover_background": "#2d2f34ff", @@ -416,7 +416,7 @@ "tab.active_background": "#fcfcfcff", "search.match_background": "#3b9ee566", "panel.background": "#ececedff", - "panel.focused_border": null, + "panel.focused_border": "#3b9ee5ff", "pane.focused_border": null, "scrollbar.thumb.background": "#5c61664c", "scrollbar.thumb.hover_background": "#dfe0e1ff", diff --git a/assets/themes/gruvbox/gruvbox.json b/assets/themes/gruvbox/gruvbox.json index 908ce3a28a..4f599cdfe6 100644 --- a/assets/themes/gruvbox/gruvbox.json +++ b/assets/themes/gruvbox/gruvbox.json @@ -55,7 +55,7 @@ "tab.active_background": "#282828ff", "search.match_background": "#83a59866", "panel.background": "#3a3735ff", - "panel.focused_border": null, + "panel.focused_border": "#83a598ff", "pane.focused_border": null, "scrollbar.thumb.background": "#fbf1c74c", "scrollbar.thumb.hover_background": "#494340ff", @@ -439,7 +439,7 @@ "tab.active_background": "#1d2021ff", "search.match_background": "#83a59866", "panel.background": "#393634ff", - "panel.focused_border": null, + "panel.focused_border": "#83a598ff", "pane.focused_border": null, "scrollbar.thumb.background": "#fbf1c74c", "scrollbar.thumb.hover_background": "#494340ff", diff --git a/assets/themes/rose_pine/rose_pine.json b/assets/themes/rose_pine/rose_pine.json index 2ff97da117..b081f5e133 100644 --- a/assets/themes/rose_pine/rose_pine.json +++ b/assets/themes/rose_pine/rose_pine.json @@ -46,7 +46,7 @@ "tab.active_background": "#191724ff", "search.match_background": "#57949f66", "panel.background": "#1c1b2aff", - "panel.focused_border": null, + "panel.focused_border": "#9bced6ff", "pane.focused_border": null, "scrollbar.thumb.background": "#e0def44c", "scrollbar.thumb.hover_background": "#232132ff", @@ -426,7 +426,7 @@ "tab.active_background": "#faf4edff", "search.match_background": "#9cced766", "panel.background": "#fef9f2ff", - "panel.focused_border": null, + "panel.focused_border": "#57949fff", "pane.focused_border": null, "scrollbar.thumb.background": "#5752794c", "scrollbar.thumb.hover_background": "#e5e0dfff", @@ -806,7 +806,7 @@ "tab.active_background": "#232136ff", "search.match_background": "#9cced766", "panel.background": "#28253cff", - "panel.focused_border": null, + "panel.focused_border": "#9bced6ff", "pane.focused_border": null, "scrollbar.thumb.background": "#e0def44c", "scrollbar.thumb.hover_background": "#322f48ff", diff --git a/assets/themes/sandcastle/sandcastle.json b/assets/themes/sandcastle/sandcastle.json index ba9e6f50fd..87030607dc 100644 --- a/assets/themes/sandcastle/sandcastle.json +++ b/assets/themes/sandcastle/sandcastle.json @@ -46,7 +46,7 @@ "tab.active_background": "#282c33ff", "search.match_background": "#528b8b66", "panel.background": "#2b3038ff", - "panel.focused_border": null, + "panel.focused_border": "#518b8bff", "pane.focused_border": null, "scrollbar.thumb.background": "#fdf4c14c", "scrollbar.thumb.hover_background": "#313741ff", diff --git a/assets/themes/solarized/solarized.json b/assets/themes/solarized/solarized.json index fe86793cdc..42341d6770 100644 --- a/assets/themes/solarized/solarized.json +++ b/assets/themes/solarized/solarized.json @@ -46,7 +46,7 @@ "tab.active_background": "#002a35ff", "search.match_background": "#288bd166", "panel.background": "#04313bff", - "panel.focused_border": null, + "panel.focused_border": "#278ad1ff", "pane.focused_border": null, "scrollbar.thumb.background": "#fdf6e34c", "scrollbar.thumb.hover_background": "#053541ff", @@ -416,7 +416,7 @@ "tab.active_background": "#fdf6e3ff", "search.match_background": "#298bd166", "panel.background": "#f3eddaff", - "panel.focused_border": null, + "panel.focused_border": "#288bd1ff", "pane.focused_border": null, "scrollbar.thumb.background": "#002a354c", "scrollbar.thumb.hover_background": "#dcdacbff", diff --git a/assets/themes/summercamp/summercamp.json b/assets/themes/summercamp/summercamp.json index c2206f9aab..0c5cfa0c6f 100644 --- a/assets/themes/summercamp/summercamp.json +++ b/assets/themes/summercamp/summercamp.json @@ -46,7 +46,7 @@ "tab.active_background": "#1b1810ff", "search.match_background": "#499bef66", "panel.background": "#231f16ff", - "panel.focused_border": null, + "panel.focused_border": "#499befff", "pane.focused_border": null, "scrollbar.thumb.background": "#f8f5de4c", "scrollbar.thumb.hover_background": "#29251bff", From e8f0ebc881dd3d686bf2ac8a6deb3611b2a67455 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Fri, 6 Dec 2024 09:17:48 -0300 Subject: [PATCH 184/215] Refine diagnostic icons in tabs (#21637) Follow up to https://github.com/zed-industries/zed/pull/21383 Mostly adjusting the alignment when there are no file icons. Screenshot 2024-12-06 at 08 35 48 Release Notes: - N/A --- assets/icons/triangle.svg | 4 ++-- assets/icons/x.svg | 4 ++-- crates/workspace/src/pane.rs | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/assets/icons/triangle.svg b/assets/icons/triangle.svg index 8c44b91b78..0ecf071e24 100644 --- a/assets/icons/triangle.svg +++ b/assets/icons/triangle.svg @@ -1,3 +1,3 @@ - - + + diff --git a/assets/icons/x.svg b/assets/icons/x.svg index d090cb55bf..5d91a9edd9 100644 --- a/assets/icons/x.svg +++ b/assets/icons/x.svg @@ -1,3 +1,3 @@ - - + + diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index c0a80cc943..8264cb2a4a 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2145,7 +2145,7 @@ impl Pane { .child(if let Some(decorated_icon) = decorated_icon { div().child(decorated_icon.into_any_element()) } else if let Some(icon) = icon { - div().child(icon.into_any_element()) + div().mt(px(2.5)).child(icon.into_any_element()) } else { div() }) From 304158ed795a2f133d8b1a6859a59e2f810f5c98 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 6 Dec 2024 08:45:03 -0500 Subject: [PATCH 185/215] Catch panic from oo7 when reading credentials (#21617) --- crates/gpui/src/platform/linux/platform.rs | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index a85052a4f0..d8bdcf1052 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -8,7 +8,7 @@ use std::fs::File; use std::io::Read; use std::ops::{Deref, DerefMut}; use std::os::fd::{AsFd, AsRawFd, FromRawFd}; -use std::panic::Location; +use std::panic::{AssertUnwindSafe, Location}; use std::rc::Weak; use std::{ path::{Path, PathBuf}, @@ -23,7 +23,7 @@ use async_task::Runnable; use calloop::channel::Channel; use calloop::{EventLoop, LoopHandle, LoopSignal}; use flume::{Receiver, Sender}; -use futures::channel::oneshot; +use futures::{channel::oneshot, future::FutureExt}; use parking_lot::Mutex; use util::ResultExt; @@ -489,7 +489,12 @@ impl Platform for P { let username = attributes .get("username") .ok_or_else(|| anyhow!("Cannot find username in stored credentials"))?; - let secret = item.secret().await?; + // oo7 panics if the retrieved secret can't be decrypted due to + // unexpected padding. + let secret = AssertUnwindSafe(item.secret()) + .catch_unwind() + .await + .map_err(|_| anyhow!("oo7 panicked while trying to read credentials"))??; // we lose the zeroizing capabilities at this boundary, // a current limitation GPUI's credentials api From e5251f40914dac87db247b2a03e2a49c0b696ad6 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Fri, 6 Dec 2024 22:33:58 +0530 Subject: [PATCH 186/215] Fix incorrect language selected in language selector (#21648) Due to filtering after enumeration, initial candidate ids are assigned incorrectly. This later causes the wrong item to be picked up when accessed via index in the vector. --- crates/language_selector/src/language_selector.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/language_selector/src/language_selector.rs b/crates/language_selector/src/language_selector.rs index 60da837baa..760a94000d 100644 --- a/crates/language_selector/src/language_selector.rs +++ b/crates/language_selector/src/language_selector.rs @@ -104,14 +104,15 @@ impl LanguageSelectorDelegate { let candidates = language_registry .language_names() .into_iter() - .enumerate() - .filter_map(|(candidate_id, name)| { + .filter_map(|name| { language_registry .available_language_for_name(&name)? .hidden() .not() - .then(|| StringMatchCandidate::new(candidate_id, name)) + .then_some(name) }) + .enumerate() + .map(|(candidate_id, name)| StringMatchCandidate::new(candidate_id, name)) .collect::>(); Self { From 9ca0d99cfd64ca186df735fc593b0f0bbd2bee05 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:22:35 -0500 Subject: [PATCH 187/215] Update Rust crate ctor to v0.2.9 (#21561) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [ctor](https://redirect.github.com/mmastrac/rust-ctor) | workspace.dependencies | patch | `0.2.8` -> `0.2.9` | --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3167456349..e421917d8f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3480,9 +3480,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.2.8" +version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" dependencies = [ "quote", "syn 2.0.87", From bffdc55d63449ca1a80e649df6f8b35f20937b91 Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Fri, 6 Dec 2024 22:56:47 +0530 Subject: [PATCH 188/215] linux: Make prompt detail selectable (#21405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #21305 As Linux doesn’t have native prompts, Zed uses a custom GPU-based prompt, like the "About Zed" prompt. Currently, the detail in the prompt isn’t selectable. This PR fixes that by using the editor's multi-line selectable functionality to make the detail selectable (and thus copyable). It achieves this by disabling editing and setting the cursor to transparent. The editor also does all the heavy lifting, like double-clicking to select a word or triple-clicking to select a line, like what user expects from selectable. Before/After: before after When detail is `None` or empty string: none Release Notes: - N/A --- Cargo.lock | 1 + crates/zed/Cargo.toml | 1 + crates/zed/src/zed/linux_prompts.rs | 43 ++++++++++++++++++++--------- 3 files changed, 32 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e421917d8f..b93ebce571 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16083,6 +16083,7 @@ dependencies = [ "languages", "libc", "log", + "markdown", "markdown_preview", "menu", "mimalloc", diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 6b26a01f27..9a672757a6 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -69,6 +69,7 @@ language_tools.workspace = true languages = { workspace = true, features = ["load-grammars"] } libc.workspace = true log.workspace = true +markdown.workspace = true markdown_preview.workspace = true menu.workspace = true mimalloc = { version = "0.1", optional = true } diff --git a/crates/zed/src/zed/linux_prompts.rs b/crates/zed/src/zed/linux_prompts.rs index 1961a5f9cd..aa262a11b9 100644 --- a/crates/zed/src/zed/linux_prompts.rs +++ b/crates/zed/src/zed/linux_prompts.rs @@ -1,13 +1,15 @@ use gpui::{ div, AppContext, EventEmitter, FocusHandle, FocusableView, FontWeight, InteractiveElement, - IntoElement, ParentElement, PromptHandle, PromptLevel, PromptResponse, Render, - RenderablePromptHandle, Styled, ViewContext, VisualContext, WindowContext, + IntoElement, ParentElement, PromptHandle, PromptLevel, PromptResponse, Refineable, Render, + RenderablePromptHandle, Styled, TextStyleRefinement, View, ViewContext, VisualContext, + WindowContext, }; +use markdown::{Markdown, MarkdownStyle}; use settings::Settings; use theme::ThemeSettings; use ui::{ - h_flex, v_flex, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, FluentBuilder, LabelSize, - TintColor, + h_flex, v_flex, ActiveTheme, ButtonCommon, ButtonStyle, Clickable, ElevationIndex, + FluentBuilder, LabelSize, TintColor, }; use workspace::ui::StyledExt; @@ -28,10 +30,27 @@ pub fn fallback_prompt_renderer( |cx| FallbackPromptRenderer { _level: level, message: message.to_string(), - detail: detail.map(ToString::to_string), actions: actions.iter().map(ToString::to_string).collect(), focus: cx.focus_handle(), active_action_id: 0, + detail: detail.filter(|text| !text.is_empty()).map(|text| { + cx.new_view(|cx| { + let settings = ThemeSettings::get_global(cx); + let mut base_text_style = cx.text_style(); + base_text_style.refine(&TextStyleRefinement { + font_family: Some(settings.ui_font.family.clone()), + font_size: Some(settings.ui_font_size.into()), + color: Some(ui::Color::Muted.color(cx)), + ..Default::default() + }); + let markdown_style = MarkdownStyle { + base_text_style, + selection_background_color: { cx.theme().players().local().selection }, + ..Default::default() + }; + Markdown::new(text.to_string(), markdown_style, None, None, cx) + }) + }), } }); @@ -42,10 +61,10 @@ pub fn fallback_prompt_renderer( pub struct FallbackPromptRenderer { _level: PromptLevel, message: String, - detail: Option, actions: Vec, focus: FocusHandle, active_action_id: usize, + detail: Option>, } impl FallbackPromptRenderer { @@ -111,13 +130,11 @@ impl Render for FallbackPromptRenderer { .child(self.message.clone()) .text_color(ui::Color::Default.color(cx)), ) - .children(self.detail.clone().map(|detail| { - div() - .w_full() - .text_xs() - .text_color(ui::Color::Muted.color(cx)) - .child(detail) - })) + .children( + self.detail + .clone() + .map(|detail| div().w_full().text_xs().child(detail)), + ) .child(h_flex().justify_end().gap_2().children( self.actions.iter().enumerate().rev().map(|(ix, action)| { ui::Button::new(ix, action.clone()) From b4f59284a94d3af414f62824331e2641e9b055e8 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Fri, 6 Dec 2024 18:31:58 +0100 Subject: [PATCH 189/215] markdown preview: Allow clicking on image to navigate to source location (#21630) Follow up to #21082 Similar to checkboxes, you can now click on the image to navigate to the source location, cmd-clicking opens the url in the browser. https://github.com/user-attachments/assets/edaaa580-9d8f-490b-a4b3-d6ffb21f197c Release Notes: - N/A --- Cargo.lock | 1 + crates/markdown_preview/Cargo.toml | 1 + .../markdown_preview/src/markdown_renderer.rs | 98 ++++++++++++++----- 3 files changed, 78 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b93ebce571..477a7b83e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7421,6 +7421,7 @@ dependencies = [ "settings", "theme", "ui", + "util", "workspace", ] diff --git a/crates/markdown_preview/Cargo.toml b/crates/markdown_preview/Cargo.toml index 46a33966f2..f1409c23a4 100644 --- a/crates/markdown_preview/Cargo.toml +++ b/crates/markdown_preview/Cargo.toml @@ -28,6 +28,7 @@ pulldown-cmark.workspace = true settings.workspace = true theme.workspace = true ui.workspace = true +util.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/markdown_preview/src/markdown_renderer.rs b/crates/markdown_preview/src/markdown_renderer.rs index 7a13077194..5183f361b6 100644 --- a/crates/markdown_preview/src/markdown_renderer.rs +++ b/crates/markdown_preview/src/markdown_renderer.rs @@ -7,8 +7,8 @@ use crate::markdown_elements::{ use gpui::{ div, img, px, rems, AbsoluteLength, AnyElement, ClipboardItem, DefiniteLength, Div, Element, ElementId, HighlightStyle, Hsla, ImageSource, InteractiveText, IntoElement, Keystroke, Length, - Modifiers, ParentElement, Resource, SharedString, Styled, StyledText, TextStyle, WeakView, - WindowContext, + Modifiers, ParentElement, Render, Resource, SharedString, Styled, StyledText, TextStyle, View, + WeakView, WindowContext, }; use settings::Settings; use std::{ @@ -18,9 +18,10 @@ use std::{ }; use theme::{ActiveTheme, SyntaxTheme, ThemeSettings}; use ui::{ - h_flex, relative, v_flex, Checkbox, Clickable, FluentBuilder, IconButton, IconName, IconSize, - InteractiveElement, LinkPreview, Selection, StatefulInteractiveElement, StyledExt, StyledImage, - Tooltip, VisibleOnHover, + h_flex, relative, tooltip_container, v_flex, Checkbox, Clickable, Color, FluentBuilder, + IconButton, IconName, IconSize, InteractiveElement, Label, LabelCommon, LabelSize, LinkPreview, + Selection, StatefulInteractiveElement, StyledExt, StyledImage, ViewContext, VisibleOnHover, + VisualContext as _, }; use workspace::Workspace; @@ -206,15 +207,7 @@ fn render_markdown_list_item( ) .hover(|s| s.cursor_pointer()) .tooltip(|cx| { - let secondary_modifier = Keystroke { - key: "".to_string(), - modifiers: Modifiers::secondary_key(), - key_char: None, - }; - Tooltip::text( - format!("{}-click to toggle the checkbox", secondary_modifier), - cx, - ) + InteractiveMarkdownElementTooltip::new(None, "toggle checkbox", cx).into() }) .into_any_element(), }; @@ -513,6 +506,7 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) let image_element = div() .id(element_id) + .cursor_pointer() .child(img(ImageSource::Resource(image_resource)).with_fallback({ let alt_text = image.alt_text.clone(); { @@ -521,18 +515,31 @@ fn render_markdown_text(parsed_new: &MarkdownParagraph, cx: &mut RenderContext) })) .tooltip({ let link = image.link.clone(); - move |cx| LinkPreview::new(&link.to_string(), cx) + move |cx| { + InteractiveMarkdownElementTooltip::new( + Some(link.to_string()), + "open image", + cx, + ) + .into() + } }) .on_click({ let workspace = workspace_clone.clone(); let link = image.link.clone(); - move |_event, window_cx| match &link { - Link::Web { url } => window_cx.open_url(url), - Link::Path { path, .. } => { - if let Some(workspace) = &workspace { - _ = workspace.update(window_cx, |workspace, cx| { - workspace.open_abs_path(path.clone(), false, cx).detach(); - }); + move |_, cx| { + if cx.modifiers().secondary() { + match &link { + Link::Web { url } => cx.open_url(url), + Link::Path { path, .. } => { + if let Some(workspace) = &workspace { + _ = workspace.update(cx, |workspace, cx| { + workspace + .open_abs_path(path.clone(), false, cx) + .detach(); + }); + } + } } } } @@ -550,3 +557,50 @@ fn render_markdown_rule(cx: &mut RenderContext) -> AnyElement { let rule = div().w_full().h(px(2.)).bg(cx.border_color); div().pt_3().pb_3().child(rule).into_any() } + +struct InteractiveMarkdownElementTooltip { + tooltip_text: Option, + action_text: String, +} + +impl InteractiveMarkdownElementTooltip { + pub fn new( + tooltip_text: Option, + action_text: &str, + cx: &mut WindowContext, + ) -> View { + let tooltip_text = tooltip_text.map(|t| util::truncate_and_trailoff(&t, 50).into()); + + cx.new_view(|_| Self { + tooltip_text, + action_text: action_text.to_string(), + }) + } +} + +impl Render for InteractiveMarkdownElementTooltip { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + tooltip_container(cx, |el, _| { + let secondary_modifier = Keystroke { + modifiers: Modifiers::secondary_key(), + ..Default::default() + }; + + el.child( + v_flex() + .gap_1() + .when_some(self.tooltip_text.clone(), |this, text| { + this.child(Label::new(text).size(LabelSize::Small)) + }) + .child( + Label::new(format!( + "{}-click to {}", + secondary_modifier, self.action_text + )) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + } +} From 8a6c2bb74936c6b28755576e2ad1484b2bb17bb1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:32:45 -0500 Subject: [PATCH 190/215] Update Rust crate rsa to v0.9.7 (#21570) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [rsa](https://redirect.github.com/RustCrypto/RSA) | workspace.dependencies | patch | `0.9.6` -> `0.9.7` | --- ### Release Notes
RustCrypto/RSA (rsa) ### [`v0.9.7`](https://redirect.github.com/RustCrypto/RSA/compare/v0.9.6...v0.9.7) [Compare Source](https://redirect.github.com/RustCrypto/RSA/compare/v0.9.6...v0.9.7)
--- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 477a7b83e2..2c7f3d0498 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10694,9 +10694,9 @@ dependencies = [ [[package]] name = "rsa" -version = "0.9.6" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" +checksum = "47c75d7c5c6b673e58bf54d8544a9f432e3a925b0e80f7cd3602ab5c50c55519" dependencies = [ "const-oid", "digest", From d6e11c58db0f894994d40e9bcd2cad4370a0120b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:33:33 -0500 Subject: [PATCH 191/215] Update Rust crate pathdiff to v0.2.3 (#21568) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [pathdiff](https://redirect.github.com/Manishearth/pathdiff) | workspace.dependencies | patch | `0.2.2` -> `0.2.3` | --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- Cargo.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c7f3d0498..b17b55957d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8578,9 +8578,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pathfinder_geometry" From feb2d85a135292435a76fcd45ac1085f29abfdf7 Mon Sep 17 00:00:00 2001 From: uncenter <47499684+uncenter@users.noreply.github.com> Date: Fri, 6 Dec 2024 12:34:15 -0500 Subject: [PATCH 192/215] Add YAML/TOML frontmatter injections for markdown (#21503) Closes #7938. Adds front-matter injections for TOML/YAML in markdown. - See: https://github.com/tree-sitter-grammars/tree-sitter-markdown/blob/split_parser/tree-sitter-markdown/queries/injections.scm. Co-authored-by: Peter Tripp --- crates/languages/src/markdown/injections.scm | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/languages/src/markdown/injections.scm b/crates/languages/src/markdown/injections.scm index 5972a43eb1..b2c35642e5 100644 --- a/crates/languages/src/markdown/injections.scm +++ b/crates/languages/src/markdown/injections.scm @@ -8,3 +8,7 @@ ((html_block) @content (#set! "language" "html")) + +((minus_metadata) @content (#set! "language" "yaml")) + +((plus_metadata) @content (#set! "language" "toml")) From 99c31816c9c86fe998641c20e2cebcff2a1b0411 Mon Sep 17 00:00:00 2001 From: Jax Young Date: Sat, 7 Dec 2024 01:47:05 +0800 Subject: [PATCH 193/215] docs: Correct default values (#20897) Some default values in the doc are outdated. Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- docs/src/vim.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/src/vim.md b/docs/src/vim.md index a350fb7773..4f87c649ef 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -473,15 +473,15 @@ Here's an example of these settings changed: Here are a few general Zed settings that can help you fine-tune your Vim experience: -| Property | Description | Default Value | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------- | -| cursor_blink | If `true`, the cursor blinks. | `true` | -| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `false` | -| scrollbar | Object that controls the scrollbar display. Set to `{ "show": "never" }` to hide the scroll bar. | `{ "show": "always" }` | -| scroll_beyond_last_line | If set to `"one_page"`, allows scrolling up to one page beyond the last line. Set to `"off"` to prevent this behavior. | `"one_page"` | -| vertical_scroll_margin | The number of lines to keep above or below the cursor when scrolling. Set to `0` to allow the cursor to go up to the edges of the screen vertically. | `3` | -| gutter.line_numbers | Controls the display of line numbers in the gutter. Set the `"line_numbers"` property to `false` to hide line numbers. | `true` | -| command_aliases | Object that defines aliases for commands in the command palette. You can use it to define shortcut names for commands you use often. Read below for examples. | `{}` | +| Property | Description | Default Value | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------- | +| cursor_blink | If `true`, the cursor blinks. | `true` | +| relative_line_numbers | If `true`, line numbers in the left gutter are relative to the cursor. | `false` | +| scrollbar | Object that controls the scrollbar display. Set to `{ "show": "never" }` to hide the scroll bar. | `{ "show": "auto" }` | +| scroll_beyond_last_line | If set to `"one_page"`, allows scrolling up to one page beyond the last line. Set to `"off"` to prevent this behavior. | `"one_page"` | +| vertical_scroll_margin | The number of lines to keep above or below the cursor when scrolling. Set to `0` to allow the cursor to go up to the edges of the screen vertically. | `3` | +| gutter.line_numbers | Controls the display of line numbers in the gutter. Set the `"line_numbers"` property to `false` to hide line numbers. | `true` | +| command_aliases | Object that defines aliases for commands in the command palette. You can use it to define shortcut names for commands you use often. Read below for examples. | `{}` | Here's an example of these settings changed: From 0368fff030117fc99edc6cc4b78478d62a8623d1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 6 Dec 2024 13:16:53 -0500 Subject: [PATCH 194/215] Update cloudflare/wrangler-action digest to 6d58852 (#21551) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Type | Update | Change | |---|---|---|---| | [cloudflare/wrangler-action](https://redirect.github.com/cloudflare/wrangler-action) | action | digest | `05f17c4` -> `6d58852` | --- ### Configuration 📅 **Schedule**: Branch creation - "after 3pm on Wednesday" in timezone America/New_York, Automerge - At any time (no schedule defined). 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- Release Notes: - N/A Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- .github/workflows/deploy_cloudflare.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy_cloudflare.yml b/.github/workflows/deploy_cloudflare.yml index d6daada6e3..6cc4ea0a33 100644 --- a/.github/workflows/deploy_cloudflare.yml +++ b/.github/workflows/deploy_cloudflare.yml @@ -37,28 +37,28 @@ jobs: mdbook build ./docs --dest-dir=../target/deploy/docs/ - name: Deploy Docs - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: pages deploy target/deploy --project-name=docs - name: Deploy Install - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: r2 object put -f script/install.sh zed-open-source-website-assets/install.sh - name: Deploy Docs Workers - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy .cloudflare/docs-proxy/src/worker.js - name: Deploy Install Workers - uses: cloudflare/wrangler-action@05f17c4a695b4d94b57b59997562c6a4624c64e4 # v3 + uses: cloudflare/wrangler-action@6d58852c35a27e6034745c5d0bc373d739014f7f # v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} From 7a1a7929bd245ebcd206edf575299cf8c94ad41f Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Fri, 6 Dec 2024 18:59:40 +0000 Subject: [PATCH 195/215] docs: Add x.ai Grok example (#21655) - Closes https://github.com/zed-industries/zed/issues/21635 Screenshot 2024-12-06 at 13 57 42 Release Notes: - Document support for x.ai Grok --- docs/src/assistant/configuration.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/src/assistant/configuration.md b/docs/src/assistant/configuration.md index 2145bd9504..8e558007bf 100644 --- a/docs/src/assistant/configuration.md +++ b/docs/src/assistant/configuration.md @@ -192,6 +192,30 @@ The Zed Assistant comes pre-configured to use the latest version for common mode You must provide the model's Context Window in the `max_tokens` parameter, this can be found [OpenAI Model Docs](https://platform.openai.com/docs/models). OpenAI `o1` models should set `max_completion_tokens` as well to avoid incurring high reasoning token costs. Custom models will be listed in the model dropdown in the assistant panel. +### OpenAI API Compatible + +Zed supports using OpenAI compatible APIs by specifying a custom `endpoint` and `available_models` for the OpenAI provider. + +#### X.ai Grok + +Example configuration for using X.ai Grok with Zed: + +```json + "language_models": { + "openai": { + "api_url": "https://api.x.ai/v1", + "available_models": [ + { + "name": "grok-beta", + "display_name": "X.ai Grok (Beta)", + "max_tokens": 131072 + } + ], + "version": "1" + }, + } +``` + ### Advanced configuration {#advanced-configuration} #### Example Configuration From 5142e38d2ba546da1be7c90de630cdd8978f46cb Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Fri, 6 Dec 2024 14:32:09 -0500 Subject: [PATCH 196/215] editor: Add actions for inserting UUIDs (#21656) This PR adds two new actions for generating and inserting UUIDs into the buffer: https://github.com/user-attachments/assets/a3445a98-07e2-40b8-9773-fd750706cbcc Release Notes: - Added `editor: insert uuid v4` and `editor: insert uuid v7` actions for inserting generated UUIDs into the editor. --- Cargo.lock | 1 + Cargo.toml | 2 +- crates/editor/Cargo.toml | 1 + crates/editor/src/actions.rs | 10 ++++++++++ crates/editor/src/editor.rs | 27 +++++++++++++++++++++++++++ crates/editor/src/element.rs | 2 ++ 6 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index b17b55957d..0993089333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3957,6 +3957,7 @@ dependencies = [ "unindent", "url", "util", + "uuid", "workspace", ] diff --git a/Cargo.toml b/Cargo.toml index a21a65c8fe..7ff0ad6ce3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -506,7 +506,7 @@ unindent = "0.1.7" unicode-segmentation = "1.10" unicode-script = "0.5.7" url = "2.2" -uuid = { version = "1.1.2", features = ["v4", "v5", "serde"] } +uuid = { version = "1.1.2", features = ["v4", "v5", "v7", "serde"] } wasmparser = "0.215" wasm-encoder = "0.215" wasmtime = { version = "24", default-features = false, features = [ diff --git a/crates/editor/Cargo.toml b/crates/editor/Cargo.toml index 166e7383fc..a728ea86a2 100644 --- a/crates/editor/Cargo.toml +++ b/crates/editor/Cargo.toml @@ -85,6 +85,7 @@ unindent = { workspace = true, optional = true } ui.workspace = true url.workspace = true util.workspace = true +uuid.workspace = true workspace.workspace = true [dev-dependencies] diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 99e7c6cd0b..eb0fcaa1e5 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -105,6 +105,7 @@ pub struct MoveDownByLines { #[serde(default)] pub(super) lines: u32, } + #[derive(PartialEq, Clone, Deserialize, Default)] pub struct SelectUpByLines { #[serde(default)] @@ -166,6 +167,13 @@ pub struct SpawnNearestTask { pub reveal: task::RevealStrategy, } +#[derive(Debug, PartialEq, Eq, Clone, Copy, Deserialize, Default)] +pub enum UuidVersion { + #[default] + V4, + V7, +} + impl_actions!( editor, [ @@ -271,6 +279,8 @@ gpui::actions!( HalfPageUp, Hover, Indent, + InsertUuidV4, + InsertUuidV7, JoinLines, KillRingCut, KillRingYank, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 132a5e04fb..0bd30465d9 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -12004,6 +12004,33 @@ impl Editor { .detach(); } + pub fn insert_uuid_v4(&mut self, _: &InsertUuidV4, cx: &mut ViewContext) { + self.insert_uuid(UuidVersion::V4, cx); + } + + pub fn insert_uuid_v7(&mut self, _: &InsertUuidV7, cx: &mut ViewContext) { + self.insert_uuid(UuidVersion::V7, cx); + } + + fn insert_uuid(&mut self, version: UuidVersion, cx: &mut ViewContext) { + self.transact(cx, |this, cx| { + let edits = this + .selections + .all::(cx) + .into_iter() + .map(|selection| { + let uuid = match version { + UuidVersion::V4 => uuid::Uuid::new_v4(), + UuidVersion::V7 => uuid::Uuid::now_v7(), + }; + + (selection.range(), uuid.to_string()) + }); + this.edit(edits, cx); + this.refresh_inline_completion(true, false, cx); + }); + } + /// Adds a row highlight for the given range. If a row has multiple highlights, the /// last highlight added will be used. /// diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 198ecf6826..2df6d66b6a 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -456,6 +456,8 @@ impl EditorElement { register_action(view, cx, Editor::open_active_item_in_terminal); register_action(view, cx, Editor::reload_file); register_action(view, cx, Editor::spawn_nearest_task); + register_action(view, cx, Editor::insert_uuid_v4); + register_action(view, cx, Editor::insert_uuid_v7); } fn register_key_listeners(&self, cx: &mut WindowContext, layout: &EditorLayout) { From e730a9d029ea6eebfe40741b8f9131c2cdc3768f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Dec 2024 13:06:55 -0700 Subject: [PATCH 197/215] Bump to livekit 1.1.6 (#21660) Co-Authored-By: Mikayla This bumps to the latest v1 version of swift SDK. We could bump to 2, but it sounds like this will already have some race condition fixes (and a click around locally seems less prone to deadlocking so far...) Release Notes: - N/A --- .../livekit_client_macos/LiveKitBridge/Package.resolved | 8 ++++---- crates/livekit_client_macos/LiveKitBridge/Package.swift | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/livekit_client_macos/LiveKitBridge/Package.resolved b/crates/livekit_client_macos/LiveKitBridge/Package.resolved index b925bc8f0d..c84933e5c1 100644 --- a/crates/livekit_client_macos/LiveKitBridge/Package.resolved +++ b/crates/livekit_client_macos/LiveKitBridge/Package.resolved @@ -6,8 +6,8 @@ "repositoryURL": "https://github.com/livekit/client-sdk-swift.git", "state": { "branch": null, - "revision": "7331b813a5ab8a95cfb81fb2b4ed10519428b9ff", - "version": "1.0.12" + "revision": "8cde9e66ce9b470c3a743f5c72784f57c5a6d0c3", + "version": "1.1.6" } }, { @@ -24,8 +24,8 @@ "repositoryURL": "https://github.com/webrtc-sdk/Specs.git", "state": { "branch": null, - "revision": "2f6bab30c8df0fe59ab3e58bc99097f757f85f65", - "version": "104.5112.17" + "revision": "4fa8d6d647fc759cdd0265fd413d2f28ea2e0e08", + "version": "114.5735.8" } }, { diff --git a/crates/livekit_client_macos/LiveKitBridge/Package.swift b/crates/livekit_client_macos/LiveKitBridge/Package.swift index d7b5c271b9..a2a5b3eb75 100644 --- a/crates/livekit_client_macos/LiveKitBridge/Package.swift +++ b/crates/livekit_client_macos/LiveKitBridge/Package.swift @@ -12,16 +12,16 @@ let package = Package( .library( name: "LiveKitBridge", type: .static, - targets: ["LiveKitBridge"]), + targets: ["LiveKitBridge"]) ], dependencies: [ - .package(url: "https://github.com/livekit/client-sdk-swift.git", .exact("1.0.12")), + .package(url: "https://github.com/livekit/client-sdk-swift.git", .exact("1.1.6")) ], targets: [ // Targets are the basic building blocks of a package. A target can define a module or a test suite. // Targets can depend on other targets in this package, and on products in packages this package depends on. .target( name: "LiveKitBridge", - dependencies: [.product(name: "LiveKit", package: "client-sdk-swift")]), + dependencies: [.product(name: "LiveKit", package: "client-sdk-swift")]) ] ) From 17448f23a68862763c7be3f4a95cff24e135ce69 Mon Sep 17 00:00:00 2001 From: The Bearodactyl Date: Fri, 6 Dec 2024 14:19:36 -0600 Subject: [PATCH 198/215] docs: Add clarification in Windows build instructions (#21659) --- docs/src/development/windows.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/development/windows.md b/docs/src/development/windows.md index 9cb539366d..4d1e565a57 100644 --- a/docs/src/development/windows.md +++ b/docs/src/development/windows.md @@ -139,3 +139,5 @@ New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name ``` For more information on this, please see [win32 docs](https://learn.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=powershell) + +(note that you will need to restart your system after enabling longpath support) From 78ca297282036b3dbaec52cf9eaed5a709a4871f Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Dec 2024 14:05:03 -0700 Subject: [PATCH 199/215] Make use_key_equivalents opt-in (#21662) When revamping international keyboard shortcuts I wanted to make the default to use key equivalents; in hindsight, this is not what people expect. Release Notes: - (Breaking) In keymap.json `"use_layout_keys": true` is now the default. If you want to opt-out of this behaviour, set `"use_key_equivalents": true` to have keys mapped for your keyboard. See [documentation](https://zed.dev/docs/key-bindings#non-qwerty-keyboards) --------- Co-authored-by: Peter Tripp --- assets/keymaps/default-macos.json | 51 ++++++++++++++++++++++++++++++ assets/keymaps/vim.json | 28 ---------------- crates/settings/src/keymap_file.rs | 10 +++--- docs/src/key-bindings.md | 9 ++---- 4 files changed, 58 insertions(+), 40 deletions(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 65389230ac..33c32035d9 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1,6 +1,7 @@ [ // Standard macOS bindings { + "use_key_equivalents": true, "bindings": { "up": "menu::SelectPrev", "shift-tab": "menu::SelectPrev", @@ -40,6 +41,7 @@ }, { "context": "Editor", + "use_key_equivalents": true, "bindings": { "escape": "editor::Cancel", "backspace": "editor::Backspace", @@ -131,6 +133,7 @@ }, { "context": "Editor && mode == full", + "use_key_equivalents": true, "bindings": { "enter": "editor::Newline", "shift-enter": "editor::Newline", @@ -148,6 +151,7 @@ }, { "context": "Editor && mode == full && inline_completion", + "use_key_equivalents": true, "bindings": { "alt-]": "editor::NextInlineCompletion", "alt-[": "editor::PreviousInlineCompletion", @@ -156,12 +160,14 @@ }, { "context": "Editor && !inline_completion", + "use_key_equivalents": true, "bindings": { "alt-\\": "editor::ShowInlineCompletion" } }, { "context": "Editor && mode == auto_height", + "use_key_equivalents": true, "bindings": { "ctrl-enter": "editor::Newline", "shift-enter": "editor::Newline", @@ -170,12 +176,14 @@ }, { "context": "Markdown", + "use_key_equivalents": true, "bindings": { "cmd-c": "markdown::Copy" } }, { "context": "Editor && jupyter && !ContextEditor", + "use_key_equivalents": true, "bindings": { "ctrl-shift-enter": "repl::Run", "ctrl-alt-enter": "repl::RunInPlace" @@ -183,6 +191,7 @@ }, { "context": "AssistantPanel", + "use_key_equivalents": true, "bindings": { "cmd-k c": "assistant::CopyCode", "cmd-g": "search::SelectNextMatch", @@ -195,6 +204,7 @@ }, { "context": "ContextEditor > Editor", + "use_key_equivalents": true, "bindings": { "cmd-enter": "assistant::Assist", "cmd-shift-enter": "assistant::Edit", @@ -209,6 +219,7 @@ }, { "context": "AssistantPanel2", + "use_key_equivalents": true, "bindings": { "cmd-n": "assistant2::NewThread", "cmd-shift-h": "assistant2::OpenHistory" @@ -216,12 +227,14 @@ }, { "context": "MessageEditor > Editor", + "use_key_equivalents": true, "bindings": { "cmd-enter": "assistant2::Chat" } }, { "context": "PromptLibrary", + "use_key_equivalents": true, "bindings": { "cmd-n": "prompt_library::NewPrompt", "cmd-shift-s": "prompt_library::ToggleDefaultPrompt", @@ -230,6 +243,7 @@ }, { "context": "BufferSearchBar", + "use_key_equivalents": true, "bindings": { "escape": "buffer_search::Dismiss", "tab": "buffer_search::FocusEditor", @@ -243,6 +257,7 @@ }, { "context": "BufferSearchBar && in_replace > Editor", + "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", "cmd-enter": "search::ReplaceAll" @@ -250,6 +265,7 @@ }, { "context": "BufferSearchBar && !in_replace > Editor", + "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", "down": "search::NextHistoryQuery" @@ -257,6 +273,7 @@ }, { "context": "ProjectSearchBar", + "use_key_equivalents": true, "bindings": { "escape": "project_search::ToggleFocus", "cmd-shift-j": "project_search::ToggleFilters", @@ -268,6 +285,7 @@ }, { "context": "ProjectSearchBar > Editor", + "use_key_equivalents": true, "bindings": { "up": "search::PreviousHistoryQuery", "down": "search::NextHistoryQuery" @@ -275,6 +293,7 @@ }, { "context": "ProjectSearchBar && in_replace > Editor", + "use_key_equivalents": true, "bindings": { "enter": "search::ReplaceNext", "cmd-enter": "search::ReplaceAll" @@ -282,6 +301,7 @@ }, { "context": "ProjectSearchView", + "use_key_equivalents": true, "bindings": { "escape": "project_search::ToggleFocus", "cmd-shift-j": "project_search::ToggleFilters", @@ -292,6 +312,7 @@ }, { "context": "Pane", + "use_key_equivalents": true, "bindings": { "cmd-{": "pane::ActivatePrevItem", "cmd-}": "pane::ActivateNextItem", @@ -320,6 +341,7 @@ // Bindings from VS Code { "context": "Editor", + "use_key_equivalents": true, "bindings": { "cmd-[": "editor::Outdent", "cmd-]": "editor::Indent", @@ -383,6 +405,7 @@ }, { "context": "Editor && mode == full", + "use_key_equivalents": true, "bindings": { "cmd-shift-o": "outline::Toggle", "ctrl-g": "go_to_line::Toggle" @@ -390,6 +413,7 @@ }, { "context": "Pane", + "use_key_equivalents": true, "bindings": { "ctrl-1": ["pane::ActivateItem", 0], "ctrl-2": ["pane::ActivateItem", 1], @@ -409,6 +433,7 @@ }, { "context": "Workspace", + "use_key_equivalents": true, "bindings": { // Change the default action on `menu::Confirm` by setting the parameter // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], @@ -464,6 +489,7 @@ }, { "context": "Workspace && !Terminal", + "use_key_equivalents": true, "bindings": { "cmd-shift-r": "task::Spawn", "cmd-alt-r": "task::Rerun", @@ -474,6 +500,7 @@ // Bindings from Sublime Text { "context": "Editor", + "use_key_equivalents": true, "bindings": { "ctrl-j": "editor::JoinLines", "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", @@ -493,6 +520,7 @@ // Bindings from Atom { "context": "Pane", + "use_key_equivalents": true, "bindings": { "cmd-k up": "pane::SplitUp", "cmd-k down": "pane::SplitDown", @@ -503,12 +531,14 @@ // Bindings that should be unified with bindings for more general actions { "context": "Editor && renaming", + "use_key_equivalents": true, "bindings": { "enter": "editor::ConfirmRename" } }, { "context": "Editor && showing_completions", + "use_key_equivalents": true, "bindings": { "enter": "editor::ConfirmCompletion", "tab": "editor::ComposeCompletion" @@ -516,18 +546,21 @@ }, { "context": "Editor && inline_completion && !showing_completions", + "use_key_equivalents": true, "bindings": { "tab": "editor::AcceptInlineCompletion" } }, { "context": "Editor && showing_code_actions", + "use_key_equivalents": true, "bindings": { "enter": "editor::ConfirmCodeAction" } }, { "context": "Editor && (showing_code_actions || showing_completions)", + "use_key_equivalents": true, "bindings": { "up": "editor::ContextMenuPrev", "ctrl-p": "editor::ContextMenuPrev", @@ -539,6 +572,7 @@ }, // Custom bindings { + "use_key_equivalents": true, "bindings": { "ctrl-alt-cmd-f": "workspace::FollowNextCollaborator", // TODO: Move this to a dock open action @@ -549,6 +583,7 @@ }, { "context": "Editor && mode == full", + "use_key_equivalents": true, "bindings": { "alt-enter": "editor::OpenExcerpts", "shift-enter": "editor::ExpandExcerpts", @@ -560,6 +595,7 @@ }, { "context": "ProposedChangesEditor", + "use_key_equivalents": true, "bindings": { "cmd-shift-y": "editor::ApplyDiffHunk", "cmd-shift-a": "editor::ApplyAllDiffHunks" @@ -567,6 +603,7 @@ }, { "context": "PromptEditor", + "use_key_equivalents": true, "bindings": { "ctrl-[": "assistant::CyclePreviousInlineAssist", "ctrl-]": "assistant::CycleNextInlineAssist" @@ -574,12 +611,14 @@ }, { "context": "ProjectSearchBar && !in_replace", + "use_key_equivalents": true, "bindings": { "cmd-enter": "project_search::SearchInNew" } }, { "context": "OutlinePanel && not_editing", + "use_key_equivalents": true, "bindings": { "escape": "menu::Cancel", "left": "outline_panel::CollapseSelectedEntry", @@ -596,6 +635,7 @@ }, { "context": "ProjectPanel", + "use_key_equivalents": true, "bindings": { "left": "project_panel::CollapseSelectedEntry", "right": "project_panel::ExpandSelectedEntry", @@ -625,12 +665,14 @@ }, { "context": "ProjectPanel && not_editing", + "use_key_equivalents": true, "bindings": { "space": "project_panel::Open" } }, { "context": "CollabPanel && not_editing", + "use_key_equivalents": true, "bindings": { "ctrl-backspace": "collab_panel::Remove", "space": "menu::Confirm" @@ -638,18 +680,21 @@ }, { "context": "(CollabPanel && editing) > Editor", + "use_key_equivalents": true, "bindings": { "space": "collab_panel::InsertSpace" } }, { "context": "ChannelModal", + "use_key_equivalents": true, "bindings": { "tab": "channel_modal::ToggleMode" } }, { "context": "Picker > Editor", + "use_key_equivalents": true, "bindings": { "tab": "picker::ConfirmCompletion", "alt-enter": ["picker::ConfirmInput", { "secondary": false }], @@ -658,18 +703,21 @@ }, { "context": "ChannelModal > Picker > Editor", + "use_key_equivalents": true, "bindings": { "tab": "channel_modal::ToggleMode" } }, { "context": "FileFinder", + "use_key_equivalents": true, "bindings": { "cmd": "file_finder::ToggleMenu" } }, { "context": "FileFinder && !menu_open", + "use_key_equivalents": true, "bindings": { "cmd-shift-p": "file_finder::SelectPrev", "cmd-j": "pane::SplitDown", @@ -680,6 +728,7 @@ }, { "context": "FileFinder && menu_open", + "use_key_equivalents": true, "bindings": { "j": "pane::SplitDown", "k": "pane::SplitUp", @@ -689,6 +738,7 @@ }, { "context": "TabSwitcher", + "use_key_equivalents": true, "bindings": { "ctrl-up": "menu::SelectPrev", "ctrl-down": "menu::SelectNext", @@ -698,6 +748,7 @@ }, { "context": "Terminal", + "use_key_equivalents": true, "bindings": { "ctrl-cmd-space": "terminal::ShowCharacterPalette", "cmd-c": "terminal::Copy", diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 8931ad0dca..9328b0325f 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -1,7 +1,6 @@ [ { "context": "VimControl && !menu", - "use_layout_keys": true, "bindings": { "i": ["vim::PushOperator", { "Object": { "around": false } }], "a": ["vim::PushOperator", { "Object": { "around": true } }], @@ -188,7 +187,6 @@ }, { "context": "vim_mode == normal", - "use_layout_keys": true, "bindings": { "escape": "editor::Cancel", "ctrl-[": "editor::Cancel", @@ -243,7 +241,6 @@ }, { "context": "VimControl && VimCount", - "use_layout_keys": true, "bindings": { "0": ["vim::Number", 0], ":": "vim::CountCommand" @@ -251,7 +248,6 @@ }, { "context": "vim_mode == visual", - "use_layout_keys": true, "bindings": { ":": "vim::VisualCommand", "u": "vim::ConvertToLowerCase", @@ -301,7 +297,6 @@ }, { "context": "vim_mode == insert", - "use_layout_keys": true, "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", @@ -344,7 +339,6 @@ { "context": "vim_mode == insert && !(showing_code_actions || showing_completions)", - "use_layout_keys": true, "bindings": { "ctrl-p": "editor::ShowCompletions", "ctrl-n": "editor::ShowCompletions" @@ -352,7 +346,6 @@ }, { "context": "vim_mode == replace", - "use_layout_keys": true, "bindings": { "escape": "vim::NormalBefore", "ctrl-c": "vim::NormalBefore", @@ -370,7 +363,6 @@ }, { "context": "vim_mode == waiting", - "use_layout_keys": true, "bindings": { "tab": "vim::Tab", "enter": "vim::Enter", @@ -384,7 +376,6 @@ }, { "context": "vim_mode == operator", - "use_layout_keys": true, "bindings": { "escape": "vim::ClearOperators", "ctrl-c": "vim::ClearOperators", @@ -394,7 +385,6 @@ }, { "context": "vim_operator == a || vim_operator == i || vim_operator == cs", - "use_layout_keys": true, "bindings": { "w": "vim::Word", "shift-w": ["vim::Word", { "ignorePunctuation": true }], @@ -425,7 +415,6 @@ }, { "context": "vim_operator == c", - "use_layout_keys": true, "bindings": { "c": "vim::CurrentLine", "d": "editor::Rename", // zed specific @@ -434,7 +423,6 @@ }, { "context": "vim_operator == d", - "use_layout_keys": true, "bindings": { "d": "vim::CurrentLine", "s": ["vim::PushOperator", "DeleteSurrounds"], @@ -444,7 +432,6 @@ }, { "context": "vim_operator == gu", - "use_layout_keys": true, "bindings": { "g u": "vim::CurrentLine", "u": "vim::CurrentLine" @@ -452,7 +439,6 @@ }, { "context": "vim_operator == gU", - "use_layout_keys": true, "bindings": { "g shift-u": "vim::CurrentLine", "shift-u": "vim::CurrentLine" @@ -460,7 +446,6 @@ }, { "context": "vim_operator == g~", - "use_layout_keys": true, "bindings": { "g ~": "vim::CurrentLine", "~": "vim::CurrentLine" @@ -468,7 +453,6 @@ }, { "context": "vim_operator == gq", - "use_layout_keys": true, "bindings": { "g q": "vim::CurrentLine", "q": "vim::CurrentLine", @@ -478,7 +462,6 @@ }, { "context": "vim_operator == y", - "use_layout_keys": true, "bindings": { "y": "vim::CurrentLine", "s": ["vim::PushOperator", { "AddSurrounds": {} }] @@ -486,42 +469,36 @@ }, { "context": "vim_operator == ys", - "use_layout_keys": true, "bindings": { "s": "vim::CurrentLine" } }, { "context": "vim_operator == >", - "use_layout_keys": true, "bindings": { ">": "vim::CurrentLine" } }, { "context": "vim_operator == <", - "use_layout_keys": true, "bindings": { "<": "vim::CurrentLine" } }, { "context": "vim_operator == eq", - "use_layout_keys": true, "bindings": { "=": "vim::CurrentLine" } }, { "context": "vim_operator == gc", - "use_layout_keys": true, "bindings": { "c": "vim::CurrentLine" } }, { "context": "vim_mode == literal", - "use_layout_keys": true, "bindings": { "ctrl-@": ["vim::Literal", ["ctrl-@", "\u0000"]], "ctrl-a": ["vim::Literal", ["ctrl-a", "\u0001"]], @@ -565,7 +542,6 @@ }, { "context": "BufferSearchBar && !in_replace", - "use_layout_keys": true, "bindings": { "enter": "vim::SearchSubmit", "escape": "buffer_search::Dismiss" @@ -573,7 +549,6 @@ }, { "context": "ProjectPanel || CollabPanel || OutlinePanel || ChatPanel || VimControl || EmptyPane || SharedScreen || MarkdownPreview || KeyContextView", - "use_layout_keys": true, "bindings": { // window related commands (ctrl-w X) "ctrl-w": null, @@ -630,7 +605,6 @@ }, { "context": "EmptyPane || SharedScreen || MarkdownPreview || KeyContextView || Welcome", - "use_layout_keys": true, "bindings": { ":": "command_palette::Toggle", "g /": "pane::DeploySearch" @@ -639,7 +613,6 @@ { // netrw compatibility "context": "ProjectPanel && not_editing", - "use_layout_keys": true, "bindings": { ":": "command_palette::Toggle", "%": "project_panel::NewFile", @@ -673,7 +646,6 @@ }, { "context": "OutlinePanel && not_editing", - "use_layout_keys": true, "bindings": { "j": "menu::SelectNext", "k": "menu::SelectPrev", diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index b34806405c..82329337c6 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -20,7 +20,7 @@ pub struct KeymapBlock { #[serde(default)] context: Option, #[serde(default)] - use_layout_keys: Option, + use_key_equivalents: Option, bindings: BTreeMap, } @@ -80,7 +80,7 @@ impl KeymapFile { for KeymapBlock { context, - use_layout_keys, + use_key_equivalents, bindings, } in self.0 { @@ -124,10 +124,10 @@ impl KeymapFile { &keystroke, action, context.as_deref(), - if use_layout_keys.unwrap_or_default() { - None - } else { + if use_key_equivalents.unwrap_or_default() { key_equivalents.as_ref() + } else { + None }, ) }) diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index 660a80ebd4..4d0a33ce55 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -146,20 +146,15 @@ Finally keyboards that support extended Latin alphabets (usually ISO keyboards) For example on a German QWERTZ keyboard, the `cmd->` shortcut is moved to `cmd-:` because `cmd->` is the system window switcher and this is where that shortcut is typed on a QWERTY keyboard. `cmd-+` stays the same because + is still typable without option, and as a result, `cmd-[` and `cmd-]` become `cmd-ö` and `cmd-ä`, moving out of the way of the `+` key. -If you are defining shortcuts in your personal keymap, you can opt-out of the key equivalent mapping by setting `use_layout_keys` to `true` in your keymap: +If you are defining shortcuts in your personal keymap, you can opt into the key equivalent mapping by setting `use_key_equivalents` to `true` in your keymap: ```json [ { + "use_key_equivalents": true, "bindings": { "ctrl->": "editor::Indent" // parsed as ctrl-: when a German QWERTZ keyboard is active } - }, - { - "use_layout_keys": true, - "bindings": { - "ctrl->": "editor::Indent" // remains ctrl-> when a German QWERTZ keyboard is active - } } ] ``` From 7d80d1208cb2f14707e330ed97d5c453a9a65057 Mon Sep 17 00:00:00 2001 From: geemili Date: Fri, 6 Dec 2024 14:05:41 -0700 Subject: [PATCH 200/215] vim: Add delete action to HelixNormal mode (#21544) Related issue: https://github.com/zed-industries/zed/issues/4642 Release-Notes: * N/A --- assets/keymaps/vim.json | 1 + crates/vim/src/helix.rs | 104 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 9328b0325f..597388368d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -326,6 +326,7 @@ "bindings": { "i": "vim::InsertBefore", "a": "vim::InsertAfter", + "d": "vim::HelixDelete", "w": "vim::NextWordStart", "e": "vim::NextWordEnd", "b": "vim::PreviousWordStart", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 21abb5cbaa..3358538991 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -5,10 +5,11 @@ use ui::ViewContext; use crate::{motion::Motion, state::Mode, Vim}; -actions!(vim, [HelixNormalAfter]); +actions!(vim, [HelixNormalAfter, HelixDelete]); pub fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, Vim::helix_normal_after); + Vim::action(editor, cx, Vim::helix_delete); } impl Vim { @@ -226,6 +227,27 @@ impl Vim { _ => self.helix_move_and_collapse(motion, times, cx), } } + + pub fn helix_delete(&mut self, _: &HelixDelete, cx: &mut ViewContext) { + self.store_visual_marks(cx); + self.update_editor(cx, |vim, editor, cx| { + // Fixup selections so they have helix's semantics. + // Specifically: + // - Make sure that each cursor acts as a 1 character wide selection + editor.transact(cx, |editor, cx| { + editor.change_selections(Some(Autoscroll::fit()), cx, |s| { + s.move_with(|map, selection| { + if selection.is_empty() && !selection.reversed { + selection.end = movement::right(map, selection.end); + } + }); + }); + }); + + vim.copy_selections_content(editor, false, cx); + editor.insert("", cx); + }); + } } #[cfg(test)] @@ -268,4 +290,84 @@ mod test { Mode::HelixNormal, ); } + + #[gpui::test] + async fn test_delete(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // test delete a selection + cx.set_state( + indoc! {" + The qu«ick ˇ»brown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quˇbrown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + // test deleting a single character + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quˇrown + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_delete_character_end_of_line(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brownˇ + fox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quick brownˇfox jumps over + the lazy dog."}, + Mode::HelixNormal, + ); + } + + #[gpui::test] + async fn test_delete_character_end_of_buffer(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state( + indoc! {" + The quick brown + fox jumps over + the lazy dog.ˇ"}, + Mode::HelixNormal, + ); + + cx.simulate_keystrokes("d"); + + cx.assert_state( + indoc! {" + The quick brown + fox jumps over + the lazy dog.ˇ"}, + Mode::HelixNormal, + ); + } } From de939e718a7f295b19a4a2b0315e710cc55dda38 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Fri, 6 Dec 2024 13:50:59 -0800 Subject: [PATCH 201/215] Simplify livekit config so that cargo check Just Works (#21661) Supersedes https://github.com/zed-industries/zed/pull/21653 This enables us to use `cargo test -p workspace` on macOS and Linux. Note that the line diffs in `shared_screen.rs` are spurious, I just re-ordered the `macos` and `cross-platform` modules to match the order in the call crate. Release Notes: - N/A --- .github/workflows/ci.yml | 9 +- crates/call/Cargo.toml | 10 +- crates/call/src/call.rs | 36 +-- crates/workspace/Cargo.toml | 2 - crates/workspace/src/shared_screen.rs | 289 +----------------- .../src/shared_screen/cross_platform.rs | 114 +++++++ crates/workspace/src/shared_screen/macos.rs | 126 ++++++++ crates/zed/Cargo.toml | 6 - script/check-rust-livekit-macos | 19 ++ .../patches/use-cross-platform-livekit.patch | 59 ++++ typos.toml | 2 + 11 files changed, 345 insertions(+), 327 deletions(-) create mode 100644 crates/workspace/src/shared_screen/cross_platform.rs create mode 100644 crates/workspace/src/shared_screen/macos.rs create mode 100755 script/check-rust-livekit-macos create mode 100644 script/patches/use-cross-platform-livekit.patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 46e7ab7d51..8a19130324 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -129,8 +129,9 @@ jobs: run: | cargo build --workspace --bins --all-features cargo check -p gpui --features "macos-blade" - cargo check -p workspace --features "livekit-cross-platform" + cargo check -p workspace cargo build -p remote_server + script/check-rust-livekit-macos linux_tests: timeout-minutes: 60 @@ -162,8 +163,10 @@ jobs: - name: Run tests uses: ./.github/actions/run_tests - - name: Build Zed - run: cargo build -p zed + - name: Build other binaries and features + run: | + cargo build -p zed + cargo check -p workspace build_remote_server: timeout-minutes: 60 diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml index e7bc8b44a3..9ba10e56ba 100644 --- a/crates/call/Cargo.toml +++ b/crates/call/Cargo.toml @@ -21,8 +21,6 @@ test-support = [ "project/test-support", "util/test-support" ] -livekit-macos = ["livekit_client_macos"] -livekit-cross-platform = ["livekit_client"] [dependencies] anyhow.workspace = true @@ -42,8 +40,12 @@ serde.workspace = true serde_derive.workspace = true settings.workspace = true util.workspace = true -livekit_client_macos = { workspace = true, optional = true } -livekit_client = { workspace = true, optional = true } + +[target.'cfg(target_os = "macos")'.dependencies] +livekit_client_macos = { workspace = true } + +[target.'cfg(not(target_os = "macos"))'.dependencies] +livekit_client = { workspace = true } [dev-dependencies] client = { workspace = true, features = ["test-support"] } diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs index 9fdce4b8ba..5e212d35b7 100644 --- a/crates/call/src/call.rs +++ b/crates/call/src/call.rs @@ -1,41 +1,13 @@ pub mod call_settings; -#[cfg(any( - all(target_os = "macos", feature = "livekit-macos"), - all( - not(target_os = "macos"), - feature = "livekit-macos", - not(feature = "livekit-cross-platform") - ) -))] +#[cfg(target_os = "macos")] mod macos; -#[cfg(any( - all(target_os = "macos", feature = "livekit-macos"), - all( - not(target_os = "macos"), - feature = "livekit-macos", - not(feature = "livekit-cross-platform") - ) -))] +#[cfg(target_os = "macos")] pub use macos::*; -#[cfg(any( - all( - target_os = "macos", - feature = "livekit-cross-platform", - not(feature = "livekit-macos"), - ), - all(not(target_os = "macos"), feature = "livekit-cross-platform"), -))] +#[cfg(not(target_os = "macos"))] mod cross_platform; -#[cfg(any( - all( - target_os = "macos", - feature = "livekit-cross-platform", - not(feature = "livekit-macos"), - ), - all(not(target_os = "macos"), feature = "livekit-cross-platform"), -))] +#[cfg(not(target_os = "macos"))] pub use cross_platform::*; diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index be2dfb06bd..3b17ed8dab 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -24,8 +24,6 @@ test-support = [ "gpui/test-support", "fs/test-support", ] -livekit-macos = ["call/livekit-macos"] -livekit-cross-platform = ["call/livekit-cross-platform"] [dependencies] anyhow.workspace = true diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs index f7a1ccf760..1d17cfa145 100644 --- a/crates/workspace/src/shared_screen.rs +++ b/crates/workspace/src/shared_screen.rs @@ -1,282 +1,11 @@ -#[cfg(any( - all( - target_os = "macos", - feature = "livekit-cross-platform", - not(feature = "livekit-macos"), - ), - all(not(target_os = "macos"), feature = "livekit-cross-platform"), -))] -mod cross_platform { - use crate::{ - item::{Item, ItemEvent}, - ItemNavHistory, WorkspaceId, - }; - use call::{RemoteVideoTrack, RemoteVideoTrackView}; - use client::{proto::PeerId, User}; - use gpui::{ - div, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, - ParentElement, Render, SharedString, Styled, View, ViewContext, VisualContext, - WindowContext, - }; - use std::sync::Arc; - use ui::{prelude::*, Icon, IconName}; +#[cfg(target_os = "macos")] +mod macos; - pub enum Event { - Close, - } - - pub struct SharedScreen { - pub peer_id: PeerId, - user: Arc, - nav_history: Option, - view: View, - focus: FocusHandle, - } - - impl SharedScreen { - pub fn new( - track: RemoteVideoTrack, - peer_id: PeerId, - user: Arc, - cx: &mut ViewContext, - ) -> Self { - let view = cx.new_view(|cx| RemoteVideoTrackView::new(track.clone(), cx)); - cx.subscribe(&view, |_, _, ev, cx| match ev { - call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), - }) - .detach(); - Self { - view, - peer_id, - user, - nav_history: Default::default(), - focus: cx.focus_handle(), - } - } - } - - impl EventEmitter for SharedScreen {} - - impl FocusableView for SharedScreen { - fn focus_handle(&self, _: &AppContext) -> FocusHandle { - self.focus.clone() - } - } - impl Render for SharedScreen { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .bg(cx.theme().colors().editor_background) - .track_focus(&self.focus) - .key_context("SharedScreen") - .size_full() - .child(self.view.clone()) - } - } - - impl Item for SharedScreen { - type Event = Event; - - fn tab_tooltip_text(&self, _: &AppContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } - - fn deactivated(&mut self, cx: &mut ViewContext) { - if let Some(nav_history) = self.nav_history.as_mut() { - nav_history.push::<()>(None, cx); - } - } - - fn tab_icon(&self, _cx: &WindowContext) -> Option { - Some(Icon::new(IconName::Screen)) - } - - fn tab_content_text(&self, _cx: &WindowContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - None - } - - fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { - self.nav_history = Some(history); - } - - fn clone_on_split( - &self, - _workspace_id: Option, - cx: &mut ViewContext, - ) -> Option> { - Some(cx.new_view(|cx| Self { - view: self.view.update(cx, |view, cx| view.clone(cx)), - peer_id: self.peer_id, - user: self.user.clone(), - nav_history: Default::default(), - focus: cx.focus_handle(), - })) - } - - fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { - match event { - Event::Close => f(ItemEvent::CloseItem), - } - } - } -} - -#[cfg(any( - all( - target_os = "macos", - feature = "livekit-cross-platform", - not(feature = "livekit-macos"), - ), - all(not(target_os = "macos"), feature = "livekit-cross-platform"), -))] -pub use cross_platform::*; - -#[cfg(any( - all(target_os = "macos", feature = "livekit-macos"), - all( - not(target_os = "macos"), - feature = "livekit-macos", - not(feature = "livekit-cross-platform") - ) -))] -mod macos { - use crate::{ - item::{Item, ItemEvent}, - ItemNavHistory, WorkspaceId, - }; - use anyhow::Result; - use call::participant::{Frame, RemoteVideoTrack}; - use client::{proto::PeerId, User}; - use futures::StreamExt; - use gpui::{ - div, surface, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, - ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, - WindowContext, - }; - use std::sync::{Arc, Weak}; - use ui::{prelude::*, Icon, IconName}; - - pub enum Event { - Close, - } - - pub struct SharedScreen { - track: Weak, - frame: Option, - pub peer_id: PeerId, - user: Arc, - nav_history: Option, - _maintain_frame: Task>, - focus: FocusHandle, - } - - impl SharedScreen { - pub fn new( - track: Arc, - peer_id: PeerId, - user: Arc, - cx: &mut ViewContext, - ) -> Self { - cx.focus_handle(); - let mut frames = track.frames(); - Self { - track: Arc::downgrade(&track), - frame: None, - peer_id, - user, - nav_history: Default::default(), - _maintain_frame: cx.spawn(|this, mut cx| async move { - while let Some(frame) = frames.next().await { - this.update(&mut cx, |this, cx| { - this.frame = Some(frame); - cx.notify(); - })?; - } - this.update(&mut cx, |_, cx| cx.emit(Event::Close))?; - Ok(()) - }), - focus: cx.focus_handle(), - } - } - } - - impl EventEmitter for SharedScreen {} - - impl FocusableView for SharedScreen { - fn focus_handle(&self, _: &AppContext) -> FocusHandle { - self.focus.clone() - } - } - impl Render for SharedScreen { - fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { - div() - .bg(cx.theme().colors().editor_background) - .track_focus(&self.focus) - .key_context("SharedScreen") - .size_full() - .children( - self.frame - .as_ref() - .map(|frame| surface(frame.image()).size_full()), - ) - } - } - - impl Item for SharedScreen { - type Event = Event; - - fn tab_tooltip_text(&self, _: &AppContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } - - fn deactivated(&mut self, cx: &mut ViewContext) { - if let Some(nav_history) = self.nav_history.as_mut() { - nav_history.push::<()>(None, cx); - } - } - - fn tab_icon(&self, _cx: &WindowContext) -> Option { - Some(Icon::new(IconName::Screen)) - } - - fn tab_content_text(&self, _cx: &WindowContext) -> Option { - Some(format!("{}'s screen", self.user.github_login).into()) - } - - fn telemetry_event_text(&self) -> Option<&'static str> { - None - } - - fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { - self.nav_history = Some(history); - } - - fn clone_on_split( - &self, - _workspace_id: Option, - cx: &mut ViewContext, - ) -> Option> { - let track = self.track.upgrade()?; - Some(cx.new_view(|cx| Self::new(track, self.peer_id, self.user.clone(), cx))) - } - - fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { - match event { - Event::Close => f(ItemEvent::CloseItem), - } - } - } -} - -#[cfg(any( - all(target_os = "macos", feature = "livekit-macos"), - all( - not(target_os = "macos"), - feature = "livekit-macos", - not(feature = "livekit-cross-platform") - ) -))] +#[cfg(target_os = "macos")] pub use macos::*; + +#[cfg(not(target_os = "macos"))] +mod cross_platform; + +#[cfg(not(target_os = "macos"))] +pub use cross_platform::*; diff --git a/crates/workspace/src/shared_screen/cross_platform.rs b/crates/workspace/src/shared_screen/cross_platform.rs new file mode 100644 index 0000000000..285946cce0 --- /dev/null +++ b/crates/workspace/src/shared_screen/cross_platform.rs @@ -0,0 +1,114 @@ +use crate::{ + item::{Item, ItemEvent}, + ItemNavHistory, WorkspaceId, +}; +use call::{RemoteVideoTrack, RemoteVideoTrackView}; +use client::{proto::PeerId, User}; +use gpui::{ + div, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, ParentElement, + Render, SharedString, Styled, View, ViewContext, VisualContext, WindowContext, +}; +use std::sync::Arc; +use ui::{prelude::*, Icon, IconName}; + +pub enum Event { + Close, +} + +pub struct SharedScreen { + pub peer_id: PeerId, + user: Arc, + nav_history: Option, + view: View, + focus: FocusHandle, +} + +impl SharedScreen { + pub fn new( + track: RemoteVideoTrack, + peer_id: PeerId, + user: Arc, + cx: &mut ViewContext, + ) -> Self { + let view = cx.new_view(|cx| RemoteVideoTrackView::new(track.clone(), cx)); + cx.subscribe(&view, |_, _, ev, cx| match ev { + call::RemoteVideoTrackViewEvent::Close => cx.emit(Event::Close), + }) + .detach(); + Self { + view, + peer_id, + user, + nav_history: Default::default(), + focus: cx.focus_handle(), + } + } +} + +impl EventEmitter for SharedScreen {} + +impl FocusableView for SharedScreen { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus.clone() + } +} +impl Render for SharedScreen { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .key_context("SharedScreen") + .size_full() + .child(self.view.clone()) + } +} + +impl Item for SharedScreen { + type Event = Event; + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, cx); + } + } + + fn tab_icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::new(IconName::Screen)) + } + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { + self.nav_history = Some(history); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> { + Some(cx.new_view(|cx| Self { + view: self.view.update(cx, |view, cx| view.clone(cx)), + peer_id: self.peer_id, + user: self.user.clone(), + nav_history: Default::default(), + focus: cx.focus_handle(), + })) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } + } +} diff --git a/crates/workspace/src/shared_screen/macos.rs b/crates/workspace/src/shared_screen/macos.rs new file mode 100644 index 0000000000..ad0b4c4275 --- /dev/null +++ b/crates/workspace/src/shared_screen/macos.rs @@ -0,0 +1,126 @@ +use crate::{ + item::{Item, ItemEvent}, + ItemNavHistory, WorkspaceId, +}; +use anyhow::Result; +use call::participant::{Frame, RemoteVideoTrack}; +use client::{proto::PeerId, User}; +use futures::StreamExt; +use gpui::{ + div, surface, AppContext, EventEmitter, FocusHandle, FocusableView, InteractiveElement, + ParentElement, Render, SharedString, Styled, Task, View, ViewContext, VisualContext, + WindowContext, +}; +use std::sync::{Arc, Weak}; +use ui::{prelude::*, Icon, IconName}; + +pub enum Event { + Close, +} + +pub struct SharedScreen { + track: Weak, + frame: Option, + pub peer_id: PeerId, + user: Arc, + nav_history: Option, + _maintain_frame: Task>, + focus: FocusHandle, +} + +impl SharedScreen { + pub fn new( + track: Arc, + peer_id: PeerId, + user: Arc, + cx: &mut ViewContext, + ) -> Self { + cx.focus_handle(); + let mut frames = track.frames(); + Self { + track: Arc::downgrade(&track), + frame: None, + peer_id, + user, + nav_history: Default::default(), + _maintain_frame: cx.spawn(|this, mut cx| async move { + while let Some(frame) = frames.next().await { + this.update(&mut cx, |this, cx| { + this.frame = Some(frame); + cx.notify(); + })?; + } + this.update(&mut cx, |_, cx| cx.emit(Event::Close))?; + Ok(()) + }), + focus: cx.focus_handle(), + } + } +} + +impl EventEmitter for SharedScreen {} + +impl FocusableView for SharedScreen { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus.clone() + } +} +impl Render for SharedScreen { + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + div() + .bg(cx.theme().colors().editor_background) + .track_focus(&self.focus) + .key_context("SharedScreen") + .size_full() + .children( + self.frame + .as_ref() + .map(|frame| surface(frame.image()).size_full()), + ) + } +} + +impl Item for SharedScreen { + type Event = Event; + + fn tab_tooltip_text(&self, _: &AppContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn deactivated(&mut self, cx: &mut ViewContext) { + if let Some(nav_history) = self.nav_history.as_mut() { + nav_history.push::<()>(None, cx); + } + } + + fn tab_icon(&self, _cx: &WindowContext) -> Option { + Some(Icon::new(IconName::Screen)) + } + + fn tab_content_text(&self, _cx: &WindowContext) -> Option { + Some(format!("{}'s screen", self.user.github_login).into()) + } + + fn telemetry_event_text(&self) -> Option<&'static str> { + None + } + + fn set_nav_history(&mut self, history: ItemNavHistory, _: &mut ViewContext) { + self.nav_history = Some(history); + } + + fn clone_on_split( + &self, + _workspace_id: Option, + cx: &mut ViewContext, + ) -> Option> { + let track = self.track.upgrade()?; + Some(cx.new_view(|cx| Self::new(track, self.peer_id, self.user.clone(), cx))) + } + + fn to_item_events(event: &Self::Event, mut f: impl FnMut(ItemEvent)) { + match event { + Event::Close => f(ItemEvent::CloseItem), + } + } +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 9a672757a6..2220cc7be0 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -127,12 +127,6 @@ welcome.workspace = true workspace.workspace = true zed_actions.workspace = true -[target.'cfg(target_os = "macos")'.dependencies] -workspace = { workspace = true, features = ["livekit-macos"] } - -[target.'cfg(not(target_os = "macos"))'.dependencies] -workspace = { workspace = true, features = ["livekit-cross-platform"] } - [target.'cfg(target_os = "windows")'.dependencies] windows.workspace = true diff --git a/script/check-rust-livekit-macos b/script/check-rust-livekit-macos new file mode 100755 index 0000000000..e2d0f9cf62 --- /dev/null +++ b/script/check-rust-livekit-macos @@ -0,0 +1,19 @@ +#!/bin/bash + + +set -exuo pipefail + +git apply script/patches/use-cross-platform-livekit.patch + +# Re-enable error skipping for this check, so that we can unapply the patch +set +e + +cargo check -p workspace +exit_code=$? + +# Disable error skipping again +set -e + +git apply -R script/patches/use-cross-platform-livekit.patch + +exit "$exit_code" diff --git a/script/patches/use-cross-platform-livekit.patch b/script/patches/use-cross-platform-livekit.patch new file mode 100644 index 0000000000..81dcca80f6 --- /dev/null +++ b/script/patches/use-cross-platform-livekit.patch @@ -0,0 +1,59 @@ +diff --git a/crates/call/Cargo.toml b/crates/call/Cargo.toml +index 9ba10e56ba..bb69440691 100644 +--- a/crates/call/Cargo.toml ++++ b/crates/call/Cargo.toml +@@ -41,10 +41,10 @@ serde_derive.workspace = true + settings.workspace = true + util.workspace = true + +-[target.'cfg(target_os = "macos")'.dependencies] ++[target.'cfg(any())'.dependencies] + livekit_client_macos = { workspace = true } + +-[target.'cfg(not(target_os = "macos"))'.dependencies] ++[target.'cfg(all())'.dependencies] + livekit_client = { workspace = true } + + [dev-dependencies] +diff --git a/crates/call/src/call.rs b/crates/call/src/call.rs +index 5e212d35b7..a8f9e8f43e 100644 +--- a/crates/call/src/call.rs ++++ b/crates/call/src/call.rs +@@ -1,13 +1,13 @@ + pub mod call_settings; + +-#[cfg(target_os = "macos")] ++#[cfg(any())] + mod macos; + +-#[cfg(target_os = "macos")] ++#[cfg(any())] + pub use macos::*; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + mod cross_platform; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + pub use cross_platform::*; +diff --git a/crates/workspace/src/shared_screen.rs b/crates/workspace/src/shared_screen.rs +index 1d17cfa145..f845234987 100644 +--- a/crates/workspace/src/shared_screen.rs ++++ b/crates/workspace/src/shared_screen.rs +@@ -1,11 +1,11 @@ +-#[cfg(target_os = "macos")] ++#[cfg(any())] + mod macos; + +-#[cfg(target_os = "macos")] ++#[cfg(any())] + pub use macos::*; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + mod cross_platform; + +-#[cfg(not(target_os = "macos"))] ++#[cfg(all())] + pub use cross_platform::*; diff --git a/typos.toml b/typos.toml index dc724dd50d..50f3aadd0a 100644 --- a/typos.toml +++ b/typos.toml @@ -43,6 +43,8 @@ extend-exclude = [ "docs/theme/css/", # Spellcheck triggers on `|Fixe[sd]|` regex part. "script/danger/dangerfile.ts", + # Hashes are not typos + "script/patches/use-cross-platform-livekit.patch" ] [default] From e5374f5d7dc70023f9b8b504f2cf001ac6f18d5e Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Sat, 7 Dec 2024 06:15:04 +0800 Subject: [PATCH 202/215] windows: Ignore WM_SIZE event when minimizing window (#21533) Closes #21364 Release Notes: - Fixed minimize window and then reopen cause the layout changed ![layout1204](https://github.com/user-attachments/assets/e823da90-0cc6-4fc9-8b8e-82680357c6fe) --- crates/gpui/src/platform/windows/events.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index 025fbba4ac..27235d5d40 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -33,7 +33,7 @@ pub(crate) fn handle_msg( WM_ACTIVATE => handle_activate_msg(handle, wparam, state_ptr), WM_CREATE => handle_create_msg(handle, state_ptr), WM_MOVE => handle_move_msg(handle, lparam, state_ptr), - WM_SIZE => handle_size_msg(lparam, state_ptr), + WM_SIZE => handle_size_msg(wparam, lparam, state_ptr), WM_ENTERSIZEMOVE | WM_ENTERMENULOOP => handle_size_move_loop(handle), WM_EXITSIZEMOVE | WM_EXITMENULOOP => handle_size_move_loop_exit(handle), WM_TIMER => handle_timer_msg(handle, wparam, state_ptr), @@ -136,7 +136,15 @@ fn handle_move_msg( Some(0) } -fn handle_size_msg(lparam: LPARAM, state_ptr: Rc) -> Option { +fn handle_size_msg( + wparam: WPARAM, + lparam: LPARAM, + state_ptr: Rc, +) -> Option { + if wparam.0 == SIZE_MINIMIZED as usize { + return Some(0); + } + let width = lparam.loword().max(1) as i32; let height = lparam.hiword().max(1) as i32; let mut lock = state_ptr.state.borrow_mut(); From e019d1405a597d379a2fe54ef09d97eb788bf16d Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Fri, 6 Dec 2024 17:35:00 -0500 Subject: [PATCH 203/215] Send an event when user changes their max monthly spend limit (#21664) Release Notes: - N/A --------- Co-authored-by: Marshall Bowers --- crates/collab/src/api/billing.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/collab/src/api/billing.rs b/crates/collab/src/api/billing.rs index d431e4c043..88201bb5cc 100644 --- a/crates/collab/src/api/billing.rs +++ b/crates/collab/src/api/billing.rs @@ -9,6 +9,7 @@ use collections::HashSet; use reqwest::StatusCode; use sea_orm::ActiveValue; use serde::{Deserialize, Serialize}; +use serde_json::json; use std::{str::FromStr, sync::Arc, time::Duration}; use stripe::{ BillingPortalSession, CreateBillingPortalSession, CreateBillingPortalSessionFlowData, @@ -19,6 +20,7 @@ use stripe::{ }; use util::ResultExt; +use crate::api::events::SnowflakeRow; use crate::llm::{DEFAULT_MAX_MONTHLY_SPEND, FREE_TIER_MONTHLY_SPENDING_LIMIT}; use crate::rpc::{ResultExt as _, Server}; use crate::{ @@ -124,6 +126,20 @@ async fn update_billing_preferences( .await? }; + SnowflakeRow::new( + "Spend Limit Updated", + Some(user.metrics_id), + user.admin, + None, + json!({ + "user_id": user.id, + "max_monthly_llm_usage_spending_in_cents": billing_preferences.max_monthly_llm_usage_spending_in_cents, + }), + ) + .write(&app.kinesis_client, &app.config.kinesis_stream) + .await + .log_err(); + rpc_server.refresh_llm_tokens_for_user(user.id).await; Ok(Json(BillingPreferencesResponse { From 21a6664cf8f54c6a8395c40a3eec9f7fdb796f55 Mon Sep 17 00:00:00 2001 From: Matin Aniss <76515905+MatinAniss@users.noreply.github.com> Date: Sat, 7 Dec 2024 09:53:27 +1100 Subject: [PATCH 204/215] gpui: Support animated WebP image (#20778) Add support for decoding animated WebP images into their individual frames. Release Notes: - N/A --- crates/gpui/src/elements/img.rs | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/crates/gpui/src/elements/img.rs b/crates/gpui/src/elements/img.rs index 895904c801..3a1b1d92fb 100644 --- a/crates/gpui/src/elements/img.rs +++ b/crates/gpui/src/elements/img.rs @@ -8,7 +8,8 @@ use anyhow::{anyhow, Result}; use futures::{AsyncReadExt, Future}; use image::{ - codecs::gif::GifDecoder, AnimationDecoder, Frame, ImageBuffer, ImageError, ImageFormat, + codecs::{gif::GifDecoder, webp::WebPDecoder}, + AnimationDecoder, DynamicImage, Frame, ImageBuffer, ImageError, ImageFormat, Rgba, }; use smallvec::SmallVec; use std::{ @@ -542,6 +543,34 @@ impl Asset for ImageAssetLoader { frames } + ImageFormat::WebP => { + let mut decoder = WebPDecoder::new(Cursor::new(&bytes))?; + + if decoder.has_animation() { + let _ = decoder.set_background_color(Rgba([0, 0, 0, 0])); + let mut frames = SmallVec::new(); + + for frame in decoder.into_frames() { + let mut frame = frame?; + // Convert from RGBA to BGRA. + for pixel in frame.buffer_mut().chunks_exact_mut(4) { + pixel.swap(0, 2); + } + frames.push(frame); + } + + frames + } else { + let mut data = DynamicImage::from_decoder(decoder)?.into_rgba8(); + + // Convert from RGBA to BGRA. + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + + SmallVec::from_elem(Frame::new(data), 1) + } + } _ => { let mut data = image::load_from_memory_with_format(&bytes, format)?.into_rgba8(); From 9d44ed089471c71c96c599c80dc60090b15f8696 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Dec 2024 16:42:50 -0700 Subject: [PATCH 205/215] Stop overriding cancelOperation (#21667) This was added before we were handling key equivalents, and is no longer needed. Furthermore in the gpui2 re-write we stopped sending the correct modifiers so this hasn't worked for the last year. Fixes #21520 Release Notes: - Fixed a bug where cmd-escape could act like . --- crates/gpui/src/platform/mac/window.rs | 27 -------------------------- 1 file changed, 27 deletions(-) diff --git a/crates/gpui/src/platform/mac/window.rs b/crates/gpui/src/platform/mac/window.rs index 8ea7ebd4d5..1779767dca 100644 --- a/crates/gpui/src/platform/mac/window.rs +++ b/crates/gpui/src/platform/mac/window.rs @@ -152,10 +152,6 @@ unsafe fn build_classes() { sel!(flagsChanged:), handle_view_event as extern "C" fn(&Object, Sel, id), ); - decl.add_method( - sel!(cancelOperation:), - cancel_operation as extern "C" fn(&Object, Sel, id), - ); decl.add_method( sel!(makeBackingLayer), @@ -1455,29 +1451,6 @@ extern "C" fn handle_view_event(this: &Object, _: Sel, native_event: id) { } } -// Allows us to receive `cmd-.` (the shortcut for closing a dialog) -// https://bugs.eclipse.org/bugs/show_bug.cgi?id=300620#c6 -extern "C" fn cancel_operation(this: &Object, _sel: Sel, _sender: id) { - let window_state = unsafe { get_window_state(this) }; - let mut lock = window_state.as_ref().lock(); - - let keystroke = Keystroke { - modifiers: Default::default(), - key: ".".into(), - key_char: None, - }; - let event = PlatformInput::KeyDown(KeyDownEvent { - keystroke: keystroke.clone(), - is_held: false, - }); - - if let Some(mut callback) = lock.event_callback.take() { - drop(lock); - callback(event); - window_state.lock().event_callback = Some(callback); - } -} - extern "C" fn window_did_change_occlusion_state(this: &Object, _: Sel, _: id) { let window_state = unsafe { get_window_state(this) }; let lock = &mut *window_state.lock(); From 9e287b33e58a4d4753a90d31a318f4c3de1d4690 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Dec 2024 16:42:58 -0700 Subject: [PATCH 206/215] Update NorwegianExtended equivalents (#21665) Release Notes: - Impoved key equivalents for Norwegian Extended layout --- crates/settings/src/key_equivalents.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/settings/src/key_equivalents.rs b/crates/settings/src/key_equivalents.rs index 4c5ae9e065..a0029aabbe 100644 --- a/crates/settings/src/key_equivalents.rs +++ b/crates/settings/src/key_equivalents.rs @@ -881,7 +881,26 @@ pub fn get_key_equivalents(layout: &str) -> Option> { ('}', 'Æ'), ('~', '>'), ], - "com.apple.keylayout.NorwegianExtended" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.NorwegianExtended" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\\', '@'), + (']', 'æ'), + ('`', '<'), + ('}', 'Æ'), + ('~', '>'), + ], "com.apple.keylayout.NorwegianSami-PC" => &[ ('"', 'ˆ'), ('&', '/'), From 4d22a07a1e90214a254f4d9d94c15d7428a96f92 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Fri, 6 Dec 2024 16:43:12 -0700 Subject: [PATCH 207/215] Remove last few alt- bindings (#21669) Although I hoped we could keep the non-ascii alt characters, it turns out this is not the case for all keyboards. Fixes #21175 Release Notes: - (breaking change) editor::ShowInlineCompetion is now `option-tab` on macOS (not `option-/`). editor::{Next,Previous}Completion are `option-tab` and `option-shift-tab` (not `option-[` and `option-]`). This fixes typing characters generated by option-{/,[,]} on keyboards like Croatian. --- assets/keymaps/default-macos.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 33c32035d9..f54216712e 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -153,8 +153,8 @@ "context": "Editor && mode == full && inline_completion", "use_key_equivalents": true, "bindings": { - "alt-]": "editor::NextInlineCompletion", - "alt-[": "editor::PreviousInlineCompletion", + "alt-tab": "editor::NextInlineCompletion", + "alt-shift-tab": "editor::PreviousInlineCompletion", "ctrl-right": "editor::AcceptPartialInlineCompletion" } }, @@ -162,7 +162,7 @@ "context": "Editor && !inline_completion", "use_key_equivalents": true, "bindings": { - "alt-\\": "editor::ShowInlineCompletion" + "alt-tab": "editor::ShowInlineCompletion" } }, { From fa7dddd6b56b44711c2cf4100131b735435acbfe Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Fri, 6 Dec 2024 22:11:40 -0500 Subject: [PATCH 208/215] gpui: Don't panic when failing to exec system opener (#21674) --- crates/gpui/src/platform/linux/platform.rs | 8 ++++---- crates/gpui/src/platform/mac/platform.rs | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index d8bdcf1052..d0c0f1768e 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -18,7 +18,7 @@ use std::{ time::Duration, }; -use anyhow::anyhow; +use anyhow::{anyhow, Context as _}; use async_task::Runnable; use calloop::channel::Channel; use calloop::{EventLoop, LoopHandle, LoopSignal}; @@ -382,14 +382,14 @@ impl Platform for P { } fn open_with_system(&self, path: &Path) { - let executor = self.background_executor().clone(); let path = path.to_owned(); - executor + self.background_executor() .spawn(async move { let _ = std::process::Command::new("xdg-open") .arg(path) .spawn() - .expect("Failed to open file with xdg-open"); + .context("invoking xdg-open") + .log_err(); }) .detach(); } diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index f0fe560ca4..096bf860a6 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -10,7 +10,7 @@ use crate::{ PlatformTextSystem, PlatformWindow, Result, ScreenCaptureSource, SemanticVersion, Task, WindowAppearance, WindowParams, }; -use anyhow::anyhow; +use anyhow::{anyhow, Context as _}; use block::ConcreteBlock; use cocoa::{ appkit::{ @@ -57,6 +57,7 @@ use std::{ sync::Arc, }; use strum::IntoEnumIterator; +use util::ResultExt; #[allow(non_upper_case_globals)] const NSUTF8StringEncoding: NSUInteger = 4; @@ -779,15 +780,16 @@ impl Platform for MacPlatform { } fn open_with_system(&self, path: &Path) { - let path = path.to_path_buf(); + let path = path.to_owned(); self.0 .lock() .background_executor .spawn(async move { - std::process::Command::new("open") + let _ = std::process::Command::new("open") .arg(path) .spawn() - .expect("Failed to open file"); + .context("invoking open command") + .log_err(); }) .detach(); } From 14ba4a9c944f75e26a4ce3f148844a3eb83b97b9 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Sat, 7 Dec 2024 10:39:01 +0200 Subject: [PATCH 209/215] Fix zoomed terminal pane issues on split (#21668) Closes https://github.com/zed-industries/zed/issues/21652 * prevents zooming out the panel when any terminal pane is closed * forces focus on new terminal panes, to prevent the workspace from getting odd pane events in the background Release Notes: - (Preview only) Fixed zoomed terminal pane issues on split --- crates/terminal_view/src/persistence.rs | 9 +- crates/terminal_view/src/terminal_panel.rs | 98 ++++++++++++++++------ crates/workspace/src/pane.rs | 8 +- 3 files changed, 87 insertions(+), 28 deletions(-) diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index d410ef6d72..f4653014a1 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -214,8 +214,13 @@ async fn deserialize_pane_group( .await; let pane = panel - .update(cx, |_, cx| { - new_terminal_pane(workspace.clone(), project.clone(), cx) + .update(cx, |terminal_panel, cx| { + new_terminal_pane( + workspace.clone(), + project.clone(), + terminal_panel.active_pane.read(cx).is_zoomed(), + cx, + ) }) .log_err()?; let active_item = serialized_pane.active_item; diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index bbe25b8a92..7a68fdd6ba 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -84,9 +84,10 @@ pub struct TerminalPanel { impl TerminalPanel { pub fn new(workspace: &Workspace, cx: &mut ViewContext) -> Self { let project = workspace.project(); - let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), cx); + let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), false, cx); let center = PaneGroup::new(pane.clone()); let enabled = project.read(cx).supports_terminal(cx); + cx.focus_view(&pane); let terminal_panel = Self { center, active_pane: pane, @@ -299,6 +300,9 @@ impl TerminalPanel { let pane_count_before_removal = self.center.panes().len(); let _removal_result = self.center.remove(&pane); if pane_count_before_removal == 1 { + self.center.first_pane().update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }); cx.emit(PanelEvent::Close); } else { if let Some(focus_on_pane) = @@ -308,27 +312,49 @@ impl TerminalPanel { } } } - pane::Event::ZoomIn => cx.emit(PanelEvent::ZoomIn), - pane::Event::ZoomOut => cx.emit(PanelEvent::ZoomOut), + pane::Event::ZoomIn => { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(true, cx); + }) + } + cx.emit(PanelEvent::ZoomIn); + cx.notify(); + } + pane::Event::ZoomOut => { + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(false, cx); + }) + } + cx.emit(PanelEvent::ZoomOut); + cx.notify(); + } pane::Event::AddItem { item } => { if let Some(workspace) = self.workspace.upgrade() { workspace.update(cx, |workspace, cx| { item.added_to_pane(workspace, pane.clone(), cx) }) } + self.serialize(cx); } pane::Event::Split(direction) => { let new_pane = self.new_pane_with_cloned_active_terminal(cx); let pane = pane.clone(); let direction = *direction; - cx.spawn(move |this, mut cx| async move { + cx.spawn(move |terminal_panel, mut cx| async move { let Some(new_pane) = new_pane.await else { return; }; - this.update(&mut cx, |this, _| { - this.center.split(&pane, &new_pane, direction).log_err(); - }) - .ok(); + terminal_panel + .update(&mut cx, |terminal_panel, cx| { + terminal_panel + .center + .split(&pane, &new_pane, direction) + .log_err(); + cx.focus_view(&new_pane); + }) + .ok(); }) .detach(); } @@ -365,7 +391,7 @@ impl TerminalPanel { .or_else(|| default_working_directory(workspace.read(cx), cx)); let kind = TerminalKind::Shell(working_directory); let window = cx.window_handle(); - cx.spawn(move |this, mut cx| async move { + cx.spawn(move |terminal_panel, mut cx| async move { let terminal = project .update(&mut cx, |project, cx| { project.create_terminal(kind, window, cx) @@ -380,10 +406,15 @@ impl TerminalPanel { }) .ok()?, ); - let pane = this - .update(&mut cx, |this, cx| { - let pane = new_terminal_pane(weak_workspace, project, cx); - this.apply_tab_bar_buttons(&pane, cx); + let pane = terminal_panel + .update(&mut cx, |terminal_panel, cx| { + let pane = new_terminal_pane( + weak_workspace, + project, + terminal_panel.active_pane.read(cx).is_zoomed(), + cx, + ); + terminal_panel.apply_tab_bar_buttons(&pane, cx); pane }) .ok()?; @@ -392,7 +423,6 @@ impl TerminalPanel { pane.add_item(terminal_view, true, true, None, cx); }) .ok()?; - cx.focus_view(&pane).ok()?; Some(pane) }) @@ -814,6 +844,7 @@ impl TerminalPanel { pub fn new_terminal_pane( workspace: WeakView, project: Model, + zoomed: bool, cx: &mut ViewContext, ) -> View { let is_local = project.read(cx).is_local(); @@ -827,9 +858,11 @@ pub fn new_terminal_pane( NewTerminal.boxed_clone(), cx, ); + pane.set_zoomed(zoomed, cx); pane.set_can_navigate(false, cx); pane.display_nav_history_buttons(None); pane.set_should_display_tab_bar(|_| true); + pane.set_zoom_out_on_close(false); let terminal_panel_for_split_check = terminal_panel.clone(); pane.set_can_split(Some(Arc::new(move |pane, dragged_item, cx| { @@ -879,8 +912,12 @@ pub fn new_terminal_pane( let new_pane = pane.drag_split_direction().and_then(|split_direction| { terminal_panel.update(cx, |terminal_panel, cx| { - let new_pane = - new_terminal_pane(workspace.clone(), project.clone(), cx); + let new_pane = new_terminal_pane( + workspace.clone(), + project.clone(), + terminal_panel.active_pane.read(cx).is_zoomed(), + cx, + ); terminal_panel.apply_tab_bar_buttons(&new_pane, cx); terminal_panel .center @@ -1062,14 +1099,21 @@ impl Render for TerminalPanel { cx.focus_view(&pane); } else { let new_pane = terminal_panel.new_pane_with_cloned_active_terminal(cx); - cx.spawn(|this, mut cx| async move { + cx.spawn(|terminal_panel, mut cx| async move { if let Some(new_pane) = new_pane.await { - this.update(&mut cx, |this, _| { - this.center - .split(&this.active_pane, &new_pane, SplitDirection::Right) - .log_err(); - }) - .ok(); + terminal_panel + .update(&mut cx, |terminal_panel, cx| { + terminal_panel + .center + .split( + &terminal_panel.active_pane, + &new_pane, + SplitDirection::Right, + ) + .log_err(); + cx.focus_view(&new_pane); + }) + .ok(); } }) .detach(); @@ -1152,8 +1196,12 @@ impl Panel for TerminalPanel { } fn set_zoomed(&mut self, zoomed: bool, cx: &mut ViewContext) { - self.active_pane - .update(cx, |pane, cx| pane.set_zoomed(zoomed, cx)); + for pane in self.center.panes() { + pane.update(cx, |pane, cx| { + pane.set_zoomed(zoomed, cx); + }) + } + cx.notify(); } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 8264cb2a4a..d213ab630b 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -306,6 +306,7 @@ pub struct Pane { pub split_item_context_menu_handle: PopoverMenuHandle, pinned_tab_count: usize, diagnostics: HashMap, + zoom_out_on_close: bool, } pub struct ActivationHistoryEntry { @@ -507,6 +508,7 @@ impl Pane { new_item_context_menu_handle: Default::default(), pinned_tab_count: 0, diagnostics: Default::default(), + zoom_out_on_close: true, } } @@ -1586,7 +1588,7 @@ impl Pane { .remove(&item.item_id()); } - if self.items.is_empty() && close_pane_if_empty && self.zoomed { + if self.zoom_out_on_close && self.items.is_empty() && close_pane_if_empty && self.zoomed { cx.emit(Event::ZoomOut); } @@ -2787,6 +2789,10 @@ impl Pane { pub fn drag_split_direction(&self) -> Option { self.drag_split_direction } + + pub fn set_zoom_out_on_close(&mut self, zoom_out_on_close: bool) { + self.zoom_out_on_close = zoom_out_on_close; + } } impl FocusableView for Pane { From f561a91daf9a8d62bb3533cac2d0bf7842338d28 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Sat, 7 Dec 2024 13:08:18 +0100 Subject: [PATCH 210/215] lsp: Add support for didRename/willRename LSP messages (#21651) Closes #21564 Notably, RA will now rename module references if you change the source file name via our project panel. This PR is a tad bigger than necessary as I torn out the Model<> from didSave watchers (I tried to reuse that code for the same purpose). Release Notes: - Added support for language server actions being executed on file rename. --- crates/lsp/src/lsp.rs | 6 + crates/project/src/lsp_store.rs | 392 ++++++++++++++++++++------- crates/project/src/project.rs | 42 ++- crates/project/src/project_tests.rs | 135 ++++++++- crates/project/src/worktree_store.rs | 68 ++++- 5 files changed, 537 insertions(+), 106 deletions(-) diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 8789f5f252..4f714cccc9 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -638,6 +638,12 @@ impl LanguageServer { snippet_edit_support: Some(true), ..WorkspaceEditClientCapabilities::default() }), + file_operations: Some(WorkspaceFileOperationsClientCapabilities { + dynamic_registration: Some(false), + did_rename: Some(true), + will_rename: Some(true), + ..Default::default() + }), ..Default::default() }), text_document: Some(TextDocumentClientCapabilities { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ff2a3d47e7..6a9acd3048 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -23,10 +23,10 @@ use futures::{ stream::FuturesUnordered, AsyncWriteExt, Future, FutureExt, StreamExt, }; -use globset::{Glob, GlobSet, GlobSetBuilder}; +use globset::{Glob, GlobBuilder, GlobMatcher, GlobSet, GlobSetBuilder}; use gpui::{ - AppContext, AsyncAppContext, Context, Entity, EventEmitter, Model, ModelContext, PromptLevel, - Task, WeakModel, + AppContext, AsyncAppContext, Entity, EventEmitter, Model, ModelContext, PromptLevel, Task, + WeakModel, }; use http_client::HttpClient; use itertools::Itertools as _; @@ -43,12 +43,13 @@ use language::{ Unclipped, }; use lsp::{ - CodeActionKind, CompletionContext, DiagnosticSeverity, DiagnosticTag, - DidChangeWatchedFilesRegistrationOptions, Edit, FileSystemWatcher, InsertTextFormat, - LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, LanguageServerId, - LanguageServerName, LspRequestFuture, MessageActionItem, MessageType, OneOf, - ServerHealthStatus, ServerStatus, SymbolKind, TextEdit, Url, WorkDoneProgressCancelParams, - WorkspaceFolder, + notification::DidRenameFiles, CodeActionKind, CompletionContext, DiagnosticSeverity, + DiagnosticTag, DidChangeWatchedFilesRegistrationOptions, Edit, FileOperationFilter, + FileOperationPatternKind, FileOperationRegistrationOptions, FileRename, FileSystemWatcher, + InsertTextFormat, LanguageServer, LanguageServerBinary, LanguageServerBinaryOptions, + LanguageServerId, LanguageServerName, LspRequestFuture, MessageActionItem, MessageType, OneOf, + RenameFilesParams, ServerHealthStatus, ServerStatus, SymbolKind, TextEdit, Url, + WillRenameFiles, WorkDoneProgressCancelParams, WorkspaceFolder, }; use node_runtime::read_package_installed_version; use parking_lot::{Mutex, RwLock}; @@ -139,7 +140,9 @@ pub struct LocalLspStore { pub language_servers: HashMap, buffers_being_formatted: HashSet, last_workspace_edits_by_language_server: HashMap, - language_server_watched_paths: HashMap>, + language_server_watched_paths: HashMap, + language_server_paths_watched_for_rename: + HashMap, language_server_watcher_registrations: HashMap>>, supplementary_language_servers: @@ -899,6 +902,7 @@ impl LspStore { language_servers: Default::default(), last_workspace_edits_by_language_server: Default::default(), language_server_watched_paths: Default::default(), + language_server_paths_watched_for_rename: Default::default(), language_server_watcher_registrations: Default::default(), current_lsp_settings: ProjectSettings::get_global(cx).lsp.clone(), buffers_being_formatted: Default::default(), @@ -4332,6 +4336,112 @@ impl LspStore { .map(|(key, value)| (*key, value)) } + pub(super) fn did_rename_entry( + &self, + worktree_id: WorktreeId, + old_path: &Path, + new_path: &Path, + is_dir: bool, + ) { + maybe!({ + let local_store = self.as_local()?; + + let old_uri = lsp::Url::from_file_path(old_path).ok().map(String::from)?; + let new_uri = lsp::Url::from_file_path(new_path).ok().map(String::from)?; + + for language_server in self.language_servers_for_worktree(worktree_id) { + let Some(filter) = local_store + .language_server_paths_watched_for_rename + .get(&language_server.server_id()) + else { + continue; + }; + + if filter.should_send_did_rename(&old_uri, is_dir) { + language_server + .notify::(RenameFilesParams { + files: vec![FileRename { + old_uri: old_uri.clone(), + new_uri: new_uri.clone(), + }], + }) + .log_err(); + } + } + Some(()) + }); + } + + pub(super) fn will_rename_entry( + this: WeakModel, + worktree_id: WorktreeId, + old_path: &Path, + new_path: &Path, + is_dir: bool, + cx: AsyncAppContext, + ) -> Task<()> { + let old_uri = lsp::Url::from_file_path(old_path).ok().map(String::from); + let new_uri = lsp::Url::from_file_path(new_path).ok().map(String::from); + cx.spawn(move |mut cx| async move { + let mut tasks = vec![]; + this.update(&mut cx, |this, cx| { + let local_store = this.as_local()?; + let old_uri = old_uri?; + let new_uri = new_uri?; + for language_server in this.language_servers_for_worktree(worktree_id) { + let Some(filter) = local_store + .language_server_paths_watched_for_rename + .get(&language_server.server_id()) + else { + continue; + }; + let Some(adapter) = + this.language_server_adapter_for_id(language_server.server_id()) + else { + continue; + }; + if filter.should_send_will_rename(&old_uri, is_dir) { + let apply_edit = cx.spawn({ + let old_uri = old_uri.clone(); + let new_uri = new_uri.clone(); + let language_server = language_server.clone(); + |this, mut cx| async move { + let edit = language_server + .request::(RenameFilesParams { + files: vec![FileRename { old_uri, new_uri }], + }) + .log_err() + .await + .flatten()?; + + Self::deserialize_workspace_edit( + this.upgrade()?, + edit, + false, + adapter.clone(), + language_server.clone(), + &mut cx, + ) + .await + .ok(); + Some(()) + } + }); + tasks.push(apply_edit); + } + } + Some(()) + }) + .ok() + .flatten(); + for task in tasks { + // Await on tasks sequentially so that the order of application of edits is deterministic + // (at least with regards to the order of registration of language servers) + task.await; + } + }) + } + fn lsp_notify_abs_paths_changed( &mut self, server_id: LanguageServerId, @@ -4369,6 +4479,32 @@ impl LspStore { language_server_id: LanguageServerId, cx: &mut ModelContext, ) { + let Some(watchers) = self.as_local().and_then(|local| { + local + .language_server_watcher_registrations + .get(&language_server_id) + }) else { + return; + }; + + let watch_builder = + self.rebuild_watched_paths_inner(language_server_id, watchers.values().flatten(), cx); + let Some(local_lsp_store) = self.as_local_mut() else { + return; + }; + let watcher = watch_builder.build(local_lsp_store.fs.clone(), language_server_id, cx); + local_lsp_store + .language_server_watched_paths + .insert(language_server_id, watcher); + + cx.notify(); + } + fn rebuild_watched_paths_inner<'a>( + &'a self, + language_server_id: LanguageServerId, + watchers: impl Iterator, + cx: &mut ModelContext, + ) -> LanguageServerWatchedPathsBuilder { let worktrees = self .worktree_store .read(cx) @@ -4380,15 +4516,6 @@ impl LspStore { }) .collect::>(); - let local_lsp_store = self.as_local_mut().unwrap(); - - let Some(watchers) = local_lsp_store - .language_server_watcher_registrations - .get(&language_server_id) - else { - return; - }; - let mut worktree_globs = HashMap::default(); let mut abs_globs = HashMap::default(); log::trace!( @@ -4406,7 +4533,7 @@ impl LspStore { pattern: String, }, } - for watcher in watchers.values().flatten() { + for watcher in watchers { let mut found_host = false; for worktree in &worktrees { let glob_is_inside_worktree = worktree.update(cx, |tree, _| { @@ -4545,12 +4672,7 @@ impl LspStore { watch_builder.watch_abs_path(abs_path, globset); } } - let watcher = watch_builder.build(local_lsp_store.fs.clone(), language_server_id, cx); - local_lsp_store - .language_server_watched_paths - .insert(language_server_id, watcher); - - cx.notify(); + watch_builder } pub fn language_server_for_id(&self, id: LanguageServerId) -> Option> { @@ -6650,6 +6772,23 @@ impl LspStore { simulate_disk_based_diagnostics_completion: None, }, ); + if let Some(file_ops_caps) = language_server + .capabilities() + .workspace + .as_ref() + .and_then(|ws| ws.file_operations.as_ref()) + { + let did_rename_caps = file_ops_caps.did_rename.as_ref(); + let will_rename_caps = file_ops_caps.will_rename.as_ref(); + if did_rename_caps.or(will_rename_caps).is_some() { + let watcher = RenamePathsWatchedForServer::default() + .with_did_rename_patterns(did_rename_caps) + .with_will_rename_patterns(will_rename_caps); + local + .language_server_paths_watched_for_rename + .insert(server_id, watcher); + } + } } self.language_server_statuses.insert( @@ -7010,7 +7149,7 @@ impl LspStore { if let Some(watched_paths) = local .language_server_watched_paths .get(server_id) - .and_then(|paths| paths.read(cx).worktree_paths.get(&worktree_id)) + .and_then(|paths| paths.worktree_paths.get(&worktree_id)) { let params = lsp::DidChangeWatchedFilesParams { changes: changes @@ -7115,7 +7254,7 @@ impl LspStore { Ok(transaction) } - pub async fn deserialize_workspace_edit( + pub(crate) async fn deserialize_workspace_edit( this: Model, edit: lsp::WorkspaceEdit, push_to_history: bool, @@ -7515,6 +7654,84 @@ pub enum LanguageServerToQuery { Other(LanguageServerId), } +#[derive(Default)] +struct RenamePathsWatchedForServer { + did_rename: Vec, + will_rename: Vec, +} + +impl RenamePathsWatchedForServer { + fn with_did_rename_patterns( + mut self, + did_rename: Option<&FileOperationRegistrationOptions>, + ) -> Self { + if let Some(did_rename) = did_rename { + self.did_rename = did_rename + .filters + .iter() + .filter_map(|filter| filter.try_into().log_err()) + .collect(); + } + self + } + fn with_will_rename_patterns( + mut self, + will_rename: Option<&FileOperationRegistrationOptions>, + ) -> Self { + if let Some(will_rename) = will_rename { + self.will_rename = will_rename + .filters + .iter() + .filter_map(|filter| filter.try_into().log_err()) + .collect(); + } + self + } + + fn should_send_did_rename(&self, path: &str, is_dir: bool) -> bool { + self.did_rename.iter().any(|pred| pred.eval(path, is_dir)) + } + fn should_send_will_rename(&self, path: &str, is_dir: bool) -> bool { + self.will_rename.iter().any(|pred| pred.eval(path, is_dir)) + } +} + +impl TryFrom<&FileOperationFilter> for RenameActionPredicate { + type Error = globset::Error; + fn try_from(ops: &FileOperationFilter) -> Result { + Ok(Self { + kind: ops.pattern.matches.clone(), + glob: GlobBuilder::new(&ops.pattern.glob) + .case_insensitive( + ops.pattern + .options + .as_ref() + .map_or(false, |ops| ops.ignore_case.unwrap_or(false)), + ) + .build()? + .compile_matcher(), + }) + } +} +struct RenameActionPredicate { + glob: GlobMatcher, + kind: Option, +} + +impl RenameActionPredicate { + // Returns true if language server should be notified + fn eval(&self, path: &str, is_dir: bool) -> bool { + self.kind.as_ref().map_or(true, |kind| { + let expected_kind = if is_dir { + FileOperationPatternKind::Folder + } else { + FileOperationPatternKind::File + }; + kind == &expected_kind + }) && self.glob.is_match(path) + } +} + #[derive(Default)] struct LanguageServerWatchedPaths { worktree_paths: HashMap, @@ -7539,78 +7756,65 @@ impl LanguageServerWatchedPathsBuilder { fs: Arc, language_server_id: LanguageServerId, cx: &mut ModelContext, - ) -> Model { + ) -> LanguageServerWatchedPaths { let project = cx.weak_model(); - cx.new_model(|cx| { - let this_id = cx.entity_id(); - const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100); - let abs_paths = self - .abs_paths - .into_iter() - .map(|(abs_path, globset)| { - let task = cx.spawn({ - let abs_path = abs_path.clone(); - let fs = fs.clone(); + const LSP_ABS_PATH_OBSERVE: Duration = Duration::from_millis(100); + let abs_paths = self + .abs_paths + .into_iter() + .map(|(abs_path, globset)| { + let task = cx.spawn({ + let abs_path = abs_path.clone(); + let fs = fs.clone(); - let lsp_store = project.clone(); - |_, mut cx| async move { - maybe!(async move { - let mut push_updates = - fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await; - while let Some(update) = push_updates.0.next().await { - let action = lsp_store - .update(&mut cx, |this, cx| { - let Some(local) = this.as_local() else { - return ControlFlow::Break(()); - }; - let Some(watcher) = local - .language_server_watched_paths - .get(&language_server_id) - else { - return ControlFlow::Break(()); - }; - if watcher.entity_id() != this_id { - // This watcher is no longer registered on the project, which means that we should - // cease operations. - return ControlFlow::Break(()); - } - let (globs, _) = watcher - .read(cx) - .abs_paths - .get(&abs_path) - .expect( - "Watched abs path is not registered with a watcher", - ); - let matching_entries = update - .into_iter() - .filter(|event| globs.is_match(&event.path)) - .collect::>(); - this.lsp_notify_abs_paths_changed( - language_server_id, - matching_entries, - ); - ControlFlow::Continue(()) - }) - .ok()?; + let lsp_store = project.clone(); + |_, mut cx| async move { + maybe!(async move { + let mut push_updates = fs.watch(&abs_path, LSP_ABS_PATH_OBSERVE).await; + while let Some(update) = push_updates.0.next().await { + let action = lsp_store + .update(&mut cx, |this, _| { + let Some(local) = this.as_local() else { + return ControlFlow::Break(()); + }; + let Some(watcher) = local + .language_server_watched_paths + .get(&language_server_id) + else { + return ControlFlow::Break(()); + }; + let (globs, _) = watcher.abs_paths.get(&abs_path).expect( + "Watched abs path is not registered with a watcher", + ); + let matching_entries = update + .into_iter() + .filter(|event| globs.is_match(&event.path)) + .collect::>(); + this.lsp_notify_abs_paths_changed( + language_server_id, + matching_entries, + ); + ControlFlow::Continue(()) + }) + .ok()?; - if action.is_break() { - break; - } - } - Some(()) - }) - .await; + if action.is_break() { + break; + } } - }); - (abs_path, (globset, task)) - }) - .collect(); - LanguageServerWatchedPaths { - worktree_paths: self.worktree_paths, - abs_paths, - } + Some(()) + }) + .await; + } + }); + (abs_path, (globset, task)) }) + .collect(); + LanguageServerWatchedPaths { + worktree_paths: self.worktree_paths, + abs_paths, + } } } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 84aedab92b..6ab800460e 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -583,6 +583,8 @@ impl Project { client.add_model_request_handler(Self::handle_open_new_buffer); client.add_model_message_handler(Self::handle_create_buffer_for_peer); + client.add_model_request_handler(WorktreeStore::handle_rename_project_entry); + WorktreeStore::init(&client); BufferStore::init(&client); LspStore::init(&client); @@ -1489,11 +1491,45 @@ impl Project { new_path: impl Into>, cx: &mut ModelContext, ) -> Task> { - let Some(worktree) = self.worktree_for_entry(entry_id, cx) else { + let worktree_store = self.worktree_store.read(cx); + let new_path = new_path.into(); + let Some((worktree, old_path, is_dir)) = worktree_store + .worktree_and_entry_for_id(entry_id, cx) + .map(|(worktree, entry)| (worktree, entry.path.clone(), entry.is_dir())) + else { return Task::ready(Err(anyhow!(format!("No worktree for entry {entry_id:?}")))); }; - worktree.update(cx, |worktree, cx| { - worktree.rename_entry(entry_id, new_path, cx) + + let worktree_id = worktree.read(cx).id(); + + let lsp_store = self.lsp_store().downgrade(); + cx.spawn(|_, mut cx| async move { + let (old_abs_path, new_abs_path) = { + let root_path = worktree.update(&mut cx, |this, _| this.abs_path())?; + (root_path.join(&old_path), root_path.join(&new_path)) + }; + LspStore::will_rename_entry( + lsp_store.clone(), + worktree_id, + &old_abs_path, + &new_abs_path, + is_dir, + cx.clone(), + ) + .await; + + let entry = worktree + .update(&mut cx, |worktree, cx| { + worktree.rename_entry(entry_id, new_path.clone(), cx) + })? + .await?; + + lsp_store + .update(&mut cx, |this, _| { + this.did_rename_entry(worktree_id, &old_abs_path, &new_abs_path, is_dir); + }) + .ok(); + Ok(entry) }) } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 26537503dc..0bd681a588 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -9,12 +9,16 @@ use language::{ tree_sitter_rust, tree_sitter_typescript, Diagnostic, DiagnosticSet, DiskState, FakeLspAdapter, LanguageConfig, LanguageMatcher, LanguageName, LineEnding, OffsetRangeExt, Point, ToPoint, }; -use lsp::{DiagnosticSeverity, NumberOrString}; +use lsp::{ + notification::DidRenameFiles, DiagnosticSeverity, DocumentChanges, FileOperationFilter, + NumberOrString, TextDocumentEdit, WillRenameFiles, +}; use parking_lot::Mutex; use pretty_assertions::{assert_eq, assert_matches}; use serde_json::json; #[cfg(not(windows))] use std::os; +use std::{str::FromStr, sync::OnceLock}; use std::{mem, num::NonZeroU32, ops::Range, task::Poll}; use task::{ResolvedTask, TaskContext}; @@ -3915,6 +3919,135 @@ async fn test_grouped_diagnostics(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_lsp_rename_notifications(cx: &mut gpui::TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + "two": { + "two.rs": "const TWO: usize = one::ONE + one::ONE;" + } + + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + + let language_registry = project.read_with(cx, |project, _| project.languages().clone()); + language_registry.add(rust_lang()); + let watched_paths = lsp::FileOperationRegistrationOptions { + filters: vec![ + FileOperationFilter { + scheme: Some("file".to_owned()), + pattern: lsp::FileOperationPattern { + glob: "**/*.rs".to_owned(), + matches: Some(lsp::FileOperationPatternKind::File), + options: None, + }, + }, + FileOperationFilter { + scheme: Some("file".to_owned()), + pattern: lsp::FileOperationPattern { + glob: "**/**".to_owned(), + matches: Some(lsp::FileOperationPatternKind::Folder), + options: None, + }, + }, + ], + }; + let mut fake_servers = language_registry.register_fake_lsp( + "Rust", + FakeLspAdapter { + capabilities: lsp::ServerCapabilities { + workspace: Some(lsp::WorkspaceServerCapabilities { + workspace_folders: None, + file_operations: Some(lsp::WorkspaceFileOperationsServerCapabilities { + did_rename: Some(watched_paths.clone()), + will_rename: Some(watched_paths), + ..Default::default() + }), + }), + ..Default::default() + }, + ..Default::default() + }, + ); + + let _ = project + .update(cx, |project, cx| { + project.open_local_buffer("/dir/one.rs", cx) + }) + .await + .unwrap(); + + let fake_server = fake_servers.next().await.unwrap(); + let response = project.update(cx, |project, cx| { + let worktree = project.worktrees(cx).next().unwrap(); + let entry = worktree.read(cx).entry_for_path("one.rs").unwrap(); + project.rename_entry(entry.id, "three.rs".as_ref(), cx) + }); + let expected_edit = lsp::WorkspaceEdit { + changes: None, + document_changes: Some(DocumentChanges::Edits({ + vec![TextDocumentEdit { + edits: vec![lsp::Edit::Plain(lsp::TextEdit { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 1, + }, + end: lsp::Position { + line: 0, + character: 3, + }, + }, + new_text: "This is not a drill".to_owned(), + })], + text_document: lsp::OptionalVersionedTextDocumentIdentifier { + uri: Url::from_str("file:///dir/two/two.rs").unwrap(), + version: Some(1337), + }, + }] + })), + change_annotations: None, + }; + let resolved_workspace_edit = Arc::new(OnceLock::new()); + fake_server + .handle_request::({ + let resolved_workspace_edit = resolved_workspace_edit.clone(); + let expected_edit = expected_edit.clone(); + move |params, _| { + let resolved_workspace_edit = resolved_workspace_edit.clone(); + let expected_edit = expected_edit.clone(); + async move { + assert_eq!(params.files.len(), 1); + assert_eq!(params.files[0].old_uri, "file:///dir/one.rs"); + assert_eq!(params.files[0].new_uri, "file:///dir/three.rs"); + resolved_workspace_edit.set(expected_edit.clone()).unwrap(); + Ok(Some(expected_edit)) + } + } + }) + .next() + .await + .unwrap(); + let _ = response.await.unwrap(); + fake_server + .handle_notification::(|params, _| { + assert_eq!(params.files.len(), 1); + assert_eq!(params.files[0].old_uri, "file:///dir/one.rs"); + assert_eq!(params.files[0].new_uri, "file:///dir/three.rs"); + }) + .next() + .await + .unwrap(); + assert_eq!(resolved_workspace_edit.get(), Some(&expected_edit)); +} + #[gpui::test] async fn test_rename(cx: &mut gpui::TestAppContext) { // hi diff --git a/crates/project/src/worktree_store.rs b/crates/project/src/worktree_store.rs index 1e48cc052e..c39b88cd40 100644 --- a/crates/project/src/worktree_store.rs +++ b/crates/project/src/worktree_store.rs @@ -26,7 +26,7 @@ use text::ReplicaId; use util::{paths::SanitizedPath, ResultExt}; use worktree::{Entry, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings}; -use crate::{search::SearchQuery, ProjectPath}; +use crate::{search::SearchQuery, LspStore, ProjectPath}; struct MatchingEntry { worktree_path: Arc, @@ -69,7 +69,6 @@ impl EventEmitter for WorktreeStore {} impl WorktreeStore { pub fn init(client: &AnyProtoClient) { client.add_model_request_handler(Self::handle_create_project_entry); - client.add_model_request_handler(Self::handle_rename_project_entry); client.add_model_request_handler(Self::handle_copy_project_entry); client.add_model_request_handler(Self::handle_delete_project_entry); client.add_model_request_handler(Self::handle_expand_project_entry); @@ -184,6 +183,19 @@ impl WorktreeStore { .find_map(|worktree| worktree.read(cx).entry_for_id(entry_id)) } + pub fn worktree_and_entry_for_id<'a>( + &'a self, + entry_id: ProjectEntryId, + cx: &'a AppContext, + ) -> Option<(Model, &'a Entry)> { + self.worktrees().find_map(|worktree| { + worktree + .read(cx) + .entry_for_id(entry_id) + .map(|e| (worktree.clone(), e)) + }) + } + pub fn entry_for_path(&self, path: &ProjectPath, cx: &AppContext) -> Option { self.worktree_for_id(path.worktree_id, cx)? .read(cx) @@ -1004,16 +1016,56 @@ impl WorktreeStore { } pub async fn handle_rename_project_entry( - this: Model, + this: Model, envelope: TypedEnvelope, mut cx: AsyncAppContext, ) -> Result { let entry_id = ProjectEntryId::from_proto(envelope.payload.entry_id); - let worktree = this.update(&mut cx, |this, cx| { - this.worktree_for_entry(entry_id, cx) - .ok_or_else(|| anyhow!("worktree not found")) - })??; - Worktree::handle_rename_entry(worktree, envelope.payload, cx).await + let (worktree_id, worktree, old_path, is_dir) = this + .update(&mut cx, |this, cx| { + this.worktree_store + .read(cx) + .worktree_and_entry_for_id(entry_id, cx) + .map(|(worktree, entry)| { + ( + worktree.read(cx).id(), + worktree, + entry.path.clone(), + entry.is_dir(), + ) + }) + })? + .ok_or_else(|| anyhow!("worktree not found"))?; + let (old_abs_path, new_abs_path) = { + let root_path = worktree.update(&mut cx, |this, _| this.abs_path())?; + ( + root_path.join(&old_path), + root_path.join(&envelope.payload.new_path), + ) + }; + let lsp_store = this + .update(&mut cx, |this, _| this.lsp_store())? + .downgrade(); + LspStore::will_rename_entry( + lsp_store, + worktree_id, + &old_abs_path, + &new_abs_path, + is_dir, + cx.clone(), + ) + .await; + let response = Worktree::handle_rename_entry(worktree, envelope.payload, cx.clone()).await; + this.update(&mut cx, |this, cx| { + this.lsp_store().read(cx).did_rename_entry( + worktree_id, + &old_abs_path, + &new_abs_path, + is_dir, + ); + }) + .ok(); + response } pub async fn handle_copy_project_entry( From fdc7751457aa899344f8436ac67cffcda65a3f6d Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Sat, 7 Dec 2024 14:52:55 +0100 Subject: [PATCH 211/215] toolchains: Do not use as_json representation for PartialEq (#21682) Closes #21679 Release Notes: - N/A --- crates/language/src/toolchain.rs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/crates/language/src/toolchain.rs b/crates/language/src/toolchain.rs index 13703d81a7..5b48157f0f 100644 --- a/crates/language/src/toolchain.rs +++ b/crates/language/src/toolchain.rs @@ -14,7 +14,7 @@ use settings::WorktreeId; use crate::LanguageName; /// Represents a single toolchain. -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug)] pub struct Toolchain { /// User-facing label pub name: SharedString, @@ -24,6 +24,18 @@ pub struct Toolchain { pub as_json: serde_json::Value, } +impl PartialEq for Toolchain { + fn eq(&self, other: &Self) -> bool { + // Do not use as_json for comparisons; it shouldn't impact equality, as it's not user-surfaced. + // Thus, there could be multiple entries that look the same in the UI. + (&self.name, &self.path, &self.language_name).eq(&( + &other.name, + &other.path, + &other.language_name, + )) + } +} + #[async_trait] pub trait ToolchainLister: Send + Sync { async fn list( From eb3d3eaebfdc559105e5f09cec9e162200e3ad1c Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Sat, 7 Dec 2024 11:00:31 -0300 Subject: [PATCH 212/215] Adjust diagnostic in tabs behavior (#21671) Follow up to https://github.com/zed-industries/zed/pull/21637 After discussing about this feature with the team, we've decided that diagnostic display in tabs should be: 1) turned off by default, and 2) only shown when there are file icons. The main reason here being to keep Zed's UI uncluttered. This means that you can technically have this setting: ``` "tabs": { "show_diagnostics": "all" }, ``` ...and still don't see any diagnostics because you're missing `file_icons": true`. | Error with file icons | Error with no file icons | |--------|--------| | Screenshot 2024-12-06 at 21 05 13 | Screenshot 2024-12-06 at 21 05 24 | Release Notes: - N/A --- assets/settings/default.json | 5 +++-- crates/workspace/src/item.rs | 4 ++-- crates/workspace/src/pane.rs | 26 +++++++++++++------------- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index dd9098e0c0..20819529ff 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -569,7 +569,8 @@ // "Neighbour" "activate_on_close": "history", /// Which files containing diagnostic errors/warnings to mark in the tabs. - /// This setting can take the following three values: + /// Diagnostics are only shown when file icons are also active. + /// This setting only works when can take the following three values: /// /// 1. Do not mark any files: /// "off" @@ -577,7 +578,7 @@ /// "errors" /// 3. Mark files with errors and warnings: /// "all" - "show_diagnostics": "all" + "show_diagnostics": "off" }, // Settings related to preview tabs. "preview_tabs": { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 97c27b52a1..7b9478a9a7 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -64,9 +64,9 @@ pub enum ClosePosition { #[derive(Copy, Clone, Debug, Default, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ShowDiagnostics { + #[default] Off, Errors, - #[default] All, } @@ -99,7 +99,7 @@ pub struct ItemSettingsContent { /// Which files containing diagnostic errors/warnings to mark in the tabs. /// This setting can take the following three values: /// - /// Default: all + /// Default: off show_diagnostics: Option, /// Whether to always show the close button on tabs. /// diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index d213ab630b..a4ca58c11c 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2002,12 +2002,8 @@ impl Pane { let icon = if decorated_icon.is_none() { match item_diagnostic { - Some(&DiagnosticSeverity::ERROR) => { - Some(Icon::new(IconName::X).color(Color::Error)) - } - Some(&DiagnosticSeverity::WARNING) => { - Some(Icon::new(IconName::Triangle).color(Color::Warning)) - } + Some(&DiagnosticSeverity::ERROR) => None, + Some(&DiagnosticSeverity::WARNING) => None, _ => item.tab_icon(cx).map(|icon| icon.color(Color::Muted)), } .map(|icon| icon.size(IconSize::Small)) @@ -2144,13 +2140,17 @@ impl Pane { .child( h_flex() .gap_1() - .child(if let Some(decorated_icon) = decorated_icon { - div().child(decorated_icon.into_any_element()) - } else if let Some(icon) = icon { - div().mt(px(2.5)).child(icon.into_any_element()) - } else { - div() - }) + .items_center() + .children( + std::iter::once(if let Some(decorated_icon) = decorated_icon { + Some(div().child(decorated_icon.into_any_element())) + } else if let Some(icon) = icon { + Some(div().child(icon.into_any_element())) + } else { + None + }) + .flatten(), + ) .child(label), ); From c5b6d78d5b01e0fcd243499bf3fb09cb1c758902 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sat, 7 Dec 2024 12:56:52 -0500 Subject: [PATCH 213/215] project_diff: Keep going after failing to rescan a buffer (#21673) I ran into a case locally where the project diff view was unexpectedly empty because the first file to be scanned wasn't valid UTF-8, and the inmost loop in `schedule_worktree_rescan` currently breaks when any loading task fails. It seems like it might make more sense to continue with the rest of the buffers in this case and also when `Project::open_unstaged_changes` fails. I've left the error handling for `update` as-is. Release Notes: - Fix project diff view missing files --- crates/editor/src/git/project_diff.rs | 34 +++++++++++++++++---------- 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index 8ececa9bb8..8fb600c52c 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -6,7 +6,7 @@ use std::{ time::Duration, }; -use anyhow::Context as _; +use anyhow::{anyhow, Context as _}; use collections::{BTreeMap, HashMap}; use feature_flags::FeatureFlagAppExt; use git::{ @@ -235,22 +235,30 @@ impl ProjectDiffEditor { >::default(); let mut change_sets = Vec::new(); for (status, entry_id, entry_path, open_task) in open_tasks { - let (_, opened_model) = open_task.await.with_context(|| { - format!("loading buffer {:?} for git diff", entry_path.path) - })?; - let buffer = match opened_model.downcast::() { - Ok(buffer) => buffer, - Err(_model) => anyhow::bail!( - "Could not load {:?} as a buffer for git diff", - entry_path.path - ), + let Some(buffer) = open_task + .await + .and_then(|(_, opened_model)| { + opened_model + .downcast::() + .map_err(|_| anyhow!("Unexpected non-buffer")) + }) + .with_context(|| { + format!("loading {} for git diff", entry_path.path.display()) + }) + .log_err() + else { + continue; }; - let change_set = project + let Some(change_set) = project .update(&mut cx, |project, cx| { project.open_unstaged_changes(buffer.clone(), cx) })? - .await?; + .await + .log_err() + else { + continue; + }; cx.update(|cx| { buffers.insert( @@ -267,7 +275,7 @@ impl ProjectDiffEditor { new_entries.push((entry_path, entry_id)); } - Ok((buffers, new_entries, change_sets)) + anyhow::Ok((buffers, new_entries, change_sets)) }) .await .log_err() From 4b93a5ca4469ef6c3926b186fc2c4f7476ad69dd Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Sun, 8 Dec 2024 09:44:48 -0700 Subject: [PATCH 214/215] Make completions selector continue to show docs aside if ever shown (#21704) In #21286, documentation fetch was made more efficient by only fetching the current completion. This has a side effect of causing the aside to disappear and reappear when navigating the list. This is particularly jarring when there isn't enough space for the aside, causing the completions list to jump to the left. The solution here is to continue to show the aside even if the current selection does not yet have docs fetched. Release Notes: - N/A --- crates/editor/src/editor.rs | 50 ++++++++++++++++++++++++------------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 0bd30465d9..b2abe8db80 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -141,7 +141,7 @@ use snippet::Snippet; use std::{ any::TypeId, borrow::Cow, - cell::RefCell, + cell::{Cell, RefCell}, cmp::{self, Ordering, Reverse}, mem, num::NonZeroU32, @@ -1008,6 +1008,7 @@ struct CompletionsMenu { selected_item: usize, scroll_handle: UniformListScrollHandle, selected_completion_resolve_debounce: Option>>, + aside_was_displayed: Cell, } impl CompletionsMenu { @@ -1040,6 +1041,7 @@ impl CompletionsMenu { selected_item: 0, scroll_handle: UniformListScrollHandle::new(), selected_completion_resolve_debounce: Some(Arc::new(Mutex::new(DebouncedDelay::new()))), + aside_was_displayed: Cell::new(false), } } @@ -1093,6 +1095,7 @@ impl CompletionsMenu { selected_item: 0, scroll_handle: UniformListScrollHandle::new(), selected_completion_resolve_debounce: Some(Arc::new(Mutex::new(DebouncedDelay::new()))), + aside_was_displayed: Cell::new(false), } } @@ -1231,7 +1234,7 @@ impl CompletionsMenu { let multiline_docs = if show_completion_documentation { let mat = &self.matches[selected_item]; - let multiline_docs = match &self.completions.read()[mat.candidate_id].documentation { + match &self.completions.read()[mat.candidate_id].documentation { Some(Documentation::MultiLinePlainText(text)) => { Some(div().child(SharedString::from(text.clone()))) } @@ -1244,24 +1247,37 @@ impl CompletionsMenu { cx, ))) } + Some(Documentation::Undocumented) if self.aside_was_displayed.get() => { + Some(div().child("No documentation")) + } _ => None, - }; - multiline_docs.map(|div| { - div.id("multiline_docs") - .max_h(max_height) - .flex_1() - .px_1p5() - .py_1() - .min_w(px(260.)) - .max_w(px(640.)) - .w(px(500.)) - .overflow_y_scroll() - .occlude() - }) + } } else { None }; + let aside_contents = if let Some(multiline_docs) = multiline_docs { + Some(multiline_docs) + } else if self.aside_was_displayed.get() { + Some(div().child("Fetching documentation...")) + } else { + None + }; + self.aside_was_displayed.set(aside_contents.is_some()); + + let aside_contents = aside_contents.map(|div| { + div.id("multiline_docs") + .max_h(max_height) + .flex_1() + .px_1p5() + .py_1() + .min_w(px(260.)) + .max_w(px(640.)) + .w(px(500.)) + .overflow_y_scroll() + .occlude() + }); + let list = uniform_list( cx.view().clone(), "completions", @@ -1357,8 +1373,8 @@ impl CompletionsMenu { Popover::new() .child(list) - .when_some(multiline_docs, |popover, multiline_docs| { - popover.aside(multiline_docs) + .when_some(aside_contents, |popover, aside_contents| { + popover.aside(aside_contents) }) .into_any_element() } From ac07b9197a5a2814dad605148fe032f6777ce5fe Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Sun, 8 Dec 2024 13:30:23 -0500 Subject: [PATCH 215/215] gpui: Don't panic on failing to set X11 cursor style (#21689) One more panic (well, two) that should be a `log_err`. Release Notes: - N/A --- crates/gpui/src/platform/linux/x11/client.rs | 8 +++++--- crates/util/src/util.rs | 10 ++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 1fd0e9aa66..a0c9ab4794 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -9,6 +9,7 @@ use std::time::{Duration, Instant}; use calloop::generic::{FdWrapper, Generic}; use calloop::{EventLoop, LoopHandle, RegistrationToken}; +use anyhow::Context as _; use collections::HashMap; use http_client::Url; use smallvec::SmallVec; @@ -1417,9 +1418,10 @@ impl LinuxClient for X11Client { ..Default::default() }, ) - .expect("failed to change window cursor") - .check() - .unwrap(); + .anyhow() + .and_then(|cookie| cookie.check().anyhow()) + .context("setting cursor style") + .log_err(); } fn open_uri(&self, uri: &str) { diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index fe3f7ef9a0..777b8b60dc 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -206,6 +206,9 @@ pub trait ResultExt { /// Assert that this result should never be an error in development or tests. fn debug_assert_ok(self, reason: &str) -> Self; fn warn_on_err(self) -> Option; + fn anyhow(self) -> anyhow::Result + where + E: Into; } impl ResultExt for Result @@ -243,6 +246,13 @@ where } } } + + fn anyhow(self) -> anyhow::Result + where + E: Into, + { + self.map_err(Into::into) + } } fn log_error_with_caller(caller: core::panic::Location<'_>, error: E, level: log::Level)