diff --git a/Cargo.lock b/Cargo.lock index 7f63e6aceb73c..0a544dcf65b0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5113,6 +5113,7 @@ dependencies = [ "collections", "db", "editor", + "futures 0.3.31", "git", "gpui", "language", @@ -5123,6 +5124,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "sum_tree", "theme", "ui", "util", @@ -9332,6 +9334,7 @@ dependencies = [ "env_logger 0.11.6", "gpui", "menu", + "schemars", "serde", "serde_json", "ui", @@ -11278,6 +11281,7 @@ dependencies = [ "language", "menu", "project", + "schemars", "serde", "serde_json", "settings", @@ -12659,6 +12663,7 @@ dependencies = [ "menu", "picker", "project", + "schemars", "serde", "serde_json", "settings", @@ -12852,6 +12857,7 @@ dependencies = [ "language", "project", "rand 0.8.5", + "schemars", "search", "serde", "serde_json", @@ -13186,6 +13192,7 @@ dependencies = [ "project", "remote", "rpc", + "schemars", "serde", "settings", "smallvec", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ac4b27f2b02b8..86fb9a8aa1708 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -682,6 +682,38 @@ "space": "project_panel::Open" } }, + { + "context": "GitPanel && !CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git_panel::Close" + } + }, + { + "context": "GitPanel && ChangesList", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrev", + "down": "menu::SelectNext", + "cmd-up": "menu::SelectFirst", + "cmd-down": "menu::SelectLast", + "enter": "menu::Confirm", + "space": "git::ToggleStaged", + "cmd-shift-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll", + "alt-down": "git_panel::FocusEditor" + } + }, + { + "context": "GitPanel && CommitEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "alt-up": "git_panel::FocusChanges", + "escape": "git_panel::FocusChanges", + "cmd-enter": "git::CommitChanges", + "cmd-alt-enter": "git::CommitAllChanges" + } + }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 816e20ad787a6..0abdb08b263d5 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -15,7 +15,6 @@ "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line - //"ctrl-space": "editor::SetMark", "ctrl-f": "editor::MoveRight", // forward-char "ctrl-b": "editor::MoveLeft", // backward-char "ctrl-n": "editor::MoveDown", // next-line @@ -52,7 +51,65 @@ "alt->": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward - "alt-^": "editor::JoinLines" // join-line + "alt-^": "editor::JoinLines", // join-line + "alt-/": "editor::ShowCompletions" // dabbrev-expand + } + }, + // Extend selection with movement bindings + { + "context": "Editor && (mode == full) && selection", + "bindings": { + "right": "editor::SelectRight", + "left": "editor::SelectLeft", + "down": "editor::SelectDown", + "up": "editor::SelectUp", + "home": "editor::SelectToBeginningOfLine", + "end": "editor::SelectToEndOfLine", + "alt-left": "editor::SelectToPreviousWordStart", + "alt-right": "editor::SelectToNextWordEnd", + "pagedown": "editor::SelectPageDown", + "pageup": "editor::SelectPageUp", + "ctrl-f": "editor::SelectRight", + "ctrl-b": "editor::SelectLeft", + "ctrl-n": "editor::SelectDown", + "ctrl-p": "editor::SelectUp", + "ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-e": "editor::SelectToEndOfLine", + "alt-f": "editor::SelectToNextWordEnd", + "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-<": "editor::SelectToBeginning", + "alt->": "editor::SelectToEnd", + "ctrl-space": "editor::Cancel" // clear mark + } + }, + // Emacs set-mark-command emulation (ctrl-space + movement bindings) + { + "context": "Editor && (mode == full) && !selection", + "bindings": { + "ctrl-space right": "editor::SelectRight", + "ctrl-space left": "editor::SelectLeft", + "ctrl-space down": "editor::SelectDown", + "ctrl-space up": "editor::SelectUp", + "ctrl-space home": "editor::SelectToBeginningOfLine", + "ctrl-space end": "editor::SelectToEndOfLine", + "ctrl-space alt-left": "editor::SelectToPreviousWordStart", + "ctrl-space alt-right": "editor::SelectToNextWordEnd", + "ctrl-space pagedown": "editor::SelectPageDown", + "ctrl-space pageup": "editor::SelectPageUp", + "ctrl-space ctrl-f": "editor::SelectRight", + "ctrl-space ctrl-b": "editor::SelectLeft", + "ctrl-space ctrl-n": "editor::SelectDown", + "ctrl-space ctrl-p": "editor::SelectUp", + "ctrl-space ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-space ctrl-e": "editor::SelectToEndOfLine", + "ctrl-space alt-f": "editor::SelectToNextWordEnd", + "ctrl-space alt-b": "editor::SelectToPreviousWordStart", + "ctrl-space alt-<": "editor::SelectToBeginning", + "ctrl-space alt->": "editor::SelectToEnd", + "ctrl-space alt-v": "editor::SelectPageUp", + "ctrl-space ctrl-v": "editor::SelectPageDown", + "ctrl-space alt-{": "editor::SelectToStartOfParagraph", + "ctrl-space alt-}": "editor::SelectToEndOfParagraph" } }, { diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 816e20ad787a6..0abdb08b263d5 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -15,7 +15,6 @@ "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line - //"ctrl-space": "editor::SetMark", "ctrl-f": "editor::MoveRight", // forward-char "ctrl-b": "editor::MoveLeft", // backward-char "ctrl-n": "editor::MoveDown", // next-line @@ -52,7 +51,65 @@ "alt->": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward - "alt-^": "editor::JoinLines" // join-line + "alt-^": "editor::JoinLines", // join-line + "alt-/": "editor::ShowCompletions" // dabbrev-expand + } + }, + // Extend selection with movement bindings + { + "context": "Editor && (mode == full) && selection", + "bindings": { + "right": "editor::SelectRight", + "left": "editor::SelectLeft", + "down": "editor::SelectDown", + "up": "editor::SelectUp", + "home": "editor::SelectToBeginningOfLine", + "end": "editor::SelectToEndOfLine", + "alt-left": "editor::SelectToPreviousWordStart", + "alt-right": "editor::SelectToNextWordEnd", + "pagedown": "editor::SelectPageDown", + "pageup": "editor::SelectPageUp", + "ctrl-f": "editor::SelectRight", + "ctrl-b": "editor::SelectLeft", + "ctrl-n": "editor::SelectDown", + "ctrl-p": "editor::SelectUp", + "ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-e": "editor::SelectToEndOfLine", + "alt-f": "editor::SelectToNextWordEnd", + "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-<": "editor::SelectToBeginning", + "alt->": "editor::SelectToEnd", + "ctrl-space": "editor::Cancel" // clear mark + } + }, + // Emacs set-mark-command emulation (ctrl-space + movement bindings) + { + "context": "Editor && (mode == full) && !selection", + "bindings": { + "ctrl-space right": "editor::SelectRight", + "ctrl-space left": "editor::SelectLeft", + "ctrl-space down": "editor::SelectDown", + "ctrl-space up": "editor::SelectUp", + "ctrl-space home": "editor::SelectToBeginningOfLine", + "ctrl-space end": "editor::SelectToEndOfLine", + "ctrl-space alt-left": "editor::SelectToPreviousWordStart", + "ctrl-space alt-right": "editor::SelectToNextWordEnd", + "ctrl-space pagedown": "editor::SelectPageDown", + "ctrl-space pageup": "editor::SelectPageUp", + "ctrl-space ctrl-f": "editor::SelectRight", + "ctrl-space ctrl-b": "editor::SelectLeft", + "ctrl-space ctrl-n": "editor::SelectDown", + "ctrl-space ctrl-p": "editor::SelectUp", + "ctrl-space ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-space ctrl-e": "editor::SelectToEndOfLine", + "ctrl-space alt-f": "editor::SelectToNextWordEnd", + "ctrl-space alt-b": "editor::SelectToPreviousWordStart", + "ctrl-space alt-<": "editor::SelectToBeginning", + "ctrl-space alt->": "editor::SelectToEnd", + "ctrl-space alt-v": "editor::SelectPageUp", + "ctrl-space ctrl-v": "editor::SelectPageDown", + "ctrl-space alt-{": "editor::SelectToStartOfParagraph", + "ctrl-space alt-}": "editor::SelectToEndOfParagraph" } }, { diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 33c12088a7d1f..6ac1d851f2cb2 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -26,7 +26,7 @@ pub use context::*; pub use context_store::*; use feature_flags::FeatureFlagAppExt; use fs::Fs; -use gpui::impl_actions; +use gpui::impl_internal_actions; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; pub(crate) use inline_assistant::*; use language_model::{ @@ -74,13 +74,13 @@ actions!( ] ); -#[derive(PartialEq, Clone, Deserialize)] +#[derive(PartialEq, Clone)] pub enum InsertDraggedFiles { ProjectPaths(Vec), ExternalFiles(Vec), } -impl_actions!(assistant, [InsertDraggedFiles]); +impl_internal_actions!(assistant, [InsertDraggedFiles]); const DEFAULT_CONTEXT_LINES: usize = 50; diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index 4f43f92edec31..8acdaec50f4cf 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -72,16 +72,16 @@ pub fn init( ) { cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); cx.observe_new_views(|_, cx| { + let workspace = cx.view().clone(); + InlineAssistant::update_global(cx, |inline_assistant, cx| { + inline_assistant.register_workspace(&workspace, cx) + }); + cx.observe_flag::({ |is_assistant2_enabled, _view, cx| { - if is_assistant2_enabled { - // Assistant2 enabled, nothing to do for Assistant1. - } else { - let workspace = cx.view().clone(); - InlineAssistant::update_global(cx, |inline_assistant, cx| { - inline_assistant.register_workspace(&workspace, cx) - }) - } + InlineAssistant::update_global(cx, |inline_assistant, _cx| { + inline_assistant.is_assistant2_enabled = is_assistant2_enabled; + }); } }) .detach(); @@ -102,6 +102,7 @@ pub struct InlineAssistant { prompt_builder: Arc, telemetry: Arc, fs: Arc, + is_assistant2_enabled: bool, } impl Global for InlineAssistant {} @@ -123,6 +124,7 @@ impl InlineAssistant { prompt_builder, telemetry, fs, + is_assistant2_enabled: false, } } @@ -183,15 +185,22 @@ impl InlineAssistant { item: &dyn ItemHandle, cx: &mut WindowContext, ) { + let is_assistant2_enabled = self.is_assistant2_enabled; + if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { - editor.push_code_action_provider( - Rc::new(AssistantCodeActionProvider { - editor: cx.view().downgrade(), - workspace: workspace.downgrade(), - }), - cx, - ); + if is_assistant2_enabled { + editor + .remove_code_action_provider(ASSISTANT_CODE_ACTION_PROVIDER_ID.into(), cx); + } else { + editor.add_code_action_provider( + Rc::new(AssistantCodeActionProvider { + editor: cx.view().downgrade(), + workspace: workspace.downgrade(), + }), + cx, + ); + } }); } } @@ -3437,7 +3446,13 @@ struct AssistantCodeActionProvider { workspace: WeakView, } +const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant"; + impl CodeActionProvider for AssistantCodeActionProvider { + fn id(&self) -> Arc { + ASSISTANT_CODE_ACTION_PROVIDER_ID.into() + } + fn code_actions( &self, buffer: &Model, diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index e56d766ea168f..86a1eb50fec49 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -1,11 +1,13 @@ use std::sync::Arc; +use std::time::Duration; use assistant_tool::ToolWorkingSet; use collections::HashMap; use gpui::{ - list, AbsoluteLength, AnyElement, AppContext, DefiniteLength, EdgesRefinement, Empty, Length, - ListAlignment, ListOffset, ListState, Model, StyleRefinement, Subscription, - TextStyleRefinement, UnderlineStyle, View, WeakView, + list, percentage, AbsoluteLength, Animation, AnimationExt, AnyElement, AppContext, + DefiniteLength, EdgesRefinement, Empty, Length, ListAlignment, ListOffset, ListState, Model, + StyleRefinement, Subscription, TextStyleRefinement, Transformation, UnderlineStyle, View, + WeakView, }; use language::LanguageRegistry; use language_model::Role; @@ -80,6 +82,12 @@ impl ActiveThread { self.thread.read(cx).summary_or_default() } + pub fn cancel_last_completion(&mut self, cx: &mut AppContext) -> bool { + self.last_error.take(); + self.thread + .update(cx, |thread, _cx| thread.cancel_last_completion()) + } + pub fn last_error(&self) -> Option { self.last_error.clone() } @@ -234,6 +242,7 @@ impl ActiveThread { fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { let message_id = self.messages[ix]; + let is_last_message = ix == self.messages.len() - 1; let Some(message) = self.thread.read(cx).message(message_id) else { return Empty.into_any(); }; @@ -242,6 +251,7 @@ impl ActiveThread { return Empty.into_any(); }; + let is_streaming_completion = self.thread.read(cx).is_streaming(); let context = self.thread.read(cx).context_for_message(message_id); let colors = cx.theme().colors(); @@ -284,6 +294,37 @@ impl ActiveThread { ), ) .child(div().p_2p5().text_ui(cx).child(markdown.clone())) + .when( + message.role == Role::Assistant + && is_last_message + && is_streaming_completion, + |parent| { + parent.child( + h_flex() + .gap_1() + .p_2p5() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate( + percentage(delta), + )) + }, + ), + ) + .child( + Label::new("Generating…") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }, + ) .when_some(context, |parent, context| { if !context.is_empty() { parent.child( @@ -303,7 +344,30 @@ impl ActiveThread { } impl Render for ActiveThread { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - list(self.list_state.clone()).flex_1().py_1() + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let is_streaming_completion = self.thread.read(cx).is_streaming(); + + v_flex() + .size_full() + .child(list(self.list_state.clone()).flex_grow()) + .child( + h_flex() + .absolute() + .bottom_1() + .flex_shrink() + .justify_center() + .w_full() + .when(is_streaming_completion, |parent| { + parent.child( + h_flex() + .gap_2() + .p_1p5() + .rounded_md() + .bg(cx.theme().colors().elevated_surface_background) + .child(Label::new("Generating…").size(LabelSize::Small)) + .child(Label::new("esc to cancel").size(LabelSize::Small)), + ) + }), + ) } } diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 894064842a88c..4c6993312e197 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -143,6 +143,11 @@ impl AssistantPanel { &self.thread_store } + fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { + self.thread + .update(cx, |thread, cx| thread.cancel_last_completion(cx)); + } + fn new_thread(&mut self, cx: &mut ViewContext) { let thread = self .thread_store @@ -611,6 +616,7 @@ impl Render for AssistantPanel { .key_context("AssistantPanel2") .justify_between() .size_full() + .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(|this, _: &NewThread, cx| { this.new_thread(cx); })) diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs index 85fc282ca2177..4f8e9fd3064f9 100644 --- a/crates/assistant2/src/context_picker.rs +++ b/crates/assistant2/src/context_picker.rs @@ -65,14 +65,15 @@ impl ContextPicker { } } - pub fn reset_mode(&mut self, cx: &mut ViewContext) { - self.mode = ContextPickerMode::Default(self.build(cx)); + pub fn init(&mut self, cx: &mut ViewContext) { + self.mode = ContextPickerMode::Default(self.build_menu(cx)); + cx.notify(); } - fn build(&mut self, cx: &mut ViewContext) -> View { + fn build_menu(&mut self, cx: &mut ViewContext) -> View { let context_picker = cx.view().clone(); - ContextMenu::build(cx, move |menu, cx| { + let menu = ContextMenu::build(cx, move |menu, cx| { let kind_entry = |kind: &'static ContextKind| { let context_picker = context_picker.clone(); @@ -90,11 +91,24 @@ impl ContextPicker { .enumerate() .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry)); - menu.when(has_recent, |menu| menu.label("Recent")) + let menu = menu + .when(has_recent, |menu| menu.label("Recent")) .extend(recent_entries) .when(has_recent, |menu| menu.separator()) - .extend(ContextKind::all().into_iter().map(kind_entry)) + .extend(ContextKind::all().into_iter().map(kind_entry)); + + match self.confirm_behavior { + ConfirmBehavior::KeepOpen => menu.keep_open_on_confirm(), + ConfirmBehavior::Close => menu, + } + }); + + cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| { + cx.emit(DismissEvent); }) + .detach(); + + menu } fn select_kind(&mut self, kind: ContextKind, cx: &mut ViewContext) { diff --git a/crates/assistant2/src/context_picker/directory_context_picker.rs b/crates/assistant2/src/context_picker/directory_context_picker.rs index 969e0e7f43bd4..fce39fec6d919 100644 --- a/crates/assistant2/src/context_picker/directory_context_picker.rs +++ b/crates/assistant2/src/context_picker/directory_context_picker.rs @@ -221,8 +221,7 @@ impl PickerDelegate for DirectoryContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/fetch_context_picker.rs b/crates/assistant2/src/context_picker/fetch_context_picker.rs index dd23ed7c9fb3c..7bb089e82b920 100644 --- a/crates/assistant2/src/context_picker/fetch_context_picker.rs +++ b/crates/assistant2/src/context_picker/fetch_context_picker.rs @@ -222,8 +222,7 @@ impl PickerDelegate for FetchContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/file_context_picker.rs b/crates/assistant2/src/context_picker/file_context_picker.rs index 282c4c70b982b..533f793bed025 100644 --- a/crates/assistant2/src/context_picker/file_context_picker.rs +++ b/crates/assistant2/src/context_picker/file_context_picker.rs @@ -239,8 +239,7 @@ impl PickerDelegate for FileContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/thread_context_picker.rs b/crates/assistant2/src/context_picker/thread_context_picker.rs index af52ec7172811..bea32d3d043ee 100644 --- a/crates/assistant2/src/context_picker/thread_context_picker.rs +++ b/crates/assistant2/src/context_picker/thread_context_picker.rs @@ -176,8 +176,7 @@ impl PickerDelegate for ThreadContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_strip.rs b/crates/assistant2/src/context_strip.rs index 10ac4edd7e4ee..d9689192c907e 100644 --- a/crates/assistant2/src/context_strip.rs +++ b/crates/assistant2/src/context_strip.rs @@ -168,7 +168,7 @@ impl Render for ContextStrip { PopoverMenu::new("context-picker") .menu(move |cx| { context_picker.update(cx, |this, cx| { - this.reset_mode(cx); + this.init(cx); }); Some(context_picker.clone()) diff --git a/crates/assistant2/src/inline_assistant.rs b/crates/assistant2/src/inline_assistant.rs index 4367b3641ac5d..73e539473f9bf 100644 --- a/crates/assistant2/src/inline_assistant.rs +++ b/crates/assistant2/src/inline_assistant.rs @@ -51,14 +51,16 @@ pub fn init( ) { cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); cx.observe_new_views(|_workspace: &mut Workspace, cx| { + let workspace = cx.view().clone(); + InlineAssistant::update_global(cx, |inline_assistant, cx| { + inline_assistant.register_workspace(&workspace, cx) + }); + cx.observe_flag::({ |is_assistant2_enabled, _view, cx| { - if is_assistant2_enabled { - let workspace = cx.view().clone(); - InlineAssistant::update_global(cx, |inline_assistant, cx| { - inline_assistant.register_workspace(&workspace, cx) - }) - } + InlineAssistant::update_global(cx, |inline_assistant, _cx| { + inline_assistant.is_assistant2_enabled = is_assistant2_enabled; + }); } }) .detach(); @@ -84,6 +86,7 @@ pub struct InlineAssistant { prompt_builder: Arc, telemetry: Arc, fs: Arc, + is_assistant2_enabled: bool, } impl Global for InlineAssistant {} @@ -105,6 +108,7 @@ impl InlineAssistant { prompt_builder, telemetry, fs, + is_assistant2_enabled: false, } } @@ -165,21 +169,31 @@ impl InlineAssistant { item: &dyn ItemHandle, cx: &mut WindowContext, ) { + let is_assistant2_enabled = self.is_assistant2_enabled; + if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { - let thread_store = workspace - .read(cx) - .panel::(cx) - .map(|assistant_panel| assistant_panel.read(cx).thread_store().downgrade()); - - editor.push_code_action_provider( - Rc::new(AssistantCodeActionProvider { - editor: cx.view().downgrade(), - workspace: workspace.downgrade(), - thread_store, - }), - cx, - ); + if is_assistant2_enabled { + let thread_store = workspace + .read(cx) + .panel::(cx) + .map(|assistant_panel| assistant_panel.read(cx).thread_store().downgrade()); + + editor.add_code_action_provider( + Rc::new(AssistantCodeActionProvider { + editor: cx.view().downgrade(), + workspace: workspace.downgrade(), + thread_store, + }), + cx, + ); + + // Remove the Assistant1 code action provider, as it still might be registered. + editor.remove_code_action_provider("assistant".into(), cx); + } else { + editor + .remove_code_action_provider(ASSISTANT_CODE_ACTION_PROVIDER_ID.into(), cx); + } }); } } @@ -1581,7 +1595,13 @@ struct AssistantCodeActionProvider { thread_store: Option>, } +const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant2"; + impl CodeActionProvider for AssistantCodeActionProvider { + fn id(&self) -> Arc { + ASSISTANT_CODE_ACTION_PROVIDER_ID.into() + } + fn code_actions( &self, buffer: &Model, diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index cd4c5899ee773..f1092f16deb93 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -281,7 +281,13 @@ impl Render for MessageEditor { }) .child( PopoverMenu::new("inline-context-picker") - .menu(move |_cx| Some(inline_context_picker.clone())) + .menu(move |cx| { + inline_context_picker.update(cx, |this, cx| { + this.init(cx); + }); + + Some(inline_context_picker.clone()) + }) .attach(gpui::Corner::TopLeft) .anchor(gpui::Corner::BottomLeft) .offset(gpui::Point { diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index c3933cf4590e7..b5a42da85b325 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -132,6 +132,10 @@ impl Thread { self.messages.iter() } + pub fn is_streaming(&self) -> bool { + !self.pending_completions.is_empty() + } + pub fn tools(&self) -> &Arc { &self.tools } @@ -502,6 +506,17 @@ impl Thread { }; } } + + /// Cancels the last pending completion, if there are any pending. + /// + /// Returns whether a completion was canceled. + pub fn cancel_last_completion(&mut self) -> bool { + if let Some(_last_completion) = self.pending_completions.pop() { + true + } else { + false + } + } } #[derive(Debug, Clone)] diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs index e0115cc5d0f01..830ea3673a537 100644 --- a/crates/collab/src/llm.rs +++ b/crates/collab/src/llm.rs @@ -470,6 +470,8 @@ async fn predict_edits( .replace("", &outline_prefix) .replace("", ¶ms.input_events) .replace("", ¶ms.input_excerpt); + + let request_start = std::time::Instant::now(); let mut response = fireworks::complete( &state.http_client, api_url, @@ -486,13 +488,18 @@ async fn predict_edits( }, ) .await?; + let duration = request_start.elapsed(); + + let choice = response + .completion + .choices + .pop() + .context("no output from completion response")?; state.executor.spawn_detached({ let kinesis_client = state.kinesis_client.clone(); let kinesis_stream = state.config.kinesis_stream.clone(); - let headers = response.headers.clone(); let model = model.clone(); - async move { SnowflakeRow::new( "Fireworks Completion Requested", @@ -501,7 +508,9 @@ async fn predict_edits( claims.system_id.clone(), json!({ "model": model.to_string(), - "headers": headers, + "headers": response.headers, + "usage": response.completion.usage, + "duration": duration.as_secs_f64(), }), ) .write(&kinesis_client, &kinesis_stream) @@ -510,11 +519,6 @@ async fn predict_edits( } }); - let choice = response - .completion - .choices - .pop() - .context("no output from completion response")?; Ok(Json(PredictEditsResponse { output_excerpt: choice.text, })) diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 773ee97a9d2ed..350b1d41bec89 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -1007,7 +1007,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes fake_language_server.start_progress("the-token").await; executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( lsp::WorkDoneProgressReport { @@ -1041,7 +1041,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( lsp::WorkDoneProgressReport { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index ce5b2d5ad65bf..30c4cedacbb12 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -3900,7 +3900,7 @@ async fn test_collaborating_with_diagnostics( .receive_notification::() .await; fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -3920,7 +3920,7 @@ async fn test_collaborating_with_diagnostics( .await .unwrap(); fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -3994,7 +3994,7 @@ async fn test_collaborating_with_diagnostics( // Simulate a language server reporting more errors for a file. fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![ @@ -4088,7 +4088,7 @@ async fn test_collaborating_with_diagnostics( // Simulate a language server reporting no errors for a file. fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![], @@ -4183,7 +4183,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }) .await .unwrap(); - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-disk-based-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( lsp::WorkDoneProgressBegin { @@ -4194,7 +4194,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }); for file_name in file_names { fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(Path::new("/test").join(file_name)).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -4207,7 +4207,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }, ); } - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-disk-based-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( lsp::WorkDoneProgressEnd { message: None }, diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 8b8837fc19a85..23b380ddaaa3f 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -1221,7 +1221,7 @@ impl RandomizedTest for ProjectCollaborationTest { id, guest_project.remote_id(), ); - assert_eq!(guest_snapshot.repositories().collect::>(), host_snapshot.repositories().collect::>(), + assert_eq!(guest_snapshot.repositories().iter().collect::>(), host_snapshot.repositories().iter().collect::>(), "{} has different repositories than the host for worktree {:?} and project {:?}", client.username, host_snapshot.abs_path(), diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index c4d7e1cc1ca03..9ffb5f3fb9691 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -270,7 +270,7 @@ impl RegisteredBuffer { server .lsp .notify::( - lsp::DidChangeTextDocumentParams { + &lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( buffer.uri.clone(), buffer.snapshot_version, @@ -460,10 +460,14 @@ impl Copilot { server .on_notification::(|_, _| { /* Silence the notification */ }) .detach(); + + let configuration = lsp::DidChangeConfigurationParams { + settings: Default::default(), + }; let server = cx .update(|cx| { let params = server.default_initialize_params(cx); - server.initialize(params, cx) + server.initialize(params, configuration.into(), cx) })? .await?; @@ -664,7 +668,7 @@ impl Copilot { let snapshot = buffer.read(cx).snapshot(); server .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem { uri: uri.clone(), language_id: language_id.clone(), @@ -712,7 +716,7 @@ impl Copilot { server .lsp .notify::( - lsp::DidSaveTextDocumentParams { + &lsp::DidSaveTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new( registered_buffer.uri.clone(), ), @@ -732,14 +736,14 @@ impl Copilot { server .lsp .notify::( - lsp::DidCloseTextDocumentParams { + &lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(old_uri), }, )?; server .lsp .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( registered_buffer.uri.clone(), registered_buffer.language_id.clone(), @@ -764,7 +768,7 @@ impl Copilot { server .lsp .notify::( - lsp::DidCloseTextDocumentParams { + &lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(buffer.uri), }, ) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index da92bf25e21f9..4391f0a955684 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -34,8 +34,8 @@ pub enum Model { Gpt4, #[serde(alias = "gpt-3.5-turbo", rename = "gpt-3.5-turbo")] Gpt3_5Turbo, - #[serde(alias = "o1-preview", rename = "o1")] - O1Preview, + #[serde(alias = "o1", rename = "o1")] + O1, #[serde(alias = "o1-mini", rename = "o1-mini")] O1Mini, #[serde(alias = "claude-3-5-sonnet", rename = "claude-3.5-sonnet")] @@ -46,7 +46,7 @@ impl Model { pub fn uses_streaming(&self) -> bool { match self { Self::Gpt4o | Self::Gpt4 | Self::Gpt3_5Turbo | Self::Claude3_5Sonnet => true, - Self::O1Mini | Self::O1Preview => false, + Self::O1Mini | Self::O1 => false, } } @@ -55,7 +55,7 @@ impl Model { "gpt-4o" => Ok(Self::Gpt4o), "gpt-4" => Ok(Self::Gpt4), "gpt-3.5-turbo" => Ok(Self::Gpt3_5Turbo), - "o1-preview" => Ok(Self::O1Preview), + "o1" => Ok(Self::O1), "o1-mini" => Ok(Self::O1Mini), "claude-3-5-sonnet" => Ok(Self::Claude3_5Sonnet), _ => Err(anyhow!("Invalid model id: {}", id)), @@ -68,7 +68,7 @@ impl Model { Self::Gpt4 => "gpt-4", Self::Gpt4o => "gpt-4o", Self::O1Mini => "o1-mini", - Self::O1Preview => "o1-preview", + Self::O1 => "o1", Self::Claude3_5Sonnet => "claude-3-5-sonnet", } } @@ -79,7 +79,7 @@ impl Model { Self::Gpt4 => "GPT-4", Self::Gpt4o => "GPT-4o", Self::O1Mini => "o1-mini", - Self::O1Preview => "o1-preview", + Self::O1 => "o1", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", } } @@ -90,7 +90,7 @@ impl Model { Self::Gpt4 => 32768, Self::Gpt3_5Turbo => 12288, Self::O1Mini => 20000, - Self::O1Preview => 20000, + Self::O1 => 20000, Self::Claude3_5Sonnet => 200_000, } } diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 43464310ed804..71f96e9f9bc31 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -95,6 +95,7 @@ impl Render for ProjectDiagnosticsEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let child = if self.path_states.is_empty() { div() + .key_context("EmptyPane") .bg(cx.theme().colors().editor_background) .flex() .items_center() @@ -106,10 +107,8 @@ impl Render for ProjectDiagnosticsEditor { }; div() + .key_context("Diagnostics") .track_focus(&self.focus_handle(cx)) - .when(self.path_states.is_empty(), |el| { - el.key_context("EmptyPane") - }) .size_full() .on_action(cx.listener(Self::toggle_warnings)) .child(child) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ede5916b06fa6..97a4aefa0e0cb 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -1,82 +1,84 @@ //! This module contains all actions supported by [`Editor`]. use super::*; -use gpui::{action_aliases, action_as}; +use gpui::{action_as, action_with_deprecated_aliases}; +use schemars::JsonSchema; use util::serde::default_true; -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectNext { #[serde(default)] pub replace_newest: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectPrevious { #[serde(default)] pub replace_newest: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveToBeginningOfLine { #[serde(default = "default_true")] pub stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectToBeginningOfLine { #[serde(default)] pub(super) stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MovePageUp { #[serde(default)] pub(super) center_cursor: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MovePageDown { #[serde(default)] pub(super) center_cursor: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveToEndOfLine { #[serde(default = "default_true")] pub stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectToEndOfLine { #[serde(default)] pub(super) stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ToggleCodeActions { // Display row from which the action was deployed. #[serde(default)] + #[serde(skip)] pub deployed_from_indicator: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ConfirmCompletion { #[serde(default)] pub item_ix: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ComposeCompletion { #[serde(default)] pub item_ix: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ConfirmCodeAction { #[serde(default)] pub item_ix: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ToggleComments { #[serde(default)] pub advance_downwards: bool, @@ -84,84 +86,87 @@ pub struct ToggleComments { pub ignore_indent: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct FoldAt { + #[serde(skip)] pub buffer_row: MultiBufferRow, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct UnfoldAt { + #[serde(skip)] pub buffer_row: MultiBufferRow, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveUpByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveDownByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectUpByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectDownByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ExpandExcerpts { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ExpandExcerptsUp { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ExpandExcerptsDown { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] + +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ShowCompletions { #[serde(default)] pub(super) trigger: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct HandleInput(pub String); -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct DeleteToNextWordEnd { #[serde(default)] pub ignore_newlines: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct DeleteToPreviousWordStart { #[serde(default)] pub ignore_newlines: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct FoldAtLevel { pub level: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SpawnNearestTask { #[serde(default)] pub reveal: task::RevealStrategy, @@ -389,4 +394,4 @@ gpui::actions!( action_as!(go_to_line, ToggleGoToLine as Toggle); -action_aliases!(editor, OpenSelectedFilename, [OpenFile]); +action_with_deprecated_aliases!(editor, OpenSelectedFilename, ["editor::OpenFile"]); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4427f62b90071..e48586de9c772 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -4299,15 +4299,29 @@ impl Editor { self.available_code_actions.take(); } - pub fn push_code_action_provider( + pub fn add_code_action_provider( &mut self, provider: Rc, cx: &mut ViewContext, ) { + if self + .code_action_providers + .iter() + .any(|existing_provider| existing_provider.id() == provider.id()) + { + return; + } + self.code_action_providers.push(provider); self.refresh_code_actions(cx); } + pub fn remove_code_action_provider(&mut self, id: Arc, cx: &mut ViewContext) { + self.code_action_providers + .retain(|provider| provider.id() != id); + self.refresh_code_actions(cx); + } + fn refresh_code_actions(&mut self, cx: &mut ViewContext) -> Option<()> { let buffer = self.buffer.read(cx); let newest_selection = self.selections.newest_anchor().clone(); @@ -6203,8 +6217,6 @@ impl Editor { pub fn convert_to_title_case(&mut self, _: &ConvertToTitleCase, cx: &mut ViewContext) { self.manipulate_text(cx, |text| { - // Hack to get around the fact that to_case crate doesn't support '\n' as a word boundary - // https://github.com/rutrum/convert-case/issues/16 text.split('\n') .map(|line| line.to_case(Case::Title)) .join("\n") @@ -6225,8 +6237,6 @@ impl Editor { cx: &mut ViewContext, ) { self.manipulate_text(cx, |text| { - // Hack to get around the fact that to_case crate doesn't support '\n' as a word boundary - // https://github.com/rutrum/convert-case/issues/16 text.split('\n') .map(|line| line.to_case(Case::UpperCamel)) .join("\n") @@ -13597,6 +13607,8 @@ pub trait CompletionProvider { } pub trait CodeActionProvider { + fn id(&self) -> Arc; + fn code_actions( &self, buffer: &Model, @@ -13615,6 +13627,10 @@ pub trait CodeActionProvider { } impl CodeActionProvider for Model { + fn id(&self) -> Arc { + "project".into() + } + fn code_actions( &self, buffer: &Model, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e477013e68735..5770d0cf1a5e2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3705,7 +3705,6 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { "}); // Test multiple line, single selection case - // Test code hack that covers the fact that to_case crate doesn't support '\n' as a word boundary cx.set_state(indoc! {" «The quick brown fox jumps over @@ -3719,7 +3718,6 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { "}); // Test multiple line, single selection case - // Test code hack that covers the fact that to_case crate doesn't support '\n' as a word boundary cx.set_state(indoc! {" «The quick brown fox jumps over diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0a468deb901a9..c5a64c7651bd9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -543,8 +543,29 @@ impl EditorElement { // and run the selection logic. modifiers.alt = false; } else { + let scroll_position_row = + position_map.scroll_pixel_position.y / position_map.line_height; + let display_row = (((event.position - gutter_hitbox.bounds.origin).y + + position_map.scroll_pixel_position.y) + / position_map.line_height) + as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point( + DisplayPoint::new(DisplayRow(display_row), 0), + Bias::Right, + ) + .row; + let line_offset_from_top = display_row - scroll_position_row as u32; // if double click is made without alt, open the corresponding excerp - editor.open_excerpts(&OpenExcerpts, cx); + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + false, + cx, + ); return; } } diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index f06841e4453ea..4acadad41e938 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -197,9 +197,10 @@ impl ProjectDiffEditor { let snapshot = worktree.read(cx).snapshot(); let applicable_entries = snapshot .repositories() + .iter() .flat_map(|entry| { entry.status().map(|git_entry| { - (git_entry.status, entry.join(git_entry.repo_path)) + (git_entry.combined_status(), entry.join(git_entry.repo_path)) }) }) .filter_map(|(status, path)| { diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 739fb98226b77..327ebbb5e2659 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -1479,7 +1479,7 @@ pub mod tests { .await .expect("work done progress create request failed"); cx.executor().run_until_parked(); - fake_server.notify::(lsp::ProgressParams { + fake_server.notify::(&lsp::ProgressParams { token: lsp::ProgressToken::String(progress_token.to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( lsp::WorkDoneProgressBegin::default(), @@ -1504,7 +1504,7 @@ pub mod tests { }) .unwrap(); - fake_server.notify::(lsp::ProgressParams { + fake_server.notify::(&lsp::ProgressParams { token: lsp::ProgressToken::String(progress_token.to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( lsp::WorkDoneProgressEnd::default(), diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 8ae5dea7205c2..3f64831909f4e 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -128,9 +128,9 @@ impl Editor { .next_row() .as_f32(); - // If the selections can't all fit on screen, scroll to the newest. + let selections_fit = target_bottom - target_top <= visible_lines; if autoscroll == Autoscroll::newest() - || autoscroll == Autoscroll::fit() && target_bottom - target_top > visible_lines + || (autoscroll == Autoscroll::fit() && !selections_fit) { let newest_selection_top = selections .iter() diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 3831ca963fbb7..23e37a1267bdb 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -331,7 +331,7 @@ impl EditorLspTestContext { } pub fn notify(&self, params: T::Params) { - self.lsp.notify::(params); + self.lsp.notify::(¶ms); } #[cfg(target_os = "windows")] diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index bb890150e578f..c5ce533026d9b 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,6 +1,7 @@ +use crate::status::GitStatusPair; use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use collections::{HashMap, HashSet}; use git2::BranchType; use gpui::SharedString; @@ -15,6 +16,7 @@ use std::{ sync::Arc, }; use sum_tree::MapSeekTarget; +use util::command::new_std_command; use util::ResultExt; #[derive(Clone, Debug, Hash, PartialEq)] @@ -51,6 +53,8 @@ pub trait GitRepository: Send + Sync { /// Returns the path to the repository, typically the `.git` folder. fn dot_git_dir(&self) -> PathBuf; + + fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()>; } impl std::fmt::Debug for dyn GitRepository { @@ -152,7 +156,7 @@ impl GitRepository for RealGitRepository { Ok(_) => Ok(true), Err(e) => match e.code() { git2::ErrorCode::NotFound => Ok(false), - _ => Err(anyhow::anyhow!(e)), + _ => Err(anyhow!(e)), }, } } @@ -196,7 +200,7 @@ impl GitRepository for RealGitRepository { repo.set_head( revision .name() - .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?, + .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?, )?; Ok(()) } @@ -228,6 +232,36 @@ impl GitRepository for RealGitRepository { self.hosting_provider_registry.clone(), ) } + + fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()> { + let working_directory = self + .repository + .lock() + .workdir() + .context("failed to read git work directory")? + .to_path_buf(); + if !stage.is_empty() { + let add = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["add", "--"]) + .args(stage.iter().map(|p| p.as_ref())) + .status()?; + if !add.success() { + return Err(anyhow!("Failed to stage files: {add}")); + } + } + if !unstage.is_empty() { + let rm = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["restore", "--staged", "--"]) + .args(unstage.iter().map(|p| p.as_ref())) + .status()?; + if !rm.success() { + return Err(anyhow!("Failed to unstage files: {rm}")); + } + } + Ok(()) + } } #[derive(Debug, Clone)] @@ -298,18 +332,24 @@ impl GitRepository for FakeGitRepository { let mut entries = state .worktree_statuses .iter() - .filter_map(|(repo_path, status)| { + .filter_map(|(repo_path, status_worktree)| { if path_prefixes .iter() .any(|path_prefix| repo_path.0.starts_with(path_prefix)) { - Some((repo_path.to_owned(), *status)) + Some(( + repo_path.to_owned(), + GitStatusPair { + index_status: None, + worktree_status: Some(*status_worktree), + }, + )) } else { None } }) .collect::>(); - entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); Ok(GitStatus { entries: entries.into(), @@ -363,6 +403,10 @@ impl GitRepository for FakeGitRepository { .with_context(|| format!("failed to get blame for {:?}", path)) .cloned() } + + fn update_index(&self, _stage: &[RepoPath], _unstage: &[RepoPath]) -> Result<()> { + unimplemented!() + } } fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { @@ -398,6 +442,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { pub enum GitFileStatus { Added, Modified, + // TODO conflicts should be represented by the GitStatusPair Conflict, Deleted, Untracked, @@ -426,6 +471,16 @@ impl GitFileStatus { _ => None, } } + + pub fn from_byte(byte: u8) -> Option { + match byte { + b'M' => Some(GitFileStatus::Modified), + b'A' => Some(GitFileStatus::Added), + b'D' => Some(GitFileStatus::Deleted), + b'?' => Some(GitFileStatus::Untracked), + _ => None, + } + } } pub static WORK_DIRECTORY_REPO_PATH: LazyLock = @@ -453,6 +508,12 @@ impl RepoPath { } } +impl std::fmt::Display for RepoPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.to_string_lossy().fmt(f) + } +} + impl From<&Path> for RepoPath { fn from(value: &Path) -> Self { RepoPath::new(value.into()) diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 0d62cfaae9df5..de574f5d2121a 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -2,9 +2,33 @@ use crate::repository::{GitFileStatus, RepoPath}; use anyhow::{anyhow, Result}; use std::{path::Path, process::Stdio, sync::Arc}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitStatusPair { + // Not both `None`. + pub index_status: Option, + pub worktree_status: Option, +} + +impl GitStatusPair { + pub fn is_staged(&self) -> Option { + match (self.index_status, self.worktree_status) { + (Some(_), None) => Some(true), + (None, Some(_)) => Some(false), + (Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false), + (Some(_), Some(_)) => None, + (None, None) => unreachable!(), + } + } + + // TODO reconsider uses of this + pub fn combined(&self) -> GitFileStatus { + self.index_status.or(self.worktree_status).unwrap() + } +} + #[derive(Clone)] pub struct GitStatus { - pub entries: Arc<[(RepoPath, GitFileStatus)]>, + pub entries: Arc<[(RepoPath, GitStatusPair)]>, } impl GitStatus { @@ -20,6 +44,7 @@ impl GitStatus { "status", "--porcelain=v1", "--untracked-files=all", + "--no-renames", "-z", ]) .args(path_prefixes.iter().map(|path_prefix| { @@ -47,36 +72,32 @@ impl GitStatus { let mut entries = stdout .split('\0') .filter_map(|entry| { - if entry.is_char_boundary(3) { - let (status, path) = entry.split_at(3); - let status = status.trim(); - Some(( - RepoPath(Path::new(path).into()), - match status { - "A" => GitFileStatus::Added, - "M" => GitFileStatus::Modified, - "D" => GitFileStatus::Deleted, - "??" => GitFileStatus::Untracked, - _ => return None, - }, - )) - } else { - None + let sep = entry.get(2..3)?; + if sep != " " { + return None; + }; + let path = &entry[3..]; + let status = entry[0..2].as_bytes(); + let index_status = GitFileStatus::from_byte(status[0]); + let worktree_status = GitFileStatus::from_byte(status[1]); + if (index_status, worktree_status) == (None, None) { + return None; } + let path = RepoPath(Path::new(path).into()); + Some(( + path, + GitStatusPair { + index_status, + worktree_status, + }, + )) }) .collect::>(); - entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); Ok(Self { entries: entries.into(), }) } - - pub fn get(&self, path: &Path) -> Option { - self.entries - .binary_search_by(|(repo_path, _)| repo_path.0.as_ref().cmp(path)) - .ok() - .map(|index| self.entries[index].1) - } } impl Default for GitStatus { diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 120ca92857a04..0c357cb436f05 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -17,6 +17,7 @@ anyhow.workspace = true collections.workspace = true db.workspace = true editor.workspace = true +futures.workspace = true git.workspace = true gpui.workspace = true language.workspace = true @@ -27,6 +28,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +sum_tree.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/git_ui/TODO.md b/crates/git_ui/TODO.md deleted file mode 100644 index efbdcf494cc38..0000000000000 --- a/crates/git_ui/TODO.md +++ /dev/null @@ -1,45 +0,0 @@ -### General - -- [x] Disable staging and committing actions for read-only projects - -### List - -- [x] Add uniform list -- [x] Git status item -- [ ] Directory item -- [x] Scrollbar -- [ ] Add indent size setting -- [ ] Add tree settings - -### List Items - -- [x] Checkbox for staging -- [x] Git status icon -- [ ] Context menu - - [ ] Discard Changes - - --- - - [ ] Ignore - - [ ] Ignore directory - - --- - - [ ] Copy path - - [ ] Copy relative path - - --- - - [ ] Reveal in Finder - -### Commit Editor - -- [ ] Add commit editor -- [ ] Add commit message placeholder & add commit message to store -- [ ] Add a way to get the current collaborators & automatically add them to the commit message as co-authors -- [ ] Add action to clear commit message -- [x] Swap commit button between "Commit" and "Commit All" based on modifier key - -### Component Updates - -- [ ] ChangedLineCount (new) - - takes `lines_added: usize, lines_removed: usize`, returns a added/removed badge -- [x] GitStatusIcon (new) -- [ ] Checkbox - - update checkbox design -- [ ] ScrollIndicator - - shows a gradient overlay when more content is available to be scrolled diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e57145f98880b..e52aba0108667 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,31 +1,21 @@ +use crate::{first_repository_in_project, first_worktree_repository}; use crate::{ - git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitStagedChanges, GitState, - RevertAll, StageAll, UnstageAll, + git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitChanges, GitState, + GitViewMode, RevertAll, StageAll, ToggleStaged, UnstageAll, }; use anyhow::{Context as _, Result}; use db::kvp::KEY_VALUE_STORE; use editor::Editor; -use git::{ - diff::DiffHunk, - repository::{GitFileStatus, RepoPath}, -}; +use git::repository::{GitFileStatus, RepoPath}; +use git::status::GitStatusPair; use gpui::*; use language::Buffer; -use menu::{SelectNext, SelectPrev}; -use project::{EntryKind, Fs, Project, ProjectEntryId, WorktreeId}; +use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; +use project::{Fs, Project}; use serde::{Deserialize, Serialize}; use settings::Settings as _; -use std::{ - cell::OnceCell, - collections::HashSet, - ffi::OsStr, - ops::{Deref, Range}, - path::PathBuf, - rc::Rc, - sync::Arc, - time::Duration, - usize, -}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize}; use theme::ThemeSettings; use ui::{ prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip, @@ -35,9 +25,18 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace, }; -use worktree::StatusEntry; -actions!(git_panel, [ToggleFocus, OpenEntryMenu]); +actions!( + git_panel, + [ + Close, + ToggleFocus, + OpenMenu, + OpenSelected, + FocusEditor, + FocusChanges + ] +); const GIT_PANEL_KEY: &str = "GitPanel"; @@ -59,35 +58,21 @@ pub enum Event { Focus, } -#[derive(Default, Debug, PartialEq, Eq, Clone)] -pub enum ViewMode { - #[default] - List, - Tree, +#[derive(Serialize, Deserialize)] +struct SerializedGitPanel { + width: Option, } -pub struct GitStatusEntry {} - #[derive(Debug, PartialEq, Eq, Clone)] -struct EntryDetails { - filename: String, - display_name: String, - path: RepoPath, - kind: EntryKind, +pub struct GitListEntry { depth: usize, - is_expanded: bool, - status: Option, - hunks: Rc>>, - index: usize, -} - -#[derive(Serialize, Deserialize)] -struct SerializedGitPanel { - width: Option, + display_name: String, + repo_path: RepoPath, + status: GitStatusPair, + is_staged: Option, } pub struct GitPanel { - // workspace: WeakView, current_modifiers: Modifiers, focus_handle: FocusHandle, fs: Arc, @@ -96,56 +81,21 @@ pub struct GitPanel { project: Model, scroll_handle: UniformListScrollHandle, scrollbar_state: ScrollbarState, - selected_item: Option, - view_mode: ViewMode, + selected_entry: Option, show_scrollbar: bool, - // TODO Reintroduce expanded directories, once we're deriving directories from paths - // expanded_dir_ids: HashMap>, + rebuild_requested: Arc, git_state: Model, commit_editor: View, - // The entries that are currently shown in the panel, aka - // not hidden by folding or such - visible_entries: Vec, + /// The visible entries in the list, accounting for folding & expanded state. + /// + /// At this point it doesn't matter what repository the entry belongs to, + /// as only one repositories' entries are visible in the list at a time. + visible_entries: Vec, + all_staged: Option, width: Option, - // git_diff_editor: Option>, - // git_diff_editor_updates: Task<()>, reveal_in_editor: Task<()>, } -#[derive(Debug, Clone)] -struct WorktreeEntries { - worktree_id: WorktreeId, - // TODO support multiple repositories per worktree - // work_directory: worktree::WorkDirectory, - visible_entries: Vec, - paths: Rc>>, -} - -#[derive(Debug, Clone)] -struct GitPanelEntry { - entry: worktree::StatusEntry, - hunks: Rc>>, -} - -impl Deref for GitPanelEntry { - type Target = worktree::StatusEntry; - - fn deref(&self) -> &Self::Target { - &self.entry - } -} - -impl WorktreeEntries { - fn paths(&self) -> &HashSet { - self.paths.get_or_init(|| { - self.visible_entries - .iter() - .map(|e| (e.entry.repo_path.clone())) - .collect() - }) - } -} - impl GitPanel { pub fn load( workspace: WeakView, @@ -155,12 +105,14 @@ impl GitPanel { } pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { - let git_state = GitState::get_global(cx); - let fs = workspace.app_state().fs.clone(); - // let weak_workspace = workspace.weak_handle(); let project = workspace.project().clone(); let language_registry = workspace.app_state().languages.clone(); + let git_state = GitState::get_global(cx); + let current_commit_message = { + let state = git_state.read(cx); + state.commit_message.clone() + }; let git_panel = cx.new_view(|cx: &mut ViewContext| { let focus_handle = cx.focus_handle(); @@ -169,36 +121,103 @@ impl GitPanel { this.hide_scrollbar(cx); }) .detach(); - cx.subscribe(&project, |this, _, event, cx| match event { - project::Event::WorktreeRemoved(_id) => { - // this.expanded_dir_ids.remove(id); - this.update_visible_entries(None, None, cx); - cx.notify(); - } - project::Event::WorktreeOrderChanged => { - this.update_visible_entries(None, None, cx); - cx.notify(); - } - project::Event::WorktreeUpdatedEntries(id, _) - | project::Event::WorktreeAdded(id) - | project::Event::WorktreeUpdatedGitRepositories(id) => { - this.update_visible_entries(Some(*id), None, cx); - cx.notify(); - } - project::Event::Closed => { - // this.git_diff_editor_updates = Task::ready(()); - this.reveal_in_editor = Task::ready(()); - // this.expanded_dir_ids.clear(); - this.visible_entries.clear(); - // this.git_diff_editor = None; - } - _ => {} + cx.subscribe(&project, move |this, project, event, cx| { + use project::Event; + + let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + snapshot.id() + }); + let first_repo_in_project = first_repository_in_project(&project, cx); + + // TODO: Don't get another git_state here + // was running into a borrow issue + let git_state = GitState::get_global(cx); + + match event { + project::Event::WorktreeRemoved(id) => { + git_state.update(cx, |state, _| { + state.all_repositories.remove(id); + let Some((worktree_id, _, _)) = state.active_repository.as_ref() else { + return; + }; + if worktree_id == id { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeOrderChanged => { + // activate the new first worktree if the first was moved + let Some(first_id) = first_worktree_id else { + return; + }; + git_state.update(cx, |state, _| { + if !state + .active_repository + .as_ref() + .is_some_and(|(id, _, _)| id == &first_id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + Event::WorktreeAdded(id) => { + git_state.update(cx, |state, cx| { + let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else { + return; + }; + let snapshot = worktree.read(cx).snapshot(); + state + .all_repositories + .insert(*id, snapshot.repositories().clone()); + }); + let Some(first_id) = first_worktree_id else { + return; + }; + git_state.update(cx, |state, _| { + if !state + .active_repository + .as_ref() + .is_some_and(|(id, _, _)| id == &first_id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeUpdatedEntries(id, _) => { + git_state.update(cx, |state, _| { + if state + .active_repository + .as_ref() + .is_some_and(|(active_id, _, _)| active_id == id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeUpdatedGitRepositories(_) => { + let Some(first) = first_repo_in_project else { + return; + }; + git_state.update(cx, |state, _| { + state.active_repository = Some(first); + this.schedule_update(); + }); + } + project::Event::Closed => { + this.reveal_in_editor = Task::ready(()); + this.visible_entries.clear(); + // TODO cancel/clear task? + } + _ => {} + }; }) .detach(); - let state = git_state.read(cx); - let current_commit_message = state.commit_message.clone(); - let commit_editor = cx.new_view(|cx| { let theme = ThemeSettings::get_global(cx); @@ -220,7 +239,6 @@ impl GitPanel { } else { commit_editor.set_text("", cx); } - // commit_editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); commit_editor.set_use_autoclose(false); commit_editor.set_show_gutter(false, cx); commit_editor.set_show_wrap_guides(false, cx); @@ -250,29 +268,60 @@ impl GitPanel { let scroll_handle = UniformListScrollHandle::new(); + git_state.update(cx, |state, cx| { + let mut visible_worktrees = project.read(cx).visible_worktrees(cx); + let Some(first_worktree) = visible_worktrees.next() else { + return; + }; + drop(visible_worktrees); + let snapshot = first_worktree.read(cx).snapshot(); + + if let Some((repo, git_repo)) = + first_worktree_repository(&project, snapshot.id(), cx) + { + state.activate_repository(snapshot.id(), repo, git_repo); + } + }); + + let rebuild_requested = Arc::new(AtomicBool::new(false)); + let flag = rebuild_requested.clone(); + let handle = cx.view().downgrade(); + cx.spawn(|_, mut cx| async move { + loop { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + if flag.load(Ordering::Relaxed) { + if let Some(this) = handle.upgrade() { + this.update(&mut cx, |this, cx| { + this.update_visible_entries(cx); + }) + .ok(); + } + flag.store(false, Ordering::Relaxed); + } + } + }) + .detach(); + let mut git_panel = Self { - // workspace: weak_workspace, focus_handle: cx.focus_handle(), fs, pending_serialization: Task::ready(None), visible_entries: Vec::new(), + all_staged: None, current_modifiers: cx.modifiers(), - // expanded_dir_ids: Default::default(), width: Some(px(360.)), scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()), scroll_handle, - selected_item: None, - view_mode: ViewMode::default(), + selected_entry: None, show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, - // git_diff_editor: Some(diff_display_editor(cx)), - // git_diff_editor_updates: Task::ready(()), + rebuild_requested, commit_editor, git_state, reveal_in_editor: Task::ready(()), project, }; - git_panel.update_visible_entries(None, None, cx); + git_panel.schedule_update(); git_panel }); @@ -280,6 +329,7 @@ impl GitPanel { } fn serialize(&mut self, cx: &mut ViewContext) { + // TODO: we can store stage status here let width = self.width; self.pending_serialization = cx.background_executor().spawn( async move { @@ -295,14 +345,31 @@ impl GitPanel { ); } - fn dispatch_context(&self) -> KeyContext { + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("GitPanel"); - dispatch_context.add("menu"); + + if self.is_focused(cx) { + dispatch_context.add("menu"); + dispatch_context.add("ChangesList"); + } + + if self.commit_editor.read(cx).is_focused(cx) { + dispatch_context.add("CommitEditor"); + } dispatch_context } + fn is_focused(&self, cx: &ViewContext) -> bool { + cx.focused() + .map_or(false, |focused| self.focus_handle == focused) + } + + fn close_panel(&mut self, _: &Close, cx: &mut ViewContext) { + cx.emit(PanelEvent::Close); + } + fn focus_in(&mut self, cx: &mut ViewContext) { if !self.focus_handle.contains_focused(cx) { cx.emit(Event::Focus); @@ -347,119 +414,211 @@ impl GitPanel { } fn calculate_depth_and_difference( - entry: &StatusEntry, - visible_worktree_entries: &HashSet, + repo_path: &RepoPath, + visible_entries: &HashSet, ) -> (usize, usize) { - let (depth, difference) = entry - .repo_path - .ancestors() - .skip(1) // Skip the entry itself - .find_map(|ancestor| { - if let Some(parent_entry) = visible_worktree_entries.get(ancestor) { - let entry_path_components_count = entry.repo_path.components().count(); - let parent_path_components_count = parent_entry.components().count(); - let difference = entry_path_components_count - parent_path_components_count; - let depth = parent_entry - .ancestors() - .skip(1) - .filter(|ancestor| visible_worktree_entries.contains(*ancestor)) - .count(); - Some((depth + 1, difference)) - } else { - None - } - }) - .unwrap_or((0, 0)); + let ancestors = repo_path.ancestors().skip(1); + for ancestor in ancestors { + if let Some(parent_entry) = visible_entries.get(ancestor) { + let entry_component_count = repo_path.components().count(); + let parent_component_count = parent_entry.components().count(); + + let difference = entry_component_count - parent_component_count; + + let parent_depth = parent_entry + .ancestors() + .skip(1) // Skip the parent itself + .filter(|ancestor| visible_entries.contains(*ancestor)) + .count(); - (depth, difference) + return (parent_depth + 1, difference); + } + } + + (0, 0) } - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum::(); + fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext) { + if let Some(selected_entry) = self.selected_entry { + self.scroll_handle + .scroll_to_item(selected_entry, ScrollStrategy::Center); + } + + cx.notify(); + } + + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + if self.visible_entries.first().is_some() { + self.selected_entry = Some(0); + self.scroll_to_selected_entry(cx); + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + let item_count = self.visible_entries.len(); if item_count == 0 { return; } - let selection = match self.selected_item { - Some(i) => { - if i < item_count - 1 { - self.selected_item = Some(i + 1); - i + 1 - } else { - self.selected_item = Some(0); - 0 - } - } - None => { - self.selected_item = Some(0); - 0 - } - }; - self.scroll_handle - .scroll_to_item(selection, ScrollStrategy::Center); - let mut hunks = None; - self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { - hunks = Some(entry.hunks.clone()); - }); - if let Some(hunks) = hunks { - self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + if let Some(selected_entry) = self.selected_entry { + let new_selected_entry = if selected_entry > 0 { + selected_entry - 1 + } else { + self.selected_entry = Some(item_count - 1); + item_count - 1 + }; + + self.selected_entry = Some(new_selected_entry); + + self.scroll_to_selected_entry(cx); } cx.notify(); } - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum::(); + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let item_count = self.visible_entries.len(); if item_count == 0 { return; } - let selection = match self.selected_item { - Some(i) => { - if i > 0 { - self.selected_item = Some(i - 1); - i - 1 - } else { - self.selected_item = Some(item_count - 1); - item_count - 1 - } - } - None => { - self.selected_item = Some(0); - 0 - } - }; - self.scroll_handle - .scroll_to_item(selection, ScrollStrategy::Center); - let mut hunks = None; - self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { - hunks = Some(entry.hunks.clone()); + if let Some(selected_entry) = self.selected_entry { + let new_selected_entry = if selected_entry < item_count - 1 { + selected_entry + 1 + } else { + selected_entry + }; + + self.selected_entry = Some(new_selected_entry); + + self.scroll_to_selected_entry(cx); + } + + cx.notify(); + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + if self.visible_entries.last().is_some() { + self.selected_entry = Some(self.visible_entries.len() - 1); + self.scroll_to_selected_entry(cx); + } + } + + fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { + self.commit_editor.update(cx, |editor, cx| { + editor.focus(cx); }); - if let Some(hunks) = hunks { - self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + cx.notify(); + } + + fn select_first_entry(&mut self, cx: &mut ViewContext) { + if !self.no_entries() && self.selected_entry.is_none() { + self.selected_entry = Some(0); + self.scroll_to_selected_entry(cx); + cx.notify(); } + } + fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext) { + self.select_first_entry(cx); + + cx.focus_self(); cx.notify(); } -} -impl GitPanel { - fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext) { - // TODO: Implement stage all - println!("Stage all triggered"); + fn get_selected_entry(&self) -> Option<&GitListEntry> { + self.selected_entry + .and_then(|i| self.visible_entries.get(i)) + } + + fn toggle_staged_for_entry(&self, entry: &GitListEntry, cx: &mut ViewContext) { + self.git_state + .clone() + .update(cx, |state, _| match entry.status.is_staged() { + Some(true) | None => state.unstage_entry(entry.repo_path.clone()), + Some(false) => state.stage_entry(entry.repo_path.clone()), + }); + cx.notify(); + } + + fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext) { + if let Some(selected_entry) = self.get_selected_entry() { + self.toggle_staged_for_entry(&selected_entry, cx); + } + } + + fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + println!("Open Selected triggered!"); + let selected_entry = self.selected_entry; + + if let Some(entry) = selected_entry.and_then(|i| self.visible_entries.get(i)) { + self.open_entry(entry); + + cx.notify(); + } + } + + fn open_entry(&self, entry: &GitListEntry) { + // TODO: Open entry or entry's changes. + println!("Open {} triggered!", entry.repo_path); + + // cx.emit(project_panel::Event::OpenedEntry { + // entry_id, + // focus_opened_item, + // allow_preview, + // }); + // + // workspace + // .open_path_preview( + // ProjectPath { + // worktree_id, + // path: file_path.clone(), + // }, + // None, + // focus_opened_item, + // allow_preview, + // cx, + // ) + // .detach_and_prompt_err("Failed to open file", cx, move |e, _| { + // match e.error_code() { + // ErrorCode::Disconnected => if is_via_ssh { + // Some("Disconnected from SSH host".to_string()) + // } else { + // Some("Disconnected from remote project".to_string()) + // }, + // ErrorCode::UnsharedItem => Some(format!( + // "{} is not shared by the host. This could be because it has been marked as `private`", + // file_path.display() + // )), + // _ => None, + // } + // }); } - fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext) { - // TODO: Implement unstage all - println!("Unstage all triggered"); + fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext) { + let to_stage = self + .visible_entries + .iter_mut() + .filter_map(|entry| { + let is_unstaged = !entry.is_staged.unwrap_or(false); + entry.is_staged = Some(true); + is_unstaged.then(|| entry.repo_path.clone()) + }) + .collect(); + self.all_staged = Some(true); + self.git_state + .update(cx, |state, _| state.stage_entries(to_stage)); + } + + fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext) { + // This should only be called when all entries are staged. + for entry in &mut self.visible_entries { + entry.is_staged = Some(false); + } + self.all_staged = Some(false); + self.git_state.update(cx, |state, _| { + state.unstage_all(); + }); } fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext) { @@ -468,14 +627,14 @@ impl GitPanel { } fn clear_message(&mut self, cx: &mut ViewContext) { - let git_state = self.git_state.clone(); - git_state.update(cx, |state, _cx| state.clear_message()); + self.git_state + .update(cx, |state, _cx| state.clear_commit_message()); self.commit_editor .update(cx, |editor, cx| editor.set_text("", cx)); } /// Commit all staged changes - fn commit_staged_changes(&mut self, _: &CommitStagedChanges, cx: &mut ViewContext) { + fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext) { self.clear_message(cx); // TODO: Implement commit all staged @@ -490,355 +649,115 @@ impl GitPanel { println!("Commit all changes triggered"); } - fn all_staged(&self) -> bool { - // TODO: Implement all_staged - true - } - fn no_entries(&self) -> bool { self.visible_entries.is_empty() } fn entry_count(&self) -> usize { - self.visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum() + self.visible_entries.len() } fn for_each_visible_entry( &self, range: Range, cx: &mut ViewContext, - mut callback: impl FnMut(usize, EntryDetails, &mut ViewContext), + mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext), ) { - let mut ix = 0; - for worktree_entries in &self.visible_entries { - if ix >= range.end { - return; - } + let visible_entries = &self.visible_entries; - if ix + worktree_entries.visible_entries.len() <= range.start { - ix += worktree_entries.visible_entries.len(); - continue; - } + for (ix, entry) in visible_entries + .iter() + .enumerate() + .skip(range.start) + .take(range.end - range.start) + { + let status = entry.status.clone(); + let filename = entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned()); + + let details = GitListEntry { + repo_path: entry.repo_path.clone(), + status, + depth: 0, + display_name: filename, + is_staged: entry.is_staged, + }; - let end_ix = range.end.min(ix + worktree_entries.visible_entries.len()); - // let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - if let Some(worktree) = self - .project - .read(cx) - .worktree_for_id(worktree_entries.worktree_id, cx) - { - let snapshot = worktree.read(cx).snapshot(); - let root_name = OsStr::new(snapshot.root_name()); - // let expanded_entry_ids = self - // .expanded_dir_ids - // .get(&snapshot.id()) - // .map(Vec::as_slice) - // .unwrap_or(&[]); - - let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - let entries = worktree_entries.paths(); - - let index_start = entry_range.start; - for (i, entry) in worktree_entries.visible_entries[entry_range] - .iter() - .enumerate() - { - let index = index_start + i; - let status = entry.status; - let is_expanded = true; //expanded_entry_ids.binary_search(&entry.id).is_ok(); - - let (depth, difference) = Self::calculate_depth_and_difference(entry, entries); - - let filename = match difference { - diff if diff > 1 => entry - .repo_path - .iter() - .skip(entry.repo_path.components().count() - diff) - .collect::() - .to_str() - .unwrap_or_default() - .to_string(), - _ => entry - .repo_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| root_name.to_string_lossy().to_string()), - }; - - let details = EntryDetails { - filename, - display_name: entry.repo_path.to_string_lossy().into_owned(), - // TODO get it from StatusEntry? - kind: EntryKind::File, - is_expanded, - path: entry.repo_path.clone(), - status: Some(status), - hunks: entry.hunks.clone(), - depth, - index, - }; - callback(ix, details, cx); - } - } - ix = end_ix; + callback(ix, details, cx); } } - // TODO: Update expanded directory state - // TODO: Updates happen in the main loop, could be long for large workspaces + fn schedule_update(&mut self) { + self.rebuild_requested.store(true, Ordering::Relaxed); + } + #[track_caller] - fn update_visible_entries( - &mut self, - for_worktree: Option, - _new_selected_entry: Option<(WorktreeId, ProjectEntryId)>, - cx: &mut ViewContext, - ) { - let project = self.project.read(cx); - let mut old_entries_removed = false; - let mut after_update = Vec::new(); - self.visible_entries - .retain(|worktree_entries| match for_worktree { - Some(for_worktree) => { - if worktree_entries.worktree_id == for_worktree { - old_entries_removed = true; - false - } else if old_entries_removed { - after_update.push(worktree_entries.clone()); - false - } else { - true - } + fn update_visible_entries(&mut self, cx: &mut ViewContext) { + let git_state = self.git_state.read(cx); + + self.visible_entries.clear(); + + let Some((_, repo, _)) = git_state.active_repository().as_ref() else { + // Just clear entries if no repository is active. + cx.notify(); + return; + }; + + // First pass - collect all paths + let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); + + // Second pass - create entries with proper depth calculation + let mut all_staged = None; + for (ix, entry) in repo.status().enumerate() { + let (depth, difference) = + Self::calculate_depth_and_difference(&entry.repo_path, &path_set); + let is_staged = entry.status.is_staged(); + all_staged = if ix == 0 { + is_staged + } else { + match (all_staged, is_staged) { + (None, _) | (_, None) => None, + (Some(a), Some(b)) => (a == b).then_some(a), } - None => false, - }); - for worktree in project.visible_worktrees(cx) { - let snapshot = worktree.read(cx).snapshot(); - let worktree_id = snapshot.id(); + }; - if for_worktree.is_some() && for_worktree != Some(worktree_id) { - continue; - } + let display_name = if difference > 1 { + // Show partial path for deeply nested files + entry + .repo_path + .as_ref() + .iter() + .skip(entry.repo_path.components().count() - difference) + .collect::() + .to_string_lossy() + .into_owned() + } else { + // Just show filename + entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + }; - let mut visible_worktree_entries = Vec::new(); - // Only use the first repository for now - let repositories = snapshot.repositories().take(1); - // let mut work_directory = None; - for repository in repositories { - visible_worktree_entries.extend(repository.status()); - // work_directory = Some(worktree::WorkDirectory::clone(repository)); - } + let entry = GitListEntry { + depth, + display_name, + repo_path: entry.repo_path, + status: entry.status, + is_staged, + }; - // TODO use the GitTraversal - // let mut visible_worktree_entries = snapshot - // .entries(false, 0) - // .filter(|entry| !entry.is_external) - // .filter(|entry| entry.git_status.is_some()) - // .cloned() - // .collect::>(); - // snapshot.propagate_git_statuses(&mut visible_worktree_entries); - // project::sort_worktree_entries(&mut visible_worktree_entries); - - if !visible_worktree_entries.is_empty() { - self.visible_entries.push(WorktreeEntries { - worktree_id, - // work_directory: work_directory.unwrap(), - visible_entries: visible_worktree_entries - .into_iter() - .map(|entry| GitPanelEntry { - entry, - hunks: Rc::default(), - }) - .collect(), - paths: Rc::default(), - }); - } + self.visible_entries.push(entry); } - self.visible_entries.extend(after_update); - - // TODO re-implement this - // if let Some((worktree_id, entry_id)) = new_selected_entry { - // self.selected_item = self.visible_entries.iter().enumerate().find_map( - // |(worktree_index, worktree_entries)| { - // if worktree_entries.worktree_id == worktree_id { - // worktree_entries - // .visible_entries - // .iter() - // .position(|entry| entry.id == entry_id) - // .map(|entry_index| { - // worktree_index * worktree_entries.visible_entries.len() - // + entry_index - // }) - // } else { - // None - // } - // }, - // ); - // } - - // let project = self.project.downgrade(); - // self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move { - // cx.background_executor() - // .timer(UPDATE_DEBOUNCE) - // .await; - // let Some(project_buffers) = git_panel - // .update(&mut cx, |git_panel, cx| { - // futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map( - // |worktree_entries| { - // worktree_entries - // .visible_entries - // .iter() - // .filter_map(|entry| { - // let git_status = entry.status; - // let entry_hunks = entry.hunks.clone(); - // let (entry_path, unstaged_changes_task) = - // project.update(cx, |project, cx| { - // let entry_path = ProjectPath { - // worktree_id: worktree_entries.worktree_id, - // path: worktree_entries.work_directory.unrelativize(&entry.repo_path)?, - // }; - // let open_task = - // project.open_path(entry_path.clone(), cx); - // let unstaged_changes_task = - // cx.spawn(|project, mut cx| async move { - // let (_, opened_model) = open_task - // .await - // .context("opening buffer")?; - // let buffer = opened_model - // .downcast::() - // .map_err(|_| { - // anyhow::anyhow!( - // "accessing buffer for entry" - // ) - // })?; - // // TODO added files have noop changes and those are not expanded properly in the multi buffer - // let unstaged_changes = project - // .update(&mut cx, |project, cx| { - // project.open_unstaged_changes( - // buffer.clone(), - // cx, - // ) - // })? - // .await - // .context("opening unstaged changes")?; - - // let hunks = cx.update(|cx| { - // entry_hunks - // .get_or_init(|| { - // match git_status { - // GitFileStatus::Added => { - // let buffer_snapshot = buffer.read(cx).snapshot(); - // let entire_buffer_range = - // buffer_snapshot.anchor_after(0) - // ..buffer_snapshot - // .anchor_before( - // buffer_snapshot.len(), - // ); - // let entire_buffer_point_range = - // entire_buffer_range - // .clone() - // .to_point(&buffer_snapshot); - - // vec![DiffHunk { - // row_range: entire_buffer_point_range - // .start - // .row - // ..entire_buffer_point_range - // .end - // .row, - // buffer_range: entire_buffer_range, - // diff_base_byte_range: 0..0, - // }] - // } - // GitFileStatus::Modified => { - // let buffer_snapshot = - // buffer.read(cx).snapshot(); - // unstaged_changes.read(cx) - // .diff_to_buffer - // .hunks_in_row_range( - // 0..BufferRow::MAX, - // &buffer_snapshot, - // ) - // .collect() - // } - // // TODO support these - // GitFileStatus::Conflict | GitFileStatus::Deleted | GitFileStatus::Untracked => Vec::new(), - // } - // }).clone() - // })?; - - // anyhow::Ok((buffer, unstaged_changes, hunks)) - // }); - // Some((entry_path, unstaged_changes_task)) - // }).ok()??; - // Some((entry_path, unstaged_changes_task)) - // }) - // .map(|(entry_path, open_task)| async move { - // (entry_path, open_task.await) - // }) - // .collect::>() - // }, - // )) - // }) - // .ok() - // else { - // return; - // }; - - // let project_buffers = project_buffers.await; - // if project_buffers.is_empty() { - // return; - // } - // let mut change_sets = Vec::with_capacity(project_buffers.len()); - // if let Some(buffer_update_task) = git_panel - // .update(&mut cx, |git_panel, cx| { - // let editor = git_panel.git_diff_editor.clone()?; - // let multi_buffer = editor.read(cx).buffer().clone(); - // let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len()); - // for (buffer_path, open_result) in project_buffers { - // if let Some((buffer, unstaged_changes, diff_hunks)) = open_result - // .with_context(|| format!("opening buffer {buffer_path:?}")) - // .log_err() - // { - // change_sets.push(unstaged_changes); - // buffers_with_ranges.push(( - // buffer, - // diff_hunks - // .into_iter() - // .map(|hunk| hunk.buffer_range) - // .collect(), - // )); - // } - // } - - // Some(multi_buffer.update(cx, |multi_buffer, cx| { - // multi_buffer.clear(cx); - // multi_buffer.push_multiple_excerpts_with_context_lines( - // buffers_with_ranges, - // DEFAULT_MULTIBUFFER_CONTEXT, - // cx, - // ) - // })) - // }) - // .ok().flatten() - // { - // buffer_update_task.await; - // git_panel - // .update(&mut cx, |git_panel, cx| { - // if let Some(diff_editor) = git_panel.git_diff_editor.as_ref() { - // diff_editor.update(cx, |editor, cx| { - // for change_set in change_sets { - // editor.add_change_set(change_set, cx); - // } - // }); - // } - // }) - // .ok(); - // } - // }); + self.all_staged = all_staged; + // Sort entries by path to maintain consistent order + self.visible_entries + .sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); cx.notify(); } @@ -860,6 +779,7 @@ impl GitPanel { } } +// GitPanel –– Render impl GitPanel { pub fn panel_button( &self, @@ -900,7 +820,11 @@ impl GitPanel { .child( h_flex() .gap_2() - .child(Checkbox::new("all-changes", true.into()).disabled(true)) + .child(Checkbox::new( + "all-changes", + self.all_staged + .map_or(ToggleState::Indeterminate, ToggleState::from), + )) .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)), ) .child(div().flex_grow()) @@ -909,27 +833,50 @@ impl GitPanel { .gap_2() .child( IconButton::new("discard-changes", IconName::Undo) - .tooltip(move |cx| { + .tooltip({ let focus_handle = focus_handle.clone(); - - Tooltip::for_action_in( - "Discard all changes", - &RevertAll, - &focus_handle, - cx, - ) + move |cx| { + Tooltip::for_action_in( + "Discard all changes", + &RevertAll, + &focus_handle, + cx, + ) + } }) .icon_size(IconSize::Small) .disabled(true), ) - .child(if self.all_staged() { - self.panel_button("unstage-all", "Unstage All").on_click( - cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(RevertAll))), - ) + .child(if self.all_staged.unwrap_or(false) { + self.panel_button("unstage-all", "Unstage All") + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Unstage all changes", + &UnstageAll, + &focus_handle, + cx, + ) + } + }) + .on_click( + cx.listener(move |this, _, cx| this.unstage_all(&UnstageAll, cx)), + ) } else { - self.panel_button("stage-all", "Stage All").on_click( - cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))), - ) + self.panel_button("stage-all", "Stage All") + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Stage all changes", + &StageAll, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, cx| this.stage_all(&StageAll, cx))) }), ) } @@ -947,14 +894,14 @@ impl GitPanel { let focus_handle = focus_handle_1.clone(); Tooltip::for_action_in( "Commit all staged changes", - &CommitStagedChanges, + &CommitChanges, &focus_handle, cx, ) }) - .on_click(cx.listener(|this, _: &ClickEvent, cx| { - this.commit_staged_changes(&CommitStagedChanges, cx) - })); + .on_click( + cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)), + ); let commit_all_button = self .panel_button("commit-all-changes", "Commit All") @@ -1063,26 +1010,16 @@ impl GitPanel { } fn render_entries(&self, cx: &mut ViewContext) -> impl IntoElement { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum(); - let selected_entry = self.selected_item; + let entry_count = self.entry_count(); h_flex() .size_full() .overflow_hidden() .child( - uniform_list(cx.view().clone(), "entries", item_count, { + uniform_list(cx.view().clone(), "entries", entry_count, { move |git_panel, range, cx| { let mut items = Vec::with_capacity(range.end - range.start); - git_panel.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(git_panel.render_entry( - id, - Some(details.index) == selected_entry, - details, - cx, - )); + git_panel.for_each_visible_entry(range, cx, |ix, details, cx| { + items.push(git_panel.render_entry(ix, details, cx)); }); items } @@ -1099,19 +1036,21 @@ impl GitPanel { fn render_entry( &self, ix: usize, - selected: bool, - details: EntryDetails, + entry_details: GitListEntry, cx: &ViewContext, ) -> impl IntoElement { - let view_mode = self.view_mode.clone(); - let checkbox_id = ElementId::Name(format!("checkbox_{}", ix).into()); - let is_staged = ToggleState::Selected; + let state = self.git_state.clone(); + let repo_path = entry_details.repo_path.clone(); + let selected = self.selected_entry == Some(ix); + + // TODO revisit, maybe use a different status here? + let status = entry_details.status.combined(); + let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into()); + let checkbox_id = + ElementId::Name(format!("checkbox_{}", entry_details.display_name).into()); + let view_mode = state.read(cx).list_view_mode.clone(); let handle = cx.view().downgrade(); - // TODO: At this point, an entry should really have a status. - // Is this fixed with the new git status stuff? - let status = details.status.unwrap_or(GitFileStatus::Untracked); - let end_slot = h_flex() .invisible() .when(selected, |this| this.visible()) @@ -1127,7 +1066,7 @@ impl GitPanel { ); let mut entry = h_flex() - .id(("git-panel-entry", ix)) + .id(entry_id) .group("git-panel-entry") .h(px(28.)) .w_full() @@ -1140,8 +1079,8 @@ impl GitPanel { this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) }); - if view_mode == ViewMode::Tree { - entry = entry.pl(px(12. + 12. * details.depth as f32)) + if view_mode == GitViewMode::Tree { + entry = entry.pl(px(12. + 12. * entry_details.depth as f32)) } else { entry = entry.pl(px(12.)) } @@ -1151,129 +1090,79 @@ impl GitPanel { } entry = entry - .child(Checkbox::new(checkbox_id, is_staged)) + .child( + Checkbox::new( + checkbox_id, + entry_details + .is_staged + .map_or(ToggleState::Indeterminate, ToggleState::from), + ) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let handle = handle.clone(); + let repo_path = repo_path.clone(); + move |toggle, cx| { + let Some(this) = handle.upgrade() else { + return; + }; + this.update(cx, |this, _| { + this.visible_entries[ix].is_staged = match *toggle { + ToggleState::Selected => Some(true), + ToggleState::Unselected => Some(false), + ToggleState::Indeterminate => None, + } + }); + state.update(cx, { + let repo_path = repo_path.clone(); + move |state, _| match toggle { + ToggleState::Selected | ToggleState::Indeterminate => { + state.stage_entry(repo_path); + } + ToggleState::Unselected => state.unstage_entry(repo_path), + } + }); + } + }), + ) .child(git_status_icon(status)) .child( h_flex() - .gap_1p5() .when(status == GitFileStatus::Deleted, |this| { this.text_color(cx.theme().colors().text_disabled) .line_through() }) - .child(details.display_name.clone()), + .when_some(repo_path.parent(), |this, parent| { + let parent_str = parent.to_string_lossy(); + if !parent_str.is_empty() { + this.child( + div() + .when(status != GitFileStatus::Deleted, |this| { + this.text_color(cx.theme().colors().text_muted) + }) + .child(format!("{}/", parent_str)), + ) + } else { + this + } + }) + .child(div().child(entry_details.display_name.clone())), ) .child(div().flex_1()) .child(end_slot) - // TODO: Only fire this if the entry is not currently revealed, otherwise the ui flashes - .on_click(move |e, cx| { + .on_click(move |_, cx| { + // TODO: add `select_entry` method then do after that + cx.dispatch_action(Box::new(OpenSelected)); + handle - .update(cx, |git_panel, cx| { - git_panel.selected_item = Some(details.index); - let change_focus = e.down.click_count > 1; - git_panel.reveal_entry_in_git_editor( - details.hunks.clone(), - change_focus, - None, - cx, - ); + .update(cx, |git_panel, _| { + git_panel.selected_entry = Some(ix); }) .ok(); }); entry } - - fn reveal_entry_in_git_editor( - &mut self, - _hunks: Rc>>, - _change_focus: bool, - _debounce: Option, - _cx: &mut ViewContext, - ) { - // let workspace = self.workspace.clone(); - // let Some(diff_editor) = self.git_diff_editor.clone() else { - // return; - // }; - // self.reveal_in_editor = cx.spawn(|_, mut cx| async move { - // if let Some(debounce) = debounce { - // cx.background_executor().timer(debounce).await; - // } - - // let Some(editor) = workspace - // .update(&mut cx, |workspace, cx| { - // let git_diff_editor = workspace - // .items_of_type::(cx) - // .find(|editor| &diff_editor == editor); - // match git_diff_editor { - // Some(existing_editor) => { - // workspace.activate_item(&existing_editor, true, change_focus, cx); - // existing_editor - // } - // None => { - // workspace.active_pane().update(cx, |pane, cx| { - // pane.add_item( - // ` diff_editor.boxed_clone(), - // true, - // change_focus, - // None, - // cx, - // ) - // }); - // diff_editor.clone() - // } - // } - // }) - // .ok() - // else { - // return; - // }; - - // if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) { - // let hunk_buffer_range = &first_hunk.buffer_range; - // if let Some(buffer_id) = hunk_buffer_range - // .start - // .buffer_id - // .or_else(|| first_hunk.buffer_range.end.buffer_id) - // { - // editor - // .update(&mut cx, |editor, cx| { - // let multi_buffer = editor.buffer().read(cx); - // let buffer = multi_buffer.buffer(buffer_id)?; - // let buffer_snapshot = buffer.read(cx).snapshot(); - // let (excerpt_id, _) = multi_buffer - // .excerpts_for_buffer(&buffer, cx) - // .into_iter() - // .find(|(_, excerpt)| { - // hunk_buffer_range - // .start - // .cmp(&excerpt.context.start, &buffer_snapshot) - // .is_ge() - // && hunk_buffer_range - // .end - // .cmp(&excerpt.context.end, &buffer_snapshot) - // .is_le() - // })?; - // let multi_buffer_hunk_start = multi_buffer - // .snapshot(cx) - // .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?; - // editor.change_selections( - // Some(Autoscroll::Strategy(AutoscrollStrategy::Center)), - // cx, - // |s| { - // s.select_ranges(Some( - // multi_buffer_hunk_start..multi_buffer_hunk_start, - // )) - // }, - // ); - // cx.notify(); - // Some(()) - // }) - // .ok() - // .flatten(); - // } - // } - // }); - } } impl Render for GitPanel { @@ -1282,24 +1171,35 @@ impl Render for GitPanel { v_flex() .id("git_panel") - .key_context(self.dispatch_context()) + .key_context(self.dispatch_context(cx)) .track_focus(&self.focus_handle) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .when(!project.is_read_only(cx), |this| { - this.on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx))) - .on_action( - cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)), - ) - .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) - .on_action(cx.listener(|this, &CommitStagedChanges, cx| { - this.commit_staged_changes(&CommitStagedChanges, cx) - })) - .on_action(cx.listener(|this, &CommitAllChanges, cx| { - this.commit_all_changes(&CommitAllChanges, cx) - })) + this.on_action(cx.listener(|this, &ToggleStaged, cx| { + this.toggle_staged_for_selected(&ToggleStaged, cx) + })) + .on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx))) + .on_action(cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx))) + .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) + .on_action( + cx.listener(|this, &CommitChanges, cx| this.commit_changes(&CommitChanges, cx)), + ) + .on_action(cx.listener(|this, &CommitAllChanges, cx| { + this.commit_all_changes(&CommitAllChanges, cx) + })) + }) + .when(self.is_focused(cx), |this| { + this.on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::close_panel)) }) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::open_selected)) + .on_action(cx.listener(Self::focus_changes_list)) + .on_action(cx.listener(Self::focus_editor)) + .on_action(cx.listener(Self::toggle_staged_for_selected)) + // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx))) .on_hover(cx.listener(|this, hovered, cx| { if *hovered { this.show_scrollbar = true; @@ -1384,14 +1284,3 @@ impl Panel for GitPanel { 2 } } - -// fn diff_display_editor(cx: &mut WindowContext) -> View { -// cx.new_view(|cx| { -// let multi_buffer = cx.new_model(|_| { -// MultiBuffer::new(language::Capability::ReadWrite).with_title("Project diff".to_string()) -// }); -// let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx); -// editor.set_expand_all_diff_hunks(); -// editor -// }) -// } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 89a47d884c76e..cf9effc11188e 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,56 +1,282 @@ use ::settings::Settings; -use git::repository::GitFileStatus; -use gpui::{actions, AppContext, Context, Global, Hsla, Model}; +use collections::HashMap; +use futures::{future::FusedFuture, select, FutureExt}; +use git::repository::{GitFileStatus, GitRepository, RepoPath}; +use gpui::{actions, AppContext, Context, Global, Hsla, Model, ModelContext}; +use project::{Project, WorktreeId}; use settings::GitPanelSettings; +use std::sync::mpsc; +use std::{ + pin::{pin, Pin}, + sync::Arc, + time::Duration, +}; +use sum_tree::SumTree; use ui::{Color, Icon, IconName, IntoElement, SharedString}; +use worktree::RepositoryEntry; pub mod git_panel; mod settings; +const GIT_TASK_DEBOUNCE: Duration = Duration::from_millis(50); + actions!( - git_ui, + git, [ + StageFile, + UnstageFile, + ToggleStaged, + // Revert actions are currently in the editor crate: + // editor::RevertFile, + // editor::RevertSelectedHunks StageAll, UnstageAll, RevertAll, - CommitStagedChanges, + CommitChanges, CommitAllChanges, - ClearMessage + ClearCommitMessage ] ); pub fn init(cx: &mut AppContext) { GitPanelSettings::register(cx); - let git_state = cx.new_model(|_cx| GitState::new()); + let git_state = cx.new_model(GitState::new); cx.set_global(GlobalGitState(git_state)); } +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub enum GitViewMode { + #[default] + List, + Tree, +} + struct GlobalGitState(Model); impl Global for GlobalGitState {} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StatusAction { + Stage, + Unstage, +} + pub struct GitState { + /// The current commit message being composed. commit_message: Option, + + /// When a git repository is selected, this is used to track which repository's changes + /// are currently being viewed or modified in the UI. + active_repository: Option<(WorktreeId, RepositoryEntry, Arc)>, + + updater_tx: mpsc::Sender<(Arc, Vec, StatusAction)>, + + all_repositories: HashMap>, + + list_view_mode: GitViewMode, } impl GitState { - pub fn new() -> Self { + pub fn new(cx: &mut ModelContext<'_, Self>) -> Self { + let (updater_tx, updater_rx) = mpsc::channel(); + cx.spawn(|_, cx| async move { + // Long-running task to periodically update git indices based on messages from the panel. + + // We read messages from the channel in batches that refer to the same repository. + // When we read a message whose repository is different from the current batch's repository, + // the batch is finished, and since we can't un-receive this last message, we save it + // to begin the next batch. + let mut leftover_message: Option<( + Arc, + Vec, + StatusAction, + )> = None; + let mut git_task = None; + loop { + let mut timer = cx.background_executor().timer(GIT_TASK_DEBOUNCE).fuse(); + let _result = { + let mut task: Pin<&mut dyn FusedFuture>> = + match git_task.as_mut() { + Some(task) => pin!(task), + // If no git task is running, just wait for the timeout. + None => pin!(std::future::pending().fuse()), + }; + select! { + result = task => { + // Task finished. + git_task = None; + Some(result) + } + _ = timer => None, + } + }; + + // TODO handle failure of the git command + + if git_task.is_none() { + // No git task running now; let's see if we should launch a new one. + let mut to_stage = Vec::new(); + let mut to_unstage = Vec::new(); + let mut current_repo = leftover_message.as_ref().map(|msg| msg.0.clone()); + for (git_repo, paths, action) in leftover_message + .take() + .into_iter() + .chain(updater_rx.try_iter()) + { + if current_repo + .as_ref() + .map_or(false, |repo| !Arc::ptr_eq(repo, &git_repo)) + { + // End of a batch, save this for the next one. + leftover_message = Some((git_repo.clone(), paths, action)); + break; + } else if current_repo.is_none() { + // Start of a batch. + current_repo = Some(git_repo); + } + + if action == StatusAction::Stage { + to_stage.extend(paths); + } else { + to_unstage.extend(paths); + } + } + + // TODO handle the same path being staged and unstaged + + if to_stage.is_empty() && to_unstage.is_empty() { + continue; + } + + if let Some(git_repo) = current_repo { + git_task = Some( + cx.background_executor() + .spawn(async move { git_repo.update_index(&to_stage, &to_unstage) }) + .fuse(), + ); + } + } + } + }) + .detach(); GitState { commit_message: None, + active_repository: None, + updater_tx, + list_view_mode: GitViewMode::default(), + all_repositories: HashMap::default(), } } - pub fn set_message(&mut self, message: Option) { + pub fn get_global(cx: &mut AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn activate_repository( + &mut self, + worktree_id: WorktreeId, + active_repository: RepositoryEntry, + git_repo: Arc, + ) { + self.active_repository = Some((worktree_id, active_repository, git_repo)); + } + + pub fn active_repository( + &self, + ) -> Option<&(WorktreeId, RepositoryEntry, Arc)> { + self.active_repository.as_ref() + } + + pub fn commit_message(&mut self, message: Option) { self.commit_message = message; } - pub fn clear_message(&mut self) { + pub fn clear_commit_message(&mut self) { self.commit_message = None; } - pub fn get_global(cx: &mut AppContext) -> Model { - cx.global::().0.clone() + pub fn stage_entry(&mut self, repo_path: RepoPath) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = self + .updater_tx + .send((git_repo.clone(), vec![repo_path], StatusAction::Stage)); + } + } + + pub fn unstage_entry(&mut self, repo_path: RepoPath) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = + self.updater_tx + .send((git_repo.clone(), vec![repo_path], StatusAction::Unstage)); + } + } + + pub fn stage_entries(&mut self, entries: Vec) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = self + .updater_tx + .send((git_repo.clone(), entries, StatusAction::Stage)); + } } + + fn act_on_all(&mut self, action: StatusAction) { + if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() { + let _ = self.updater_tx.send(( + git_repo.clone(), + active_repository + .status() + .map(|entry| entry.repo_path) + .collect(), + action, + )); + } + } + + pub fn stage_all(&mut self) { + self.act_on_all(StatusAction::Stage); + } + + pub fn unstage_all(&mut self) { + self.act_on_all(StatusAction::Unstage); + } +} + +pub fn first_worktree_repository( + project: &Model, + worktree_id: WorktreeId, + cx: &mut AppContext, +) -> Option<(RepositoryEntry, Arc)> { + project + .read(cx) + .worktree_for_id(worktree_id, cx) + .and_then(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().iter().next()?.clone(); + let git_repo = worktree + .read(cx) + .as_local()? + .get_local_repo(&repo)? + .repo() + .clone(); + Some((repo, git_repo)) + }) +} + +pub fn first_repository_in_project( + project: &Model, + cx: &mut AppContext, +) -> Option<(WorktreeId, RepositoryEntry, Arc)> { + project.read(cx).worktrees(cx).next().and_then(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().iter().next()?.clone(); + let git_repo = worktree + .read(cx) + .as_local()? + .get_local_repo(&repo)? + .repo() + .clone(); + Some((snapshot.id(), repo, git_repo)) + }) } const ADDED_COLOR: Hsla = Hsla { diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index 338397a5513f1..ee285b896bf04 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -63,6 +63,16 @@ pub trait Action: 'static + Send { where Self: Sized; + /// Optional JSON schema for the action's input data. + fn action_json_schema( + _: &mut schemars::gen::SchemaGenerator, + ) -> Option + where + Self: Sized, + { + None + } + /// A list of alternate, deprecated names for this action. fn deprecated_aliases() -> &'static [&'static str] where @@ -90,16 +100,16 @@ impl dyn Action { type ActionBuilder = fn(json: serde_json::Value) -> anyhow::Result>; pub(crate) struct ActionRegistry { - builders_by_name: HashMap, + by_name: HashMap, names_by_type_id: HashMap, all_names: Vec, // So we can return a static slice. - deprecations: Vec<(SharedString, SharedString)>, + deprecations: HashMap, } impl Default for ActionRegistry { fn default() -> Self { let mut this = ActionRegistry { - builders_by_name: Default::default(), + by_name: Default::default(), names_by_type_id: Default::default(), all_names: Default::default(), deprecations: Default::default(), @@ -111,19 +121,25 @@ impl Default for ActionRegistry { } } +struct ActionData { + pub build: ActionBuilder, + pub json_schema: fn(&mut schemars::gen::SchemaGenerator) -> Option, +} + /// This type must be public so that our macros can build it in other crates. /// But this is an implementation detail and should not be used directly. #[doc(hidden)] -pub type MacroActionBuilder = fn() -> ActionData; +pub type MacroActionBuilder = fn() -> MacroActionData; /// This type must be public so that our macros can build it in other crates. /// But this is an implementation detail and should not be used directly. #[doc(hidden)] -pub struct ActionData { +pub struct MacroActionData { pub name: &'static str, pub aliases: &'static [&'static str], pub type_id: TypeId, pub build: ActionBuilder, + pub json_schema: fn(&mut schemars::gen::SchemaGenerator) -> Option, } /// This constant must be public to be accessible from other crates. @@ -143,20 +159,35 @@ impl ActionRegistry { #[cfg(test)] pub(crate) fn load_action(&mut self) { - self.insert_action(ActionData { + self.insert_action(MacroActionData { name: A::debug_name(), aliases: A::deprecated_aliases(), type_id: TypeId::of::(), build: A::build, + json_schema: A::action_json_schema, }); } - fn insert_action(&mut self, action: ActionData) { + fn insert_action(&mut self, action: MacroActionData) { let name: SharedString = action.name.into(); - self.builders_by_name.insert(name.clone(), action.build); + self.by_name.insert( + name.clone(), + ActionData { + build: action.build, + json_schema: action.json_schema, + }, + ); for &alias in action.aliases { - self.builders_by_name.insert(alias.into(), action.build); - self.deprecations.push((alias.into(), name.clone())); + let alias: SharedString = alias.into(); + self.by_name.insert( + alias.clone(), + ActionData { + build: action.build, + json_schema: action.json_schema, + }, + ); + self.deprecations.insert(alias.clone(), name.clone()); + self.all_names.push(alias); } self.names_by_type_id.insert(action.type_id, name.clone()); self.all_names.push(name); @@ -180,9 +211,10 @@ impl ActionRegistry { params: Option, ) -> Result> { let build_action = self - .builders_by_name + .by_name .get(name) - .ok_or_else(|| anyhow!("no action type registered for {}", name))?; + .ok_or_else(|| anyhow!("No action type registered for {}", name))? + .build; (build_action)(params.unwrap_or_else(|| json!({}))) .with_context(|| format!("Attempting to build action {}", name)) } @@ -191,26 +223,50 @@ impl ActionRegistry { self.all_names.as_slice() } - pub fn action_deprecations(&self) -> &[(SharedString, SharedString)] { - self.deprecations.as_slice() + pub fn action_schemas( + &self, + generator: &mut schemars::gen::SchemaGenerator, + ) -> Vec<(SharedString, Option)> { + // Use the order from all_names so that the resulting schema has sensible order. + self.all_names + .iter() + .map(|name| { + let action_data = self + .by_name + .get(name) + .expect("All actions in all_names should be registered"); + (name.clone(), (action_data.json_schema)(generator)) + }) + .collect::>() + } + + pub fn action_deprecations(&self) -> &HashMap { + &self.deprecations } } -/// Defines unit structs that can be used as actions. +/// Defines and registers unit structs that can be used as actions. +/// /// To use more complex data types as actions, use `impl_actions!` #[macro_export] macro_rules! actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { $( - #[doc = "The `"] + // Unfortunately rust-analyzer doesn't display the name due to + // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "` action, see [`gpui::actions!`]"] + #[doc = "action generated by `gpui::actions!`"] #[derive(::std::clone::Clone,::std::cmp::PartialEq, ::std::default::Default)] pub struct $name; gpui::__impl_action!($namespace, $name, $name, fn build(_: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) + }, + fn action_json_schema( + _: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None } ); @@ -219,18 +275,20 @@ macro_rules! actions { }; } -/// Defines a unit struct that can be used as an actions, with a name -/// that differs from it's type name. +/// Defines and registers a unit struct that can be used as an actions, with a name that differs +/// from it's type name. /// -/// To use more complex data types as actions, and rename them use -/// `impl_action_as!` +/// To use more complex data types as actions, and rename them use `impl_action_as!` #[macro_export] macro_rules! action_as { ($namespace:path, $name:ident as $visual_name:ident) => { - #[doc = "The `"] + // Unfortunately rust-analyzer doesn't display the name due to + // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "` action, see [`gpui::actions!`]"] - #[derive(::std::clone::Clone, ::std::cmp::PartialEq, ::std::default::Default)] + #[doc = "action generated by `gpui::action_as!`"] + #[derive( + ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, + )] pub struct $name; gpui::__impl_action!( @@ -241,6 +299,11 @@ macro_rules! action_as { _: gpui::private::serde_json::Value, ) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None } ); @@ -248,21 +311,17 @@ macro_rules! action_as { }; } -/// Defines a unit struct that can be used as an action, with some deprecated aliases. +/// Defines and registers a unit struct that can be used as an action, with some deprecated aliases. #[macro_export] -macro_rules! action_aliases { - ($namespace:path, $name:ident, [$($alias:ident),* $(,)?]) => { - #[doc = "The `"] +macro_rules! action_with_deprecated_aliases { + ($namespace:path, $name:ident, [$($alias:literal),* $(,)?]) => { + // Unfortunately rust-analyzer doesn't display the name due to + // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "` action, see [`gpui::actions!`]"] + #[doc = "action, generated by `gpui::action_with_deprecated_aliases!`"] #[derive( - ::std::cmp::PartialEq, - ::std::clone::Clone, - ::std::default::Default, - ::std::fmt::Debug, - gpui::private::serde_derive::Deserialize, + ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, )] - #[serde(crate = "gpui::private::serde")] pub struct $name; gpui::__impl_action!( @@ -274,9 +333,15 @@ macro_rules! action_aliases { ) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None + + }, fn deprecated_aliases() -> &'static [&'static str] { &[ - $(concat!(stringify!($namespace), "::", stringify!($alias))),* + $($alias),* ] } ); @@ -285,7 +350,11 @@ macro_rules! action_aliases { }; } -/// Implements the Action trait for any struct that implements Clone, Default, PartialEq, and serde_deserialize::Deserialize +/// Registers the action and implements the Action trait for any struct that implements Clone, +/// Default, PartialEq, serde_deserialize::Deserialize, and schemars::JsonSchema. +/// +/// Fields and variants that don't make sense for user configuration should be annotated with +/// #[serde(skip)]. #[macro_export] macro_rules! impl_actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { @@ -293,6 +362,13 @@ macro_rules! impl_actions { gpui::__impl_action!($namespace, $name, $name, fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { Ok(std::boxed::Box::new(gpui::private::serde_json::from_value::(value)?)) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + Some(::json_schema( + generator, + )) } ); @@ -301,8 +377,41 @@ macro_rules! impl_actions { }; } -/// Implements the Action trait for a struct that implements Clone, Default, PartialEq, and serde_deserialize::Deserialize -/// Allows you to rename the action visually, without changing the struct's name +/// Implements the Action trait for internal action structs that implement Clone, Default, +/// PartialEq. The purpose of this is to conveniently define values that can be passed in `dyn +/// Action`. +/// +/// These actions are internal and so are not registered and do not support deserialization. +#[macro_export] +macro_rules! impl_internal_actions { + ($namespace:path, [ $($name:ident),* $(,)? ]) => { + $( + gpui::__impl_action!($namespace, $name, $name, + fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { + gpui::Result::Err(gpui::private::anyhow::anyhow!( + concat!( + stringify!($namespace), + "::", + stringify!($visual_name), + " is an internal action, so cannot be built from JSON." + ))) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None + } + ); + )* + }; +} + +/// Implements the Action trait for a struct that implements Clone, Default, PartialEq, and +/// serde_deserialize::Deserialize. Allows you to rename the action visually, without changing the +/// struct's name. +/// +/// Fields and variants that don't make sense for user configuration should be annotated with +/// #[serde(skip)]. #[macro_export] macro_rules! impl_action_as { ($namespace:path, $name:ident as $visual_name:tt ) => { @@ -316,6 +425,13 @@ macro_rules! impl_action_as { Ok(std::boxed::Box::new( gpui::private::serde_json::from_value::(value)?, )) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + Some(::json_schema( + generator, + )) } ); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index a0ec1f9933f11..0c23eaeb3bb6b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -23,7 +23,7 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -use collections::{FxHashMap, FxHashSet, VecDeque}; +use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use entity_map::*; use http_client::HttpClient; pub use model_context::*; @@ -1218,16 +1218,22 @@ impl AppContext { self.actions.build_action(name, data) } - /// Get a list of all action names that have been registered. - /// in the application. Note that registration only allows for - /// actions to be built dynamically, and is unrelated to binding - /// actions in the element tree. + /// Get all action names that have been registered. Note that registration only allows for + /// actions to be built dynamically, and is unrelated to binding actions in the element tree. pub fn all_action_names(&self) -> &[SharedString] { self.actions.all_action_names() } + /// Get all non-internal actions that have been registered, along with their schemas. + pub fn action_schemas( + &self, + generator: &mut schemars::gen::SchemaGenerator, + ) -> Vec<(SharedString, Option)> { + self.actions.action_schemas(generator) + } + /// Get a list of all deprecated action aliases and their canonical names. - pub fn action_deprecations(&self) -> &[(SharedString, SharedString)] { + pub fn action_deprecations(&self) -> &HashMap { self.actions.action_deprecations() } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index fd2617f393f9f..b173382dcab46 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -102,7 +102,9 @@ mod window; /// Do not touch, here be dragons for use by gpui_macros and such. #[doc(hidden)] pub mod private { + pub use anyhow; pub use linkme; + pub use schemars; pub use serde; pub use serde_derive; pub use serde_json; diff --git a/crates/gpui/tests/action_macros.rs b/crates/gpui/tests/action_macros.rs index 99572a4b3c5b1..98a70daa9532e 100644 --- a/crates/gpui/tests/action_macros.rs +++ b/crates/gpui/tests/action_macros.rs @@ -1,12 +1,13 @@ use gpui::{actions, impl_actions}; use gpui_macros::register_action; +use schemars::JsonSchema; use serde_derive::Deserialize; #[test] fn test_action_macros() { actions!(test, [TestAction]); - #[derive(PartialEq, Clone, Deserialize)] + #[derive(PartialEq, Clone, Deserialize, JsonSchema)] struct AnotherTestAction; impl_actions!(test, [AnotherTestAction]); diff --git a/crates/gpui_macros/src/register_action.rs b/crates/gpui_macros/src/register_action.rs index 7fc8158e9bf2c..6c22ccf02fbd7 100644 --- a/crates/gpui_macros/src/register_action.rs +++ b/crates/gpui_macros/src/register_action.rs @@ -29,12 +29,13 @@ pub(crate) fn register_action(type_name: &Ident) -> proc_macro2::TokenStream { fn __autogenerated() { /// This is an auto generated function, do not use. #[doc(hidden)] - fn #action_builder_fn_name() -> gpui::ActionData { - gpui::ActionData { + fn #action_builder_fn_name() -> gpui::MacroActionData { + gpui::MacroActionData { name: <#type_name as gpui::Action>::debug_name(), aliases: <#type_name as gpui::Action>::deprecated_aliases(), type_id: ::std::any::TypeId::of::<#type_name>(), build: <#type_name as gpui::Action>::build, + json_schema: <#type_name as gpui::Action>::action_json_schema, } } #[doc(hidden)] diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 3a905a2945f25..b6e793d0e8793 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -709,8 +709,7 @@ pub struct LanguageConfigOverride { pub line_comments: Override>>, #[serde(default)] pub block_comment: Override<(Arc, Arc)>, - #[serde(skip_deserializing)] - #[schemars(skip)] + #[serde(skip)] pub disabled_bracket_ixs: Vec, #[serde(default)] pub word_characters: Override>, @@ -823,7 +822,7 @@ pub struct BracketPairConfig { pub pairs: Vec, /// A list of tree-sitter scopes for which a given bracket should not be active. /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]` - #[schemars(skip)] + #[serde(skip)] pub disabled_scopes_by_bracket_ix: Vec>, } diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index e35322d7552bc..3f0777a4f6353 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -179,7 +179,7 @@ impl LanguageModel for CopilotChatLanguageModel { CopilotChatModel::Gpt4o => open_ai::Model::FourOmni, CopilotChatModel::Gpt4 => open_ai::Model::Four, CopilotChatModel::Gpt3_5Turbo => open_ai::Model::ThreePointFiveTurbo, - CopilotChatModel::O1Preview | CopilotChatModel::O1Mini => open_ai::Model::Four, + CopilotChatModel::O1 | CopilotChatModel::O1Mini => open_ai::Model::Four, CopilotChatModel::Claude3_5Sonnet => unreachable!(), }; count_open_ai_tokens(request, model, cx) diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index 042f419a4f6f1..b41b4b9e91374 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -730,12 +730,17 @@ impl LspLogView { * Binary: {BINARY:#?} -* Capabilities: {CAPABILITIES}", + +* Capabilities: {CAPABILITIES} + +* Configuration: {CONFIGURATION}", NAME = server.name(), ID = server.server_id(), BINARY = server.binary(), CAPABILITIES = serde_json::to_string_pretty(&server.capabilities()) .unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")), + CONFIGURATION = serde_json::to_string_pretty(server.configuration()) + .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")), ); editor.set_text(server_info, cx); editor.set_read_only(true); @@ -960,7 +965,7 @@ impl LspLogView { }); server - .notify::(SetTraceParams { value: level }) + .notify::(&SetTraceParams { value: level }) .ok(); } } diff --git a/crates/language_tools/src/lsp_log_tests.rs b/crates/language_tools/src/lsp_log_tests.rs index ad3cc87f2d042..cbcb900c3f28b 100644 --- a/crates/language_tools/src/lsp_log_tests.rs +++ b/crates/language_tools/src/lsp_log_tests.rs @@ -71,7 +71,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { let log_view = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window, cx); - language_server.notify::(lsp::LogMessageParams { + language_server.notify::(&lsp::LogMessageParams { message: "hello from the server".into(), typ: lsp::MessageType::INFO, }); diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 7172f96f74430..a783195db32bb 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -75,9 +75,7 @@ impl JsonLspAdapter { } fn get_workspace_config(language_names: Vec, cx: &mut AppContext) -> Value { - let action_names = cx.all_action_names(); - let deprecations = cx.action_deprecations(); - + let keymap_schema = KeymapFile::generate_json_schema_for_registered_actions(cx); let font_names = &cx.text_system().all_font_names(); let settings_schema = cx.global::().json_schema( &SettingsJsonSchemaParams { @@ -90,6 +88,8 @@ impl JsonLspAdapter { let tsconfig_schema = serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap(); let package_json_schema = serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap(); + // This can be viewed via `debug: open language server logs` -> `json-language-server` -> + // `Server Info` serde_json::json!({ "json": { "format": { @@ -117,7 +117,7 @@ impl JsonLspAdapter { }, { "fileMatch": [schema_file_match(paths::keymap_file())], - "schema": KeymapFile::generate_json_schema(action_names, deprecations), + "schema": keymap_schema, }, { "fileMatch": [ diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index c3dce5dfde6c8..fcdd251bef4ee 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -86,6 +86,10 @@ pub struct LanguageServer { process_name: Arc, binary: LanguageServerBinary, capabilities: RwLock, + /// Configuration sent to the server, stored for display in the language server logs + /// buffer. This is represented as the message sent to the LSP in order to avoid cloning it (can + /// be large in cases like sending schemas to the json server). + configuration: Arc, code_action_kinds: Option>, notification_handlers: Arc>>, response_handlers: Arc>>>, @@ -456,6 +460,11 @@ impl LanguageServer { .log_err() }); + let configuration = DidChangeConfigurationParams { + settings: Value::Null, + } + .into(); + Self { server_id, notification_handlers, @@ -469,6 +478,7 @@ impl LanguageServer { .unwrap_or_default(), binary, capabilities: Default::default(), + configuration, code_action_kinds, next_id: Default::default(), outbound_tx, @@ -792,6 +802,7 @@ impl LanguageServer { pub fn initialize( mut self, params: InitializeParams, + configuration: Arc, cx: &AppContext, ) -> Task>> { cx.spawn(|_| async move { @@ -800,8 +811,9 @@ impl LanguageServer { self.process_name = info.name.into(); } self.capabilities = RwLock::new(response.capabilities); + self.configuration = configuration; - self.notify::(InitializedParams {})?; + self.notify::(&InitializedParams {})?; Ok(Arc::new(self)) }) } @@ -821,7 +833,7 @@ impl LanguageServer { &executor, (), ); - let exit = Self::notify_internal::(&outbound_tx, ()); + let exit = Self::notify_internal::(&outbound_tx, &()); outbound_tx.close(); let server = self.server.clone(); @@ -1035,6 +1047,10 @@ impl LanguageServer { update(self.capabilities.write().deref_mut()); } + pub fn configuration(&self) -> &Value { + &self.configuration.settings + } + /// Get the id of the running language server. pub fn server_id(&self) -> LanguageServerId { self.server_id @@ -1126,7 +1142,7 @@ impl LanguageServer { if let Some(outbound_tx) = outbound_tx.upgrade() { Self::notify_internal::( &outbound_tx, - CancelParams { + &CancelParams { id: NumberOrString::Number(id), }, ) @@ -1154,13 +1170,13 @@ impl LanguageServer { /// Sends a RPC notification to the language server. /// /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) - pub fn notify(&self, params: T::Params) -> Result<()> { + pub fn notify(&self, params: &T::Params) -> Result<()> { Self::notify_internal::(&self.outbound_tx, params) } fn notify_internal( outbound_tx: &channel::Sender, - params: T::Params, + params: &T::Params, ) -> Result<()> { let message = serde_json::to_string(&Notification { jsonrpc: JSON_RPC_VERSION, @@ -1200,7 +1216,7 @@ impl LanguageServer { removed: vec![], }, }; - self.notify::(params).log_err(); + self.notify::(¶ms).log_err(); } } /// Add new workspace folder to the list. @@ -1230,7 +1246,7 @@ impl LanguageServer { }], }, }; - self.notify::(params).log_err(); + self.notify::(¶ms).log_err(); } } pub fn set_workspace_folders(&self, folders: BTreeSet) { @@ -1256,7 +1272,7 @@ impl LanguageServer { let params = DidChangeWorkspaceFoldersParams { event: WorkspaceFoldersChangeEvent { added, removed }, }; - self.notify::(params).log_err(); + self.notify::(¶ms).log_err(); } } @@ -1428,7 +1444,7 @@ impl LanguageServer { #[cfg(any(test, feature = "test-support"))] impl FakeLanguageServer { /// See [`LanguageServer::notify`]. - pub fn notify(&self, params: T::Params) { + pub fn notify(&self, params: &T::Params) { self.server.notify::(params).ok(); } @@ -1536,7 +1552,7 @@ impl FakeLanguageServer { }) .await .unwrap(); - self.notify::(ProgressParams { + self.notify::(&ProgressParams { token: NumberOrString::String(token), value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(progress)), }); @@ -1544,7 +1560,7 @@ impl FakeLanguageServer { /// Simulate that the server has completed work and notifies about that with the specified token. pub fn end_progress(&self, token: impl Into) { - self.notify::(ProgressParams { + self.notify::(&ProgressParams { token: NumberOrString::String(token.into()), value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(Default::default())), }); @@ -1597,12 +1613,15 @@ mod tests { let server = cx .update(|cx| { let params = server.default_initialize_params(cx); - server.initialize(params, cx) + let configuration = DidChangeConfigurationParams { + settings: Default::default(), + }; + server.initialize(params, configuration.into(), cx) }) .await .unwrap(); server - .notify::(DidOpenTextDocumentParams { + .notify::(&DidOpenTextDocumentParams { text_document: TextDocumentItem::new( Url::from_str("file://a/b").unwrap(), "rust".to_string(), @@ -1620,11 +1639,11 @@ mod tests { "file://a/b" ); - fake.notify::(ShowMessageParams { + fake.notify::(&ShowMessageParams { typ: MessageType::ERROR, message: "ok".to_string(), }); - fake.notify::(PublishDiagnosticsParams { + fake.notify::(&PublishDiagnosticsParams { uri: Url::from_str("file://b/c").unwrap(), version: Some(5), diagnostics: vec![], diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index db42c1cd80cdf..e906b0965e9d9 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true editor.workspace = true gpui.workspace = true menu.workspace = true +schemars.workspace = true serde.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index c97fceeef3403..56dbf69835b38 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -7,6 +7,7 @@ use gpui::{ ViewContext, WindowContext, }; use head::Head; +use schemars::JsonSchema; use serde::Deserialize; use std::{sync::Arc, time::Duration}; use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing}; @@ -24,7 +25,7 @@ actions!(picker, [ConfirmCompletion]); /// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally, /// performing some kind of action on it. -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Default)] pub struct ConfirmInput { pub secondary: bool, } diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index 877c3f6418b55..c0d770c5d5760 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -286,7 +286,10 @@ impl Prettier { let server = cx .update(|cx| { let params = server.default_initialize_params(cx); - executor.spawn(server.initialize(params, cx)) + let configuration = lsp::DidChangeConfigurationParams { + settings: Default::default(), + }; + executor.spawn(server.initialize(params, configuration.into(), cx)) })? .await .context("prettier server initialization")?; diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index ba959befa1e1a..d81fcea6e6dd8 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -295,8 +295,18 @@ impl LocalLspStore { Self::setup_lsp_messages(this.clone(), &language_server, delegate, adapter); + let did_change_configuration_params = + Arc::new(lsp::DidChangeConfigurationParams { + settings: workspace_config, + }); let language_server = cx - .update(|cx| language_server.initialize(initialization_params, cx))? + .update(|cx| { + language_server.initialize( + initialization_params, + did_change_configuration_params.clone(), + cx, + ) + })? .await .inspect_err(|_| { if let Some(this) = this.upgrade() { @@ -309,9 +319,7 @@ impl LocalLspStore { language_server .notify::( - lsp::DidChangeConfigurationParams { - settings: workspace_config, - }, + &did_change_configuration_params, ) .ok(); @@ -2060,7 +2068,7 @@ impl LocalLspStore { for server in servers { server .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( uri.clone(), adapter.language_id(&language.name()), @@ -2109,7 +2117,7 @@ impl LocalLspStore { for (_, language_server) in self.language_servers_for_buffer(buffer, cx) { language_server .notify::( - lsp::DidCloseTextDocumentParams { + &lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(file_url.clone()), }, ) @@ -5267,7 +5275,7 @@ impl LspStore { language_server .notify::( - lsp::DidChangeTextDocumentParams { + &lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( uri.clone(), next_version, @@ -5303,7 +5311,7 @@ impl LspStore { }; server .notify::( - lsp::DidSaveTextDocumentParams { + &lsp::DidSaveTextDocumentParams { text_document: text_document.clone(), text, }, @@ -5382,7 +5390,7 @@ impl LspStore { server .notify::( - lsp::DidChangeConfigurationParams { settings }, + &lsp::DidChangeConfigurationParams { settings }, ) .ok(); } @@ -6411,7 +6419,7 @@ impl LspStore { if filter.should_send_did_rename(&old_uri, is_dir) { language_server - .notify::(RenameFilesParams { + .notify::(&RenameFilesParams { files: vec![FileRename { old_uri: old_uri.clone(), new_uri: new_uri.clone(), @@ -6518,7 +6526,7 @@ impl LspStore { if !changes.is_empty() { server .notify::( - lsp::DidChangeWatchedFilesParams { changes }, + &lsp::DidChangeWatchedFilesParams { changes }, ) .log_err(); } @@ -7728,7 +7736,7 @@ impl LspStore { let uri = lsp::Url::from_file_path(file.abs_path(cx)).unwrap(); language_server .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( uri, adapter.language_id(&language.name()), @@ -7833,7 +7841,7 @@ impl LspStore { if progress.is_cancellable { server .notify::( - WorkDoneProgressCancelParams { + &WorkDoneProgressCancelParams { token: lsp::NumberOrString::String(token.clone()), }, ) @@ -7843,7 +7851,7 @@ impl LspStore { if progress.is_cancellable { server .notify::( - WorkDoneProgressCancelParams { + &WorkDoneProgressCancelParams { token: lsp::NumberOrString::String(token.clone()), }, ) @@ -7980,7 +7988,7 @@ impl LspStore { }; if !params.changes.is_empty() { server - .notify::(params) + .notify::(¶ms) .log_err(); } } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 005cd34cba2b2..b216d15b1eb41 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -1269,7 +1269,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { } ); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -1321,7 +1321,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { }); // Ensure publishing empty diagnostics twice only results in one update event. - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: Default::default(), @@ -1334,7 +1334,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { } ); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: Default::default(), @@ -1453,7 +1453,7 @@ async fn test_restarting_server_with_diagnostics_published(cx: &mut gpui::TestAp // Publish diagnostics let fake_server = fake_servers.next().await.unwrap(); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -1534,7 +1534,7 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T // Before restarting the server, report diagnostics with an unknown buffer version. let fake_server = fake_servers.next().await.unwrap(); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(10000), diagnostics: Vec::new(), @@ -1784,7 +1784,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { assert!(change_notification_1.text_document.version > open_notification.text_document.version); // Report some diagnostics for the initial version of the buffer - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(open_notification.text_document.version), diagnostics: vec![ @@ -1870,7 +1870,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { }); // Ensure overlapping diagnostics are highlighted correctly. - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(open_notification.text_document.version), diagnostics: vec![ @@ -1962,7 +1962,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { ); // Handle out-of-order diagnostics - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(change_notification_2.text_document.version), diagnostics: vec![ diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 1eccfc9e1b409..5cdbd78ede508 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1,10 +1,10 @@ mod project_panel_settings; mod utils; +use anyhow::{anyhow, Context as _, Result}; use client::{ErrorCode, ErrorExt}; -use language::DiagnosticSeverity; -use settings::{Settings, SettingsStore}; - +use collections::{hash_map, BTreeSet, HashMap}; +use command_palette_hooks::CommandPaletteFilter; use db::kvp::KEY_VALUE_STORE; use editor::{ items::{ @@ -15,10 +15,6 @@ use editor::{ Editor, EditorEvent, EditorSettings, ShowScrollbar, }; 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, @@ -30,6 +26,7 @@ use gpui::{ VisualContext as _, WeakView, WindowContext, }; use indexmap::IndexMap; +use language::DiagnosticSeverity; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; use project::{ relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, @@ -38,7 +35,9 @@ use project::{ use project_panel_settings::{ ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides, }; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsStore}; use smallvec::SmallVec; use std::any::TypeId; use std::{ @@ -152,13 +151,13 @@ struct EntryDetails { canonical_path: Option>, } -#[derive(PartialEq, Clone, Default, Debug, Deserialize)] +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] struct Delete { #[serde(default)] pub skip_prompt: bool, } -#[derive(PartialEq, Clone, Default, Debug, Deserialize)] +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] struct Trash { #[serde(default)] pub skip_prompt: bool, diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index 9288416d5720b..e1e2c5ed09795 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -5,7 +5,6 @@ use futures::{ Future, FutureExt as _, }; use gpui::{AnyModel, AnyWeakModel, AsyncAppContext, Model}; -// pub use prost::Message; use proto::{ error::ErrorExt as _, AnyTypedEnvelope, EntityMessage, Envelope, EnvelopedMessage, RequestMessage, TypedEnvelope, diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 18cdb36f169b6..ad36242d7cb32 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -31,6 +31,7 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index a81ddc1a6a9c2..df6e7e7d6a8f8 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -22,6 +22,7 @@ use project::{ search::SearchQuery, search_history::{SearchHistory, SearchHistoryCursor}, }; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::Arc; @@ -43,7 +44,7 @@ use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults}; const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; -#[derive(PartialEq, Clone, Deserialize)] +#[derive(PartialEq, Clone, Deserialize, JsonSchema)] pub struct Deploy { #[serde(default = "util::serde::default_true")] pub focus: bool, diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index c2b4625ffc74c..b8c9cff21f393 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -1,11 +1,11 @@ use crate::{settings_store::parse_json_with_comments, SettingsAssets}; use anyhow::{anyhow, Context, Result}; -use collections::BTreeMap; -use gpui::{Action, AppContext, KeyBinding, SharedString}; +use collections::{BTreeMap, HashMap}; +use gpui::{Action, AppContext, KeyBinding, NoAction, SharedString}; use schemars::{ gen::{SchemaGenerator, SchemaSettings}, - schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation}, - JsonSchema, Map, + schema::{ArrayValidation, InstanceType, Schema, SchemaObject, SubschemaValidation}, + JsonSchema, }; use serde::Deserialize; use serde_json::Value; @@ -139,60 +139,182 @@ impl KeymapFile { Ok(()) } - pub fn generate_json_schema( - action_names: &[SharedString], - deprecations: &[(SharedString, SharedString)], - ) -> serde_json::Value { - let mut root_schema = SchemaSettings::draft07() + pub fn generate_json_schema_for_registered_actions(cx: &mut AppContext) -> Value { + let mut generator = SchemaSettings::draft07() .with(|settings| settings.option_add_null_type = false) - .into_generator() - .into_root_schema_for::(); - - let mut alternatives = vec![ - Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - enum_values: Some( - action_names - .iter() - .map(|name| Value::String(name.to_string())) - .collect(), - ), - ..Default::default() - }), - Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Array))), - ..Default::default() - }), - Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Null))), + .into_generator(); + + let action_schemas = cx.action_schemas(&mut generator); + let deprecations = cx.action_deprecations(); + KeymapFile::generate_json_schema(generator, action_schemas, deprecations) + } + + fn generate_json_schema( + generator: SchemaGenerator, + action_schemas: Vec<(SharedString, Option)>, + deprecations: &HashMap, + ) -> serde_json::Value { + fn set(input: I) -> Option + where + I: Into, + { + Some(input.into()) + } + + fn add_deprecation(schema_object: &mut SchemaObject, message: String) { + schema_object.extensions.insert( + // deprecationMessage is not part of the JSON Schema spec, + // but json-language-server recognizes it. + "deprecationMessage".to_owned(), + Value::String(message), + ); + } + + fn add_deprecation_preferred_name(schema_object: &mut SchemaObject, new_name: &str) { + add_deprecation(schema_object, format!("Deprecated, use {new_name}")); + } + + fn add_description(schema_object: &mut SchemaObject, description: String) { + schema_object + .metadata + .get_or_insert(Default::default()) + .description = Some(description); + } + + let empty_object: SchemaObject = SchemaObject { + instance_type: set(InstanceType::Object), + ..Default::default() + }; + + // This is a workaround for a json-language-server issue where it matches the first + // alternative that matches the value's shape and uses that for documentation. + // + // In the case of the array validations, it would even provide an error saying that the name + // must match the name of the first alternative. + let mut plain_action = SchemaObject { + instance_type: set(InstanceType::String), + const_value: Some(Value::String("".to_owned())), + ..Default::default() + }; + let no_action_message = "No action named this."; + add_description(&mut plain_action, no_action_message.to_owned()); + add_deprecation(&mut plain_action, no_action_message.to_owned()); + let mut matches_action_name = SchemaObject { + const_value: Some(Value::String("".to_owned())), + ..Default::default() + }; + let no_action_message = "No action named this that takes input."; + add_description(&mut matches_action_name, no_action_message.to_owned()); + add_deprecation(&mut matches_action_name, no_action_message.to_owned()); + let action_with_input = SchemaObject { + instance_type: set(InstanceType::Array), + array: set(ArrayValidation { + items: set(vec![ + matches_action_name.into(), + // Accept any value, as we want this to be the preferred match when there is a + // typo in the name. + Schema::Bool(true), + ]), + min_items: Some(2), + max_items: Some(2), ..Default::default() }), - ]; - for (old, new) in deprecations { - alternatives.push(Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - const_value: Some(Value::String(old.to_string())), - extensions: Map::from_iter([( - // deprecationMessage is not part of the JSON Schema spec, - // but json-language-server recognizes it. - "deprecationMessage".to_owned(), - format!("Deprecated, use {new}").into(), - )]), + ..Default::default() + }; + let mut keymap_action_alternatives = vec![plain_action.into(), action_with_input.into()]; + + for (name, action_schema) in action_schemas.iter() { + let schema = if let Some(Schema::Object(schema)) = action_schema { + Some(schema.clone()) + } else { + None + }; + + let description = schema.as_ref().and_then(|schema| { + schema + .metadata + .as_ref() + .and_then(|metadata| metadata.description.clone()) + }); + + let deprecation = if name == NoAction.name() { + Some("null") + } else { + deprecations.get(name).map(|new_name| new_name.as_ref()) + }; + + // Add an alternative for plain action names. + let mut plain_action = SchemaObject { + instance_type: set(InstanceType::String), + const_value: Some(Value::String(name.to_string())), ..Default::default() - })); + }; + if let Some(new_name) = deprecation { + add_deprecation_preferred_name(&mut plain_action, new_name); + } + if let Some(description) = description.clone() { + add_description(&mut plain_action, description); + } + keymap_action_alternatives.push(plain_action.into()); + + // Add an alternative for actions with data specified as a [name, data] array. + // + // When a struct with no deserializable fields is added with impl_actions! / + // impl_actions_as! an empty object schema is produced. The action should be invoked + // without data in this case. + if let Some(schema) = schema { + if schema != empty_object { + let mut matches_action_name = SchemaObject { + const_value: Some(Value::String(name.to_string())), + ..Default::default() + }; + if let Some(description) = description.clone() { + add_description(&mut matches_action_name, description.to_string()); + } + if let Some(new_name) = deprecation { + add_deprecation_preferred_name(&mut matches_action_name, new_name); + } + let action_with_input = SchemaObject { + instance_type: set(InstanceType::Array), + array: set(ArrayValidation { + items: set(vec![matches_action_name.into(), schema.into()]), + min_items: Some(2), + max_items: Some(2), + ..Default::default() + }), + ..Default::default() + }; + keymap_action_alternatives.push(action_with_input.into()); + } + } } - let action_schema = Schema::Object(SchemaObject { - subschemas: Some(Box::new(SubschemaValidation { - one_of: Some(alternatives), + + // Placing null first causes json-language-server to default assuming actions should be + // null, so place it last. + keymap_action_alternatives.push( + SchemaObject { + instance_type: set(InstanceType::Null), ..Default::default() - })), + } + .into(), + ); + + let action_schema = SchemaObject { + subschemas: set(SubschemaValidation { + one_of: Some(keymap_action_alternatives), + ..Default::default() + }), ..Default::default() - }); + } + .into(); + let mut root_schema = generator.into_root_schema_for::(); root_schema .definitions .insert("KeymapAction".to_owned(), action_schema); + // This and other json schemas can be viewed via `debug: open language server logs` -> + // `json-language-server` -> `Server Info`. serde_json::to_value(root_schema).unwrap() } diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index c30469fb176c7..cee167ed7b5ff 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -19,6 +19,7 @@ gpui.workspace = true menu.workspace = true picker.workspace = true project.workspace = true +schemars.workspace = true serde.workspace = true settings.workspace = true ui.workspace = true diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index f076a4f1bc5a6..1b427d9f31a59 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -10,6 +10,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use project::Project; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::Arc; @@ -23,7 +24,7 @@ use workspace::{ const PANEL_WIDTH_REMS: f32 = 28.; -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default)] pub struct Toggle { #[serde(default)] pub select_last: bool, diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 83803ff21c976..b71c095abc3db 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -29,6 +29,7 @@ itertools.workspace = true language.workspace = true project.workspace = true task.workspace = true +schemars.workspace = true search.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index a3bb2cc522b04..36b07bdd6dda9 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -146,13 +146,16 @@ fn populate_pane_items( cx: &mut ViewContext, ) { let mut item_index = pane.items_len(); + let mut active_item_index = None; for item in items { - let activate_item = Some(item.item_id().as_u64()) == active_item; + if Some(item.item_id().as_u64()) == active_item { + active_item_index = Some(item_index); + } pane.add_item(Box::new(item), false, false, None, cx); item_index += 1; - if activate_item { - pane.activate_item(item_index, false, false, cx); - } + } + if let Some(index) = active_item_index { + pane.activate_item(index, false, false, cx); } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index a250d3bcadce3..5f3b9d336aa4e 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -31,7 +31,7 @@ use ui::{ }; use util::{ResultExt, TryFutureExt}; use workspace::{ - dock::{DockPosition, Panel, PanelEvent}, + dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, ui::IconName, @@ -83,7 +83,6 @@ impl TerminalPanel { let project = workspace.project(); let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), false, cx); let center = PaneGroup::new(pane.clone()); - cx.focus_view(&pane); let terminal_panel = Self { center, active_pane: pane, @@ -283,6 +282,25 @@ impl TerminalPanel { } } + if let Some(workspace) = workspace.upgrade() { + let should_focus = workspace + .update(&mut cx, |workspace, cx| { + workspace.active_item(cx).is_none() + && workspace.is_dock_at_position_open(terminal_panel.position(cx), cx) + }) + .unwrap_or(false); + + if should_focus { + terminal_panel + .update(&mut cx, |panel, cx| { + panel.active_pane.update(cx, |pane, cx| { + pane.focus_active_item(cx); + }); + }) + .ok(); + } + } + Ok(terminal_panel) } diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 63958bd839040..6d87b03d0562f 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -15,6 +15,7 @@ use gpui::{ use language::Bias; use persistence::TERMINAL_DB; use project::{search::SearchQuery, terminals::TerminalKind, Fs, Metadata, Project}; +use schemars::JsonSchema; use terminal::{ alacritty_terminal::{ index::Point, @@ -66,14 +67,14 @@ const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); const GIT_DIFF_PATH_PREFIXES: &[char] = &['a', 'b']; -///Event to transmit the scroll from the element to the view +/// Event to transmit the scroll from the element to the view #[derive(Clone, Debug, PartialEq)] pub struct ScrollTerminal(pub i32); -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)] pub struct SendText(String); -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)] pub struct SendKeystroke(String); impl_actions!(terminal, [SendText, SendKeystroke]); diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 62ab46610ace4..4717c63f5d786 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -57,8 +57,6 @@ pub struct ThemeColors { pub element_disabled: Hsla, /// Background Color. Used for the area that shows where a dragged element will be dropped. pub drop_target_background: Hsla, - /// Border Color. Used to show the area that shows where a dragged element will be dropped. - // pub drop_target_border: Hsla, /// Used for the background of a ghost element that should have the same background as the surface it's on. /// /// Elements might include: Buttons, Inputs, Checkboxes, Radio Buttons... @@ -140,16 +138,12 @@ pub struct ThemeColors { pub scrollbar_track_background: Hsla, /// The border color of the scrollbar track. pub scrollbar_track_border: Hsla, - // /// The opacity of the scrollbar status marks, like diagnostic states and git status. - // todo() - // pub scrollbar_status_opacity: Hsla, // === // Editor // === pub editor_foreground: Hsla, pub editor_background: Hsla, - // pub editor_inactive_background: Hsla, pub editor_gutter_background: Hsla, pub editor_subheader_background: Hsla, pub editor_active_line_background: Hsla, diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index df6e0afd17424..18980300b2832 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -36,6 +36,7 @@ notifications.workspace = true project.workspace = true remote.workspace = true rpc.workspace = true +schemars.workspace = true serde.workspace = true settings.workspace = true smallvec.workspace = true diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 1c3e67c0958fd..23e470a345444 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,17 +1,18 @@ use gpui::{impl_actions, OwnedMenu, OwnedMenuItem, View}; +use schemars::JsonSchema; use serde::Deserialize; use smallvec::SmallVec; use ui::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip}; impl_actions!( app_menu, - [OpenApplicationMenu, NavigateApplicationMenuInDirection,] + [OpenApplicationMenu, NavigateApplicationMenuInDirection] ); -#[derive(Clone, Deserialize, PartialEq, Default)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default)] pub struct OpenApplicationMenu(String); -#[derive(Clone, Deserialize, PartialEq, Default)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default)] pub struct NavigateApplicationMenuInDirection(String); #[derive(Clone)] diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 26ef2fc19f1d8..f0c69c79c49b7 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -487,6 +487,7 @@ impl RenderOnce for ButtonLike { self.base .h_flex() .id(self.id.clone()) + .font_ui(cx) .group("") .flex_none() .h(self.height.unwrap_or(self.size.rems().into())) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 53f2a62bb3b50..38d151c8b0ed3 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -112,6 +112,7 @@ pub struct ContextMenu { delayed: bool, clicked: bool, _on_blur_subscription: Subscription, + keep_open_on_confirm: bool, } impl FocusableView for ContextMenu { @@ -144,6 +145,7 @@ impl ContextMenu { delayed: false, clicked: false, _on_blur_subscription, + keep_open_on_confirm: true, }, cx, ) @@ -304,6 +306,11 @@ impl ContextMenu { self } + pub fn keep_open_on_confirm(mut self) -> Self { + self.keep_open_on_confirm = true; + self + } + pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { let context = self.action_context.as_ref(); if let Some( @@ -318,7 +325,9 @@ impl ContextMenu { (handler)(context, cx) } - cx.emit(DismissEvent); + if !self.keep_open_on_confirm { + cx.emit(DismissEvent); + } } pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index f7af6d0567cef..e2bd19af5fb3c 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1,11 +1,3 @@ -use std::{ - iter::Peekable, - ops::{Deref, Range}, - str::Chars, - sync::OnceLock, - time::Instant, -}; - use anyhow::{anyhow, Result}; use command_palette_hooks::CommandInterceptResult; use editor::{ @@ -13,12 +5,22 @@ use editor::{ display_map::ToDisplayPoint, Bias, Editor, ToPoint, }; -use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext, WindowContext}; +use gpui::{ + actions, impl_internal_actions, Action, AppContext, Global, ViewContext, WindowContext, +}; use language::Point; use multi_buffer::MultiBufferRow; use regex::Regex; +use schemars::JsonSchema; use search::{BufferSearchBar, SearchOptions}; use serde::Deserialize; +use std::{ + iter::Peekable, + ops::{Deref, Range}, + str::Chars, + sync::OnceLock, + time::Instant, +}; use util::ResultExt; use workspace::{notifications::NotifyResultExt, SaveIntent}; @@ -33,24 +35,24 @@ use crate::{ Vim, }; -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct GoToLine { range: CommandRange, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct YankCommand { range: CommandRange, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct WithRange { restore_selection: bool, range: CommandRange, action: WrappedAction, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct WithCount { count: u32, action: WrappedAction, @@ -60,20 +62,11 @@ pub struct WithCount { struct WrappedAction(Box); actions!(vim, [VisualCommand, CountCommand]); -impl_actions!( +impl_internal_actions!( vim, [GoToLine, YankCommand, WithRange, WithCount, OnMatchingLines] ); -impl<'de> Deserialize<'de> for WrappedAction { - fn deserialize(_: D) -> Result - where - D: serde::Deserializer<'de>, - { - Err(serde::de::Error::custom("Cannot deserialize WrappedAction")) - } -} - impl PartialEq for WrappedAction { fn eq(&self, other: &Self) -> bool { self.0.partial_eq(&*other.0) @@ -423,7 +416,7 @@ impl VimCommand { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] enum Position { Line { row: u32, offset: i32 }, Mark { name: char, offset: i32 }, @@ -467,7 +460,7 @@ impl Position { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct CommandRange { start: Position, end: Option, @@ -877,7 +870,7 @@ fn generate_positions(string: &str, query: &str) -> Vec { positions } -#[derive(Debug, PartialEq, Deserialize, Clone)] +#[derive(Debug, PartialEq, Clone)] pub(crate) struct OnMatchingLines { range: CommandRange, search: String, diff --git a/crates/vim/src/digraph.rs b/crates/vim/src/digraph.rs index dcccc8b5cd316..c17a774be04ec 100644 --- a/crates/vim/src/digraph.rs +++ b/crates/vim/src/digraph.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use collections::HashMap; use editor::Editor; use gpui::{impl_actions, AppContext, Keystroke, KeystrokeEvent}; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::LazyLock; @@ -12,7 +13,7 @@ use crate::{state::Operator, Vim, VimSettings}; mod default; -#[derive(PartialEq, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] struct Literal(String, char); impl_actions!(vim, [Literal]); diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 43c4397e8b272..cf90d03a7ded7 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -9,6 +9,7 @@ use editor::{ use gpui::{actions, impl_actions, px, ViewContext}; use language::{CharKind, Point, Selection, SelectionGoal}; use multi_buffer::MultiBufferRow; +use schemars::JsonSchema; use serde::Deserialize; use std::ops::Range; use workspace::searchable::Direction; @@ -139,105 +140,105 @@ pub enum Motion { }, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct NextWordStart { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct NextWordEnd { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct PreviousWordStart { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct PreviousWordEnd { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct NextSubwordStart { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct NextSubwordEnd { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct PreviousSubwordStart { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct PreviousSubwordEnd { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct Up { #[serde(default)] pub(crate) display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct Down { #[serde(default)] pub(crate) display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct FirstNonWhitespace { #[serde(default)] display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct EndOfLine { #[serde(default)] display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StartOfLine { #[serde(default)] pub(crate) display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct UnmatchedForward { #[serde(default)] char: char, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct UnmatchedBackward { #[serde(default)] diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index ca300fc1be27d..803a8d01e28bf 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -1,20 +1,20 @@ -use std::ops::Range; - use editor::{scroll::Autoscroll, Editor, MultiBufferSnapshot, ToOffset, ToPoint}; use gpui::{impl_actions, ViewContext}; use language::{Bias, Point}; +use schemars::JsonSchema; use serde::Deserialize; +use std::ops::Range; use crate::{state::Mode, Vim}; -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct Increment { #[serde(default)] step: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct Decrement { #[serde(default)] diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 8d49a6802c195..4fe18a61b1266 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -1,16 +1,16 @@ -use std::cmp; - use editor::{display_map::ToDisplayPoint, movement, scroll::Autoscroll, DisplayPoint, RowExt}; use gpui::{impl_actions, ViewContext}; use language::{Bias, SelectionGoal}; +use schemars::JsonSchema; use serde::Deserialize; +use std::cmp; use crate::{ state::{Mode, Register}, Vim, }; -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Paste { #[serde(default)] diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 103d33f8af12c..fb6bd6a93b4ae 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -1,10 +1,10 @@ -use std::{iter::Peekable, str::Chars, time::Duration}; - use editor::Editor; -use gpui::{actions, impl_actions, ViewContext}; +use gpui::{actions, impl_actions, impl_internal_actions, ViewContext}; use language::Point; +use schemars::JsonSchema; use search::{buffer_search, BufferSearchBar, SearchOptions}; use serde_derive::Deserialize; +use std::{iter::Peekable, str::Chars, time::Duration}; use util::serde::default_true; use workspace::{notifications::NotifyResultExt, searchable::Direction}; @@ -15,7 +15,7 @@ use crate::{ Vim, }; -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct MoveToNext { #[serde(default = "default_true")] @@ -26,7 +26,7 @@ pub(crate) struct MoveToNext { regex: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct MoveToPrev { #[serde(default = "default_true")] @@ -37,7 +37,7 @@ pub(crate) struct MoveToPrev { regex: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] pub(crate) struct Search { #[serde(default)] backwards: bool, @@ -45,19 +45,19 @@ pub(crate) struct Search { regex: bool, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] pub struct FindCommand { pub query: String, pub backwards: bool, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct ReplaceCommand { pub(crate) range: CommandRange, pub(crate) replacement: Replacement, } -#[derive(Debug, Default, PartialEq, Deserialize, Clone)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct Replacement { search: String, replacement: String, @@ -66,10 +66,8 @@ pub(crate) struct Replacement { } actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]); -impl_actions!( - vim, - [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext] -); +impl_actions!(vim, [FindCommand, Search, MoveToPrev, MoveToNext]); +impl_internal_actions!(vim, [ReplaceCommand]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, Vim::move_to_next); diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 745d1adb78418..a00e6fea17e8d 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -10,15 +10,14 @@ use editor::{ movement::{self, FindRange}, Bias, DisplayPoint, Editor, }; - -use itertools::Itertools; - use gpui::{actions, impl_actions, ViewContext}; +use itertools::Itertools; use language::{BufferSnapshot, CharKind, Point, Selection, TextObject, TreeSitterOptions}; use multi_buffer::MultiBufferRow; +use schemars::JsonSchema; use serde::Deserialize; -#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)] pub enum Object { Word { ignore_punctuation: bool }, Sentence, @@ -40,13 +39,14 @@ pub enum Object { Comment, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct Word { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] + +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct IndentObj { #[serde(default)] diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index f7a617a6ab55f..e401903c9a9b9 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,6 +1,3 @@ -use std::borrow::BorrowMut; -use std::{fmt::Display, ops::Range, sync::Arc}; - use crate::command::command_interceptor; use crate::normal::repeat::Replayer; use crate::surrounds::SurroundsType; @@ -13,12 +10,15 @@ use gpui::{ Action, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, Global, View, WeakView, }; use language::Point; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; +use std::borrow::BorrowMut; +use std::{fmt::Display, ops::Range, sync::Arc}; use ui::{SharedString, ViewContext}; use workspace::searchable::Direction; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema, Serialize)] pub enum Mode { Normal, Insert, @@ -59,22 +59,39 @@ impl Default for Mode { } } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)] pub enum Operator { Change, Delete, Yank, Replace, - Object { around: bool }, - FindForward { before: bool }, - FindBackward { after: bool }, - Sneak { first_char: Option }, - SneakBackward { first_char: Option }, - AddSurrounds { target: Option }, - ChangeSurrounds { target: Option }, + Object { + around: bool, + }, + FindForward { + before: bool, + }, + FindBackward { + after: bool, + }, + Sneak { + first_char: Option, + }, + SneakBackward { + first_char: Option, + }, + AddSurrounds { + #[serde(skip)] + target: Option, + }, + ChangeSurrounds { + target: Option, + }, DeleteSurrounds, Mark, - Jump { line: bool }, + Jump { + line: bool, + }, Indent, Outdent, AutoIndent, @@ -82,8 +99,12 @@ pub enum Operator { Lowercase, Uppercase, OppositeCase, - Digraph { first_char: Option }, - Literal { prefix: Option }, + Digraph { + first_char: Option, + }, + Literal { + prefix: Option, + }, Register, RecordRegister, ReplayRegister, diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 719a147062386..a480ff3617bab 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -6,7 +6,7 @@ use crate::{ }; use editor::{movement, scroll::Autoscroll, Bias}; use language::BracketPair; -use serde::Deserialize; + use std::sync::Arc; use ui::ViewContext; @@ -17,16 +17,6 @@ pub enum SurroundsType { Selection, } -// This exists so that we can have Deserialize on Operators, but not on Motions. -impl<'de> Deserialize<'de> for SurroundsType { - fn deserialize(_: D) -> Result - where - D: serde::Deserializer<'de>, - { - Err(serde::de::Error::custom("Cannot deserialize SurroundsType")) - } -} - impl Vim { pub fn add_surrounds( &mut self, diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index dbc3a25ce320c..d8a58e4789374 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -49,25 +49,25 @@ use workspace::{self, Pane, ResizeIntent, Workspace}; use crate::state::ReplayableAction; /// Used to resize the current pane -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] pub struct ResizePane(pub ResizeIntent); /// An Action to Switch between modes -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] pub struct SwitchMode(pub Mode); /// PushOperator is used to put vim into a "minor" mode, /// where it's waiting for a specific next set of keystrokes. /// For example 'd' needs a motion to complete. -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] pub struct PushOperator(pub Operator); /// Number is used to manage vim's count. Pushing a digit -/// multiplis the current value by 10 and adds the digit. -#[derive(Clone, Deserialize, PartialEq)] +/// multiplies the current value by 10 and adds the digit. +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] struct Number(usize); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] struct SelectRegister(String); actions!( diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 05ab9a8f90e2b..a633c27610b04 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -25,6 +25,7 @@ use itertools::Itertools; use language::DiagnosticSeverity; use parking_lot::Mutex; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; +use schemars::JsonSchema; use serde::Deserialize; use settings::{Settings, SettingsStore}; use std::{ @@ -71,7 +72,7 @@ impl DraggedSelection { } } -#[derive(PartialEq, Clone, Copy, Deserialize, Debug)] +#[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum SaveIntent { /// write all files (even if unchanged) @@ -92,16 +93,16 @@ pub enum SaveIntent { Skip, } -#[derive(Clone, Deserialize, PartialEq, Debug)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] pub struct ActivateItem(pub usize); -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseActiveItem { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseInactiveItems { pub save_intent: Option, @@ -109,7 +110,7 @@ pub struct CloseInactiveItems { pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseAllItems { pub save_intent: Option, @@ -117,34 +118,35 @@ pub struct CloseAllItems { pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseCleanItems { #[serde(default)] pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseItemsToTheRight { #[serde(default)] pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseItemsToTheLeft { #[serde(default)] pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct RevealInProjectPanel { + #[serde(skip)] pub entry_id: Option, } -#[derive(Default, PartialEq, Clone, Deserialize)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] pub struct DeploySearch { #[serde(default)] pub replace_enabled: bool, diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 168f6539e0100..1b523c3d2ca79 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -13,6 +13,7 @@ use gpui::{ }; use parking_lot::Mutex; use project::Project; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::Arc; @@ -717,7 +718,7 @@ impl PaneAxis { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, JsonSchema)] pub enum SplitDirection { Up, Down, @@ -800,7 +801,7 @@ impl SplitDirection { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq)] pub enum ResizeIntent { Lengthen, Shorten, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ad91bb386b009..e4488650ec3b6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -61,10 +61,9 @@ use persistence::{ SerializedWindowBounds, DB, }; use postage::stream::Stream; -use project::{ - DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, -}; +use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree}; use remote::{ssh_session::ConnectionIdentifier, SshClientDelegate, SshConnectionOptions}; +use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; use settings::Settings; @@ -119,9 +118,6 @@ static ZED_WINDOW_POSITION: LazyLock>> = LazyLock::new(|| { .and_then(parse_pixel_position_env_var) }); -#[derive(Clone, PartialEq)] -pub struct RemoveWorktreeFromProject(pub WorktreeId); - actions!(assistant, [ShowConfiguration]); actions!( @@ -165,64 +161,64 @@ pub struct OpenPaths { pub paths: Vec, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct ActivatePane(pub usize); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct ActivatePaneInDirection(pub SplitDirection); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct SwapPaneInDirection(pub SplitDirection); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct MoveItemToPane { pub destination: usize, #[serde(default = "default_true")] pub focus: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct MoveItemToPaneInDirection { pub direction: SplitDirection, #[serde(default = "default_true")] pub focus: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SaveAll { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct Save { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, Default, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CloseAllItemsAndPanes { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, Default, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CloseInactiveTabsAndPanes { pub save_intent: Option, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct SendKeystrokes(pub String); -#[derive(Clone, Deserialize, PartialEq, Default)] +#[derive(Clone, Deserialize, PartialEq, Default, JsonSchema)] pub struct Reload { pub binary_path: Option, } action_as!(project_symbols, ToggleProjectSymbols as Toggle); -#[derive(Default, PartialEq, Eq, Clone, serde::Deserialize)] +#[derive(Default, PartialEq, Eq, Clone, Deserialize, JsonSchema)] pub struct ToggleFileFinder { #[serde(default)] pub separate_history: bool, @@ -299,7 +295,7 @@ impl PartialEq for Toast { } } -#[derive(Debug, Default, Clone, Deserialize, PartialEq)] +#[derive(Debug, Default, Clone, Deserialize, PartialEq, JsonSchema)] pub struct OpenTerminal { pub working_directory: PathBuf, } @@ -2302,6 +2298,19 @@ impl Workspace { } } + pub fn is_dock_at_position_open( + &self, + position: DockPosition, + cx: &mut ViewContext, + ) -> bool { + let dock = match position { + DockPosition::Left => &self.left_dock, + DockPosition::Bottom => &self.bottom_dock, + DockPosition::Right => &self.right_dock, + }; + dock.read(cx).is_open() + } + pub fn toggle_dock(&mut self, dock_side: DockPosition, cx: &mut ViewContext) { let dock = match dock_side { DockPosition::Left => &self.left_dock, diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b2ced35e75794..3f8c113db674c 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -21,6 +21,7 @@ use fuzzy::CharBag; use git::GitHostingProviderRegistry; use git::{ repository::{GitFileStatus, GitRepository, RepoPath}, + status::GitStatusPair, COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, }; use gpui::{ @@ -193,8 +194,8 @@ pub struct RepositoryEntry { /// - my_sub_folder_1/project_root/changed_file_1 /// - my_sub_folder_2/changed_file_2 pub(crate) statuses_by_path: SumTree, - pub(crate) work_directory_id: ProjectEntryId, - pub(crate) work_directory: WorkDirectory, + pub work_directory_id: ProjectEntryId, + pub work_directory: WorkDirectory, pub(crate) branch: Option>, } @@ -225,6 +226,12 @@ impl RepositoryEntry { self.statuses_by_path.iter().cloned() } + pub fn status_for_path(&self, path: &RepoPath) -> Option { + self.statuses_by_path + .get(&PathKey(path.0.clone()), &()) + .cloned() + } + pub fn initial_update(&self) -> proto::RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), @@ -234,7 +241,7 @@ impl RepositoryEntry { .iter() .map(|entry| proto::StatusEntry { repo_path: entry.repo_path.to_string_lossy().to_string(), - status: git_status_to_proto(entry.status), + status: status_pair_to_proto(entry.status.clone()), }) .collect(), removed_statuses: Default::default(), @@ -259,7 +266,7 @@ impl RepositoryEntry { current_new_entry = new_statuses.next(); } Ordering::Equal => { - if new_entry.status != old_entry.status { + if new_entry.combined_status() != old_entry.combined_status() { updated_statuses.push(new_entry.to_proto()); } current_old_entry = old_statuses.next(); @@ -2360,7 +2367,7 @@ impl Snapshot { let repo_path = repo.relativize(path).unwrap(); repo.statuses_by_path .get(&PathKey(repo_path.0), &()) - .map(|entry| entry.status) + .map(|entry| entry.combined_status()) }) } @@ -2574,8 +2581,8 @@ impl Snapshot { .map(|repo| repo.status().collect()) } - pub fn repositories(&self) -> impl Iterator { - self.repositories.iter() + pub fn repositories(&self) -> &SumTree { + &self.repositories } /// Get the repository whose work directory corresponds to the given path. @@ -2609,7 +2616,7 @@ impl Snapshot { entries: impl 'a + Iterator, ) -> impl 'a + Iterator)> { let mut containing_repos = Vec::<&RepositoryEntry>::new(); - let mut repositories = self.repositories().peekable(); + let mut repositories = self.repositories().iter().peekable(); entries.map(move |entry| { while let Some(repository) = containing_repos.last() { if repository.directory_contains(&entry.path) { @@ -3626,14 +3633,31 @@ pub type UpdatedGitRepositoriesSet = Arc<[(Arc, GitRepositoryChange)]>; #[derive(Clone, Debug, PartialEq, Eq)] pub struct StatusEntry { pub repo_path: RepoPath, - pub status: GitFileStatus, + pub status: GitStatusPair, } impl StatusEntry { + // TODO revisit uses of this + pub fn combined_status(&self) -> GitFileStatus { + self.status.combined() + } + + pub fn index_status(&self) -> Option { + self.status.index_status + } + + pub fn worktree_status(&self) -> Option { + self.status.worktree_status + } + + pub fn is_staged(&self) -> Option { + self.status.is_staged() + } + fn to_proto(&self) -> proto::StatusEntry { proto::StatusEntry { repo_path: self.repo_path.to_proto(), - status: git_status_to_proto(self.status), + status: status_pair_to_proto(self.status.clone()), } } } @@ -3641,11 +3665,10 @@ impl StatusEntry { impl TryFrom for StatusEntry { type Error = anyhow::Error; fn try_from(value: proto::StatusEntry) -> Result { - Ok(Self { - repo_path: RepoPath(Path::new(&value.repo_path).into()), - status: git_status_from_proto(Some(value.status)) - .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?, - }) + let repo_path = RepoPath(Path::new(&value.repo_path).into()); + let status = status_pair_from_proto(value.status) + .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?; + Ok(Self { repo_path, status }) } } @@ -3729,7 +3752,7 @@ impl sum_tree::Item for StatusEntry { fn summary(&self, _: &::Context) -> Self::Summary { PathSummary { max_path: self.repo_path.0.clone(), - item_summary: match self.status { + item_summary: match self.combined_status() { GitFileStatus::Added => GitStatuses { added: 1, ..Default::default() @@ -4820,15 +4843,15 @@ impl BackgroundScanner { for (repo_path, status) in &*status.entries { paths.remove_repo_path(repo_path); - if cursor.seek_forward(&PathTarget::Path(&repo_path), Bias::Left, &()) { - if cursor.item().unwrap().status == *status { + if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left, &()) { + if &cursor.item().unwrap().status == status { continue; } } changed_path_statuses.push(Edit::Insert(StatusEntry { repo_path: repo_path.clone(), - status: *status, + status: status.clone(), })); } @@ -5257,7 +5280,7 @@ impl BackgroundScanner { new_entries_by_path.insert_or_replace( StatusEntry { repo_path: repo_path.clone(), - status: *status, + status: status.clone(), }, &(), ); @@ -5771,7 +5794,7 @@ impl<'a> GitTraversal<'a> { } else if entry.is_file() { // For a file entry, park the cursor on the corresponding status if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) { - self.current_entry_status = Some(statuses.item().unwrap().status); + self.current_entry_status = Some(statuses.item().unwrap().combined_status()); } } } @@ -6136,19 +6159,23 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry { } } -fn git_status_from_proto(git_status: Option) -> Option { - git_status.and_then(|status| { - proto::GitStatus::from_i32(status).map(|status| match status { - proto::GitStatus::Added => GitFileStatus::Added, - proto::GitStatus::Modified => GitFileStatus::Modified, - proto::GitStatus::Conflict => GitFileStatus::Conflict, - proto::GitStatus::Deleted => GitFileStatus::Deleted, - }) +// TODO pass the status pair all the way through +fn status_pair_from_proto(proto: i32) -> Option { + let proto = proto::GitStatus::from_i32(proto)?; + let worktree_status = match proto { + proto::GitStatus::Added => GitFileStatus::Added, + proto::GitStatus::Modified => GitFileStatus::Modified, + proto::GitStatus::Conflict => GitFileStatus::Conflict, + proto::GitStatus::Deleted => GitFileStatus::Deleted, + }; + Some(GitStatusPair { + index_status: None, + worktree_status: Some(worktree_status), }) } -fn git_status_to_proto(status: GitFileStatus) -> i32 { - match status { +fn status_pair_to_proto(status: GitStatusPair) -> i32 { + match status.combined() { GitFileStatus::Added => proto::GitStatus::Added as i32, GitFileStatus::Modified => proto::GitStatus::Modified as i32, GitFileStatus::Conflict => proto::GitStatus::Conflict as i32, diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index eebb5f9360f47..5f8144347d93f 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -2179,7 +2179,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().next().unwrap(); + let repo = tree.repositories().iter().next().unwrap(); assert_eq!(repo.path.as_ref(), Path::new("projects/project1")); assert_eq!( tree.status_for_file(Path::new("projects/project1/a")), @@ -2200,7 +2200,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().next().unwrap(); + let repo = tree.repositories().iter().next().unwrap(); assert_eq!(repo.path.as_ref(), Path::new("projects/project2")); assert_eq!( tree.status_for_file(Path::new("projects/project2/a")), @@ -2380,8 +2380,8 @@ async fn test_file_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().count(), 1); - let repo_entry = snapshot.repositories().next().unwrap(); + assert_eq!(snapshot.repositories().iter().count(), 1); + let repo_entry = snapshot.repositories().iter().next().unwrap(); assert_eq!(repo_entry.path.as_ref(), Path::new("project")); assert!(repo_entry.location_in_repo.is_none()); @@ -2554,16 +2554,16 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().next().unwrap(); + let repo = snapshot.repositories().iter().next().unwrap(); let entries = repo.status().collect::>(); assert_eq!(entries.len(), 3); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Modified); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].status, GitFileStatus::Untracked); + assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[2].status, GitFileStatus::Deleted); + assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Deleted)); }); std::fs::write(work_dir.join("c.txt"), "some changes").unwrap(); @@ -2576,19 +2576,19 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repository = snapshot.repositories().next().unwrap(); + let repository = snapshot.repositories().iter().next().unwrap(); let entries = repository.status().collect::>(); std::assert_eq!(entries.len(), 4, "entries: {entries:?}"); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Modified); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].status, GitFileStatus::Untracked); + assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); // Status updated assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt")); - assert_eq!(entries[2].status, GitFileStatus::Modified); + assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[3].status, GitFileStatus::Deleted); + assert_eq!(entries[3].worktree_status(), Some(GitFileStatus::Deleted)); }); git_add("a.txt", &repo); @@ -2609,7 +2609,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().next().unwrap(); + let repo = snapshot.repositories().iter().next().unwrap(); let entries = repo.status().collect::>(); // Deleting an untracked entry, b.txt, should leave no status @@ -2621,7 +2621,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { &entries ); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Deleted); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Deleted)); }); } @@ -2676,8 +2676,8 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { // Ensure that the git status is loaded correctly tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().count(), 1); - let repo = snapshot.repositories().next().unwrap(); + assert_eq!(snapshot.repositories().iter().count(), 1); + let repo = snapshot.repositories().iter().next().unwrap(); // Path is blank because the working directory of // the git repository is located at the root of the project assert_eq!(repo.path.as_ref(), Path::new("")); @@ -2707,7 +2707,7 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert!(snapshot.repositories().next().is_some()); + assert!(snapshot.repositories().iter().next().is_some()); assert_eq!(snapshot.status_for_file("c.txt"), None); assert_eq!(snapshot.status_for_file("d/e.txt"), None); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e8dd1bcba69a..28eb82daa049b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3502,6 +3502,73 @@ mod tests { assert_key_bindings_for(workspace.into(), cx, vec![("6", &Deploy)], line!()); } + #[gpui::test] + async fn test_generate_keymap_json_schema_for_registered_actions( + cx: &mut gpui::TestAppContext, + ) { + init_keymap_test(cx); + cx.update(|cx| { + // Make sure it doesn't panic. + KeymapFile::generate_json_schema_for_registered_actions(cx); + }); + } + + /// Actions that don't build from empty input won't work from command palette invocation. + #[gpui::test] + async fn test_actions_build_with_empty_input(cx: &mut gpui::TestAppContext) { + init_keymap_test(cx); + cx.update(|cx| { + let all_actions = cx.all_action_names(); + let mut failing_names = Vec::new(); + let mut errors = Vec::new(); + for action in all_actions { + match action.to_string().as_str() { + "vim::FindCommand" + | "vim::Literal" + | "vim::ResizePane" + | "vim::SwitchMode" + | "vim::PushOperator" + | "vim::Number" + | "vim::SelectRegister" + | "terminal::SendText" + | "terminal::SendKeystroke" + | "app_menu::OpenApplicationMenu" + | "app_menu::NavigateApplicationMenuInDirection" + | "picker::ConfirmInput" + | "editor::HandleInput" + | "editor::FoldAtLevel" + | "pane::ActivateItem" + | "workspace::ActivatePane" + | "workspace::ActivatePaneInDirection" + | "workspace::MoveItemToPane" + | "workspace::MoveItemToPaneInDirection" + | "workspace::OpenTerminal" + | "workspace::SwapPaneInDirection" + | "workspace::SendKeystrokes" + | "zed::OpenBrowser" + | "zed::OpenZedUrl" => {} + _ => { + let result = cx.build_action(action, None); + match &result { + Ok(_) => {} + Err(err) => { + failing_names.push(action); + errors.push(format!("{action} failed to build: {err:?}")); + } + } + } + } + } + if errors.len() > 0 { + panic!( + "Failed to build actions using {{}} as input: {:?}. Errors:\n{}", + failing_names, + errors.join("\n") + ); + } + }); + } + #[gpui::test] fn test_bundled_settings_and_themes(cx: &mut AppContext) { cx.text_system() diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 6b2691cf76b35..cafc46b743ee6 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -11,12 +11,12 @@ use serde::{Deserialize, Serialize}; // https://github.com/mmastrac/rust-ctor/issues/280 pub fn init() {} -#[derive(Clone, PartialEq, Deserialize)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema)] pub struct OpenBrowser { pub url: String, } -#[derive(Clone, PartialEq, Deserialize)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema)] pub struct OpenZedUrl { pub url: String, } @@ -65,9 +65,10 @@ pub mod feedback { pub mod theme_selector { use gpui::impl_actions; + use schemars::JsonSchema; use serde::Deserialize; - #[derive(PartialEq, Clone, Default, Debug, Deserialize)] + #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] pub struct Toggle { /// A list of theme names to filter the theme selector down to. pub themes_filter: Option>, @@ -76,20 +77,21 @@ pub mod theme_selector { impl_actions!(theme_selector, [Toggle]); } -#[derive(Clone, Default, Deserialize, PartialEq)] +#[derive(Clone, Default, Deserialize, PartialEq, JsonSchema)] pub struct InlineAssist { pub prompt: Option, } impl_actions!(assistant, [InlineAssist]); -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct OpenRecent { #[serde(default)] pub create_new_window: bool, } -gpui::impl_actions!(projects, [OpenRecent]); -gpui::actions!(projects, [OpenRemote]); + +impl_actions!(projects, [OpenRecent]); +actions!(projects, [OpenRemote]); /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -102,8 +104,8 @@ pub enum RevealTarget { Dock, } -/// Spawn a task with name or open tasks modal -#[derive(Debug, PartialEq, Clone, Deserialize)] +/// Spawn a task with name or open tasks modal. +#[derive(Debug, PartialEq, Clone, Deserialize, JsonSchema)] #[serde(untagged)] pub enum Spawn { /// Spawns a task by the name given. @@ -128,8 +130,8 @@ impl Spawn { } } -/// Rerun last task -#[derive(PartialEq, Clone, Deserialize, Default)] +/// Rerun the last task. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct Rerun { /// Controls whether the task context is reevaluated prior to execution of a task. /// If it is not, environment variables such as ZED_COLUMN, ZED_FILE are gonna be the same as in the last execution of a task @@ -147,6 +149,7 @@ pub struct Rerun { pub use_new_terminal: Option, /// If present, rerun the task with this ID, otherwise rerun the last task. + #[serde(skip)] pub task_id: Option, } diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 546f6a254905e..9c794dc7fc6e9 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -206,7 +206,7 @@ impl Zeta { } fn push_event(&mut self, event: Event) { - const MAX_EVENT_COUNT: usize = 20; + const MAX_EVENT_COUNT: usize = 16; if let Some(Event::BufferChange { new_snapshot: last_new_snapshot, @@ -232,8 +232,8 @@ impl Zeta { } self.events.push_back(event); - if self.events.len() > MAX_EVENT_COUNT { - self.events.pop_front(); + if self.events.len() >= MAX_EVENT_COUNT { + self.events.drain(..MAX_EVENT_COUNT / 2); } }