From c48197b2804a17ecf8ec46781985d4f9cff35e11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hendrik=20M=C3=BCller?= Date: Mon, 25 Aug 2025 11:28:33 +0200 Subject: [PATCH 01/54] util: Fix edge case when parsing paths (#36025) Searching for files broke a couple releases ago. It used to be possible to start typing part of a file name, then select a file (not confirm it yet) and then type in `:` and a line number to navigate directly to that line. The current behavior can be seen in the following screenshots. When the `:` is typed, the selection is lost, since no files match any more. Screenshot From 2025-08-12 10-36-08 Screenshot From 2025-08-12 10-36-25 Screenshot From 2025-08-12 10-36-47 --- With this PR, the previous behavior is restored and can be seen in these screenshots: Screenshot From 2025-08-12 10-36-08 Screenshot From 2025-08-12 10-47-07 Screenshot From 2025-08-12 10-47-21 --- Release Notes: - Adjusted the file finder to show matching file paths when adding the `:row:column` to the query --- crates/file_finder/src/file_finder.rs | 9 ++-- crates/file_finder/src/file_finder_tests.rs | 48 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/crates/file_finder/src/file_finder.rs b/crates/file_finder/src/file_finder.rs index 8aaaa04729..7512152324 100644 --- a/crates/file_finder/src/file_finder.rs +++ b/crates/file_finder/src/file_finder.rs @@ -1401,13 +1401,16 @@ impl PickerDelegate for FileFinderDelegate { #[cfg(windows)] let raw_query = raw_query.trim().to_owned().replace("/", "\\"); #[cfg(not(windows))] - let raw_query = raw_query.trim().to_owned(); + let raw_query = raw_query.trim(); - let file_query_end = if path_position.path.to_str().unwrap_or(&raw_query) == raw_query { + let raw_query = raw_query.trim_end_matches(':').to_owned(); + let path = path_position.path.to_str(); + let path_trimmed = path.unwrap_or(&raw_query).trim_end_matches(':'); + let file_query_end = if path_trimmed == raw_query { None } else { // Safe to unwrap as we won't get here when the unwrap in if fails - Some(path_position.path.to_str().unwrap().len()) + Some(path.unwrap().len()) }; let query = FileSearchQuery { diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 8203d1b1fd..cd0f203d6a 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -218,6 +218,7 @@ async fn test_matching_paths(cx: &mut TestAppContext) { " ndan ", " band ", "a bandana", + "bandana:", ] { picker .update_in(cx, |picker, window, cx| { @@ -252,6 +253,53 @@ async fn test_matching_paths(cx: &mut TestAppContext) { } } +#[gpui::test] +async fn test_matching_paths_with_colon(cx: &mut TestAppContext) { + let app_state = init_test(cx); + app_state + .fs + .as_fake() + .insert_tree( + path!("/root"), + json!({ + "a": { + "foo:bar.rs": "", + "foo.rs": "", + } + }), + ) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await; + + let (picker, _, cx) = build_find_picker(project, cx); + + // 'foo:' matches both files + cx.simulate_input("foo:"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 3); + assert_match_at_position(picker, 0, "foo.rs"); + assert_match_at_position(picker, 1, "foo:bar.rs"); + }); + + // 'foo:b' matches one of the files + cx.simulate_input("b"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 2); + assert_match_at_position(picker, 0, "foo:bar.rs"); + }); + + cx.dispatch_action(editor::actions::Backspace); + + // 'foo:1' matches both files, specifying which row to jump to + cx.simulate_input("1"); + picker.update(cx, |picker, _| { + assert_eq!(picker.delegate.matches.len(), 3); + assert_match_at_position(picker, 0, "foo.rs"); + assert_match_at_position(picker, 1, "foo:bar.rs"); + }); +} + #[gpui::test] async fn test_unicode_paths(cx: &mut TestAppContext) { let app_state = init_test(cx); From fe5e81203f03e86ada3397b1738b9f0f79801368 Mon Sep 17 00:00:00 2001 From: versecafe <147033096+versecafe@users.noreply.github.com> Date: Mon, 25 Aug 2025 03:55:56 -0700 Subject: [PATCH 02/54] Fix macOS arch reporting from `arch_ios` to `arch_arm` (#36217) ```xml arch_kind arch_arm ``` Closes #36037 Release Notes: - N/A --- crates/zed/resources/info/SupportedPlatforms.plist | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 crates/zed/resources/info/SupportedPlatforms.plist diff --git a/crates/zed/resources/info/SupportedPlatforms.plist b/crates/zed/resources/info/SupportedPlatforms.plist new file mode 100644 index 0000000000..fd2a4101d8 --- /dev/null +++ b/crates/zed/resources/info/SupportedPlatforms.plist @@ -0,0 +1,4 @@ +CFBundleSupportedPlatforms + + MacOSX + From dfc99de7b8c3796ded1c5ec73b585f85ef7bc783 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 08:18:23 -0300 Subject: [PATCH 03/54] thread view: Add a few UI tweaks (#36845) Release Notes: - N/A --- assets/icons/copy.svg | 5 +- crates/agent_ui/src/acp/thread_view.rs | 76 +++++++++++--------------- crates/markdown/src/markdown.rs | 11 ++-- 3 files changed, 43 insertions(+), 49 deletions(-) diff --git a/assets/icons/copy.svg b/assets/icons/copy.svg index bca13f8d56..aba193930b 100644 --- a/assets/icons/copy.svg +++ b/assets/icons/copy.svg @@ -1 +1,4 @@ - + + + + diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 9caa4bad8c..0b987e25b6 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1306,7 +1306,11 @@ impl AcpThreadView { v_flex() .id(("user_message", entry_ix)) - .pt_2() + .map(|this| if rules_item.is_some() { + this.pt_3() + } else { + this.pt_2() + }) .pb_4() .px_2() .gap_1p5() @@ -1315,6 +1319,7 @@ impl AcpThreadView { .children(message.id.clone().and_then(|message_id| { message.checkpoint.as_ref()?.show.then(|| { h_flex() + .px_3() .gap_2() .child(Divider::horizontal()) .child( @@ -1492,9 +1497,7 @@ impl AcpThreadView { .child(self.render_thread_controls(cx)) .when_some( self.thread_feedback.comments_editor.clone(), - |this, editor| { - this.child(Self::render_feedback_feedback_editor(editor, window, cx)) - }, + |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), ) .into_any_element() } else { @@ -1725,6 +1728,7 @@ impl AcpThreadView { tool_call.status, ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed ); + let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } @@ -1742,7 +1746,7 @@ impl AcpThreadView { .absolute() .top_0() .right_0() - .w_16() + .w_12() .h_full() .bg(linear_gradient( 90., @@ -1902,7 +1906,7 @@ impl AcpThreadView { .into_any() }), ) - .when(in_progress && use_card_layout, |this| { + .when(in_progress && use_card_layout && !is_open, |this| { this.child( div().absolute().right_2().child( Icon::new(IconName::ArrowCircle) @@ -2460,7 +2464,6 @@ impl AcpThreadView { Some( h_flex() .px_2p5() - .pb_1() .child( Icon::new(IconName::Attach) .size(IconSize::XSmall) @@ -2476,8 +2479,7 @@ impl AcpThreadView { Label::new(user_rules_text) .size(LabelSize::XSmall) .color(Color::Muted) - .truncate() - .buffer_font(cx), + .truncate(), ) .hover(|s| s.bg(cx.theme().colors().element_hover)) .tooltip(Tooltip::text("View User Rules")) @@ -2491,7 +2493,13 @@ impl AcpThreadView { }), ) }) - .when(has_both, |this| this.child(Divider::vertical())) + .when(has_both, |this| { + this.child( + Label::new("•") + .size(LabelSize::XSmall) + .color(Color::Disabled), + ) + }) .when_some(rules_file_text, |parent, rules_file_text| { parent.child( h_flex() @@ -2500,8 +2508,7 @@ impl AcpThreadView { .child( Label::new(rules_file_text) .size(LabelSize::XSmall) - .color(Color::Muted) - .buffer_font(cx), + .color(Color::Muted), ) .hover(|s| s.bg(cx.theme().colors().element_hover)) .tooltip(Tooltip::text("View Project Rules")) @@ -3078,13 +3085,13 @@ impl AcpThreadView { h_flex() .p_1() .justify_between() + .flex_wrap() .when(expanded, |this| { this.border_b_1().border_color(cx.theme().colors().border) }) .child( h_flex() .id("edits-container") - .w_full() .gap_1() .child(Disclosure::new("edits-disclosure", expanded)) .map(|this| { @@ -4177,13 +4184,8 @@ impl AcpThreadView { container.child(open_as_markdown).child(scroll_to_top) } - fn render_feedback_feedback_editor( - editor: Entity, - window: &mut Window, - cx: &Context, - ) -> Div { - let focus_handle = editor.focus_handle(cx); - v_flex() + fn render_feedback_feedback_editor(editor: Entity, cx: &Context) -> Div { + h_flex() .key_context("AgentFeedbackMessageEditor") .on_action(cx.listener(move |this, _: &menu::Cancel, _, cx| { this.thread_feedback.dismiss_comments(); @@ -4192,43 +4194,31 @@ impl AcpThreadView { .on_action(cx.listener(move |this, _: &menu::Confirm, _window, cx| { this.submit_feedback_message(cx); })) - .mb_2() - .mx_4() .p_2() + .mb_2() + .mx_5() + .gap_1() .rounded_md() .border_1() .border_color(cx.theme().colors().border) .bg(cx.theme().colors().editor_background) - .child(editor) + .child(div().w_full().child(editor)) .child( h_flex() - .gap_1() - .justify_end() .child( - Button::new("dismiss-feedback-message", "Cancel") - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in(&menu::Cancel, &focus_handle, window, cx) - .map(|kb| kb.size(rems_from_px(10.))), - ) + IconButton::new("dismiss-feedback-message", IconName::Close) + .icon_color(Color::Error) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.thread_feedback.dismiss_comments(); cx.notify(); })), ) .child( - Button::new("submit-feedback-message", "Share Feedback") - .style(ButtonStyle::Tinted(ui::TintColor::Accent)) - .label_size(LabelSize::Small) - .key_binding( - KeyBinding::for_action_in( - &menu::Confirm, - &focus_handle, - window, - cx, - ) - .map(|kb| kb.size(rems_from_px(10.))), - ) + IconButton::new("submit-feedback-message", IconName::Return) + .icon_size(IconSize::XSmall) + .shape(ui::IconButtonShape::Square) .on_click(cx.listener(move |this, _, _window, cx| { this.submit_feedback_message(cx); })), diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index 39a438c512..f16da45d79 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1085,10 +1085,10 @@ impl Element for MarkdownElement { ); el.child( h_flex() - .w_5() + .w_4() .absolute() - .top_1() - .right_1() + .top_1p5() + .right_1p5() .justify_end() .child(codeblock), ) @@ -1115,11 +1115,12 @@ impl Element for MarkdownElement { cx, ); el.child( - div() + h_flex() + .w_4() .absolute() .top_0() .right_0() - .w_5() + .justify_end() .visible_on_hover("code_block") .child(codeblock), ) From 8c83281399d013aab4415b4e425063be5a02b89e Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 25 Aug 2025 08:23:36 -0400 Subject: [PATCH 04/54] acp: Fix read_file tool flickering (#36854) We were rendering a Markdown link like `[Read file x.rs (lines Y-Z)](@selection)` while the tool ran, but then switching to just `x.rs` as soon as we got the file location from the tool call (due to an if/else in the UI code that applies to all tools). This caused a flicker, which is fixed by having `initial_title` return just the filename from the input as it arrives instead of a link that we're going to stop rendering almost immediately anyway. Release Notes: - N/A --- crates/agent2/src/tools/read_file_tool.rs | 29 ++++++----------------- 1 file changed, 7 insertions(+), 22 deletions(-) diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index 903e1582ac..fea9732093 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -10,7 +10,7 @@ use project::{AgentLocation, ImageItem, Project, WorktreeSettings, image_store}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; -use std::sync::Arc; +use std::{path::Path, sync::Arc}; use crate::{AgentTool, ToolCallEventStream}; @@ -68,27 +68,12 @@ impl AgentTool for ReadFileTool { } fn initial_title(&self, input: Result) -> SharedString { - if let Ok(input) = input { - let path = &input.path; - match (input.start_line, input.end_line) { - (Some(start), Some(end)) => { - format!( - "[Read file `{}` (lines {}-{})](@selection:{}:({}-{}))", - path, start, end, path, start, end - ) - } - (Some(start), None) => { - format!( - "[Read file `{}` (from line {})](@selection:{}:({}-{}))", - path, start, path, start, start - ) - } - _ => format!("[Read file `{}`](@file:{})", path, path), - } - .into() - } else { - "Read file".into() - } + input + .ok() + .as_ref() + .and_then(|input| Path::new(&input.path).file_name()) + .map(|file_name| file_name.to_string_lossy().to_string().into()) + .unwrap_or_default() } fn run( From 4c0ad95acc50c8bc509e5845991c811dbcf1a513 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 14:52:25 +0200 Subject: [PATCH 05/54] acp: Show retry button for errors (#36862) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/acp_thread/src/acp_thread.rs | 6 +- crates/acp_thread/src/connection.rs | 8 +- crates/agent2/src/agent.rs | 8 +- crates/agent2/src/tests/mod.rs | 98 ++++++++++++++++--- crates/agent2/src/thread.rs | 124 +++++++++++++------------ crates/agent_ui/src/acp/thread_view.rs | 46 ++++++++- 6 files changed, 212 insertions(+), 78 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 029d175054..d9a7a2582a 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -1373,6 +1373,10 @@ impl AcpThread { }) } + pub fn can_resume(&self, cx: &App) -> bool { + self.connection.resume(&self.session_id, cx).is_some() + } + pub fn resume(&mut self, cx: &mut Context) -> BoxFuture<'static, Result<()>> { self.run_turn(cx, async move |this, cx| { this.update(cx, |this, cx| { @@ -2659,7 +2663,7 @@ mod tests { fn truncate( &self, session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(FakeAgentSessionEditor { _session_id: session_id.clone(), diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 91e46dbac1..5f5032e588 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -43,7 +43,7 @@ pub trait AgentConnection { fn resume( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } @@ -53,7 +53,7 @@ pub trait AgentConnection { fn truncate( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } @@ -61,7 +61,7 @@ pub trait AgentConnection { fn set_title( &self, _session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { None } @@ -439,7 +439,7 @@ mod test_support { fn truncate( &self, _session_id: &agent_client_protocol::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(StubAgentSessionEditor)) } diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 4eaf87e218..415933b7d1 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -936,7 +936,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn resume( &self, session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(NativeAgentSessionResume { connection: self.clone(), @@ -956,9 +956,9 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn truncate( &self, session_id: &agent_client_protocol::SessionId, - cx: &mut App, + cx: &App, ) -> Option> { - self.0.update(cx, |agent, _cx| { + self.0.read_with(cx, |agent, _cx| { agent.sessions.get(session_id).map(|session| { Rc::new(NativeAgentSessionEditor { thread: session.thread.clone(), @@ -971,7 +971,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { fn set_title( &self, session_id: &acp::SessionId, - _cx: &mut App, + _cx: &App, ) -> Option> { Some(Rc::new(NativeAgentSessionSetTitle { connection: self.clone(), diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 5b935dae4c..87ecc1037c 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -5,6 +5,7 @@ use agent_settings::AgentProfileId; use anyhow::Result; use client::{Client, UserStore}; use cloud_llm_client::CompletionIntent; +use collections::IndexMap; use context_server::{ContextServer, ContextServerCommand, ContextServerId}; use fs::{FakeFs, Fs}; use futures::{ @@ -673,15 +674,6 @@ async fn test_resume_after_tool_use_limit(cx: &mut TestAppContext) { "} ) }); - - // Ensure we error if calling resume when tool use limit was *not* reached. - let error = thread - .update(cx, |thread, cx| thread.resume(cx)) - .unwrap_err(); - assert_eq!( - error.to_string(), - "can only resume after tool use limit is reached" - ) } #[gpui::test] @@ -2105,6 +2097,7 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { .unwrap(); cx.run_until_parked(); + fake_model.send_last_completion_stream_text_chunk("Hey,"); fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { provider: LanguageModelProviderName::new("Anthropic"), retry_after: Some(Duration::from_secs(3)), @@ -2114,8 +2107,9 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { cx.executor().advance_clock(Duration::from_secs(3)); cx.run_until_parked(); - fake_model.send_last_completion_stream_text_chunk("Hey!"); + fake_model.send_last_completion_stream_text_chunk("there!"); fake_model.end_last_completion_stream(); + cx.run_until_parked(); let mut retry_events = Vec::new(); while let Some(Ok(event)) = events.next().await { @@ -2143,12 +2137,94 @@ async fn test_send_retry_on_error(cx: &mut TestAppContext) { ## Assistant - Hey! + Hey, + + [resume] + + ## Assistant + + there! "} ) }); } +#[gpui::test] +async fn test_send_retry_finishes_tool_calls_on_error(cx: &mut TestAppContext) { + let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; + let fake_model = model.as_fake(); + + let events = thread + .update(cx, |thread, cx| { + thread.set_completion_mode(agent_settings::CompletionMode::Burn, cx); + thread.add_tool(EchoTool); + thread.send(UserMessageId::new(), ["Call the echo tool!"], cx) + }) + .unwrap(); + cx.run_until_parked(); + + let tool_use_1 = LanguageModelToolUse { + id: "tool_1".into(), + name: EchoTool::name().into(), + raw_input: json!({"text": "test"}).to_string(), + input: json!({"text": "test"}), + is_input_complete: true, + }; + fake_model.send_last_completion_stream_event(LanguageModelCompletionEvent::ToolUse( + tool_use_1.clone(), + )); + fake_model.send_last_completion_stream_error(LanguageModelCompletionError::ServerOverloaded { + provider: LanguageModelProviderName::new("Anthropic"), + retry_after: Some(Duration::from_secs(3)), + }); + fake_model.end_last_completion_stream(); + + cx.executor().advance_clock(Duration::from_secs(3)); + let completion = fake_model.pending_completions().pop().unwrap(); + assert_eq!( + completion.messages[1..], + vec![ + LanguageModelRequestMessage { + role: Role::User, + content: vec!["Call the echo tool!".into()], + cache: false + }, + LanguageModelRequestMessage { + role: Role::Assistant, + content: vec![language_model::MessageContent::ToolUse(tool_use_1.clone())], + cache: false + }, + LanguageModelRequestMessage { + role: Role::User, + content: vec![language_model::MessageContent::ToolResult( + LanguageModelToolResult { + tool_use_id: tool_use_1.id.clone(), + tool_name: tool_use_1.name.clone(), + is_error: false, + content: "test".into(), + output: Some("test".into()) + } + )], + cache: true + }, + ] + ); + + fake_model.send_last_completion_stream_text_chunk("Done"); + fake_model.end_last_completion_stream(); + cx.run_until_parked(); + events.collect::>().await; + thread.read_with(cx, |thread, _cx| { + assert_eq!( + thread.last_message(), + Some(Message::Agent(AgentMessage { + content: vec![AgentMessageContent::Text("Done".into())], + tool_results: IndexMap::default() + })) + ); + }) +} + #[gpui::test] async fn test_send_max_retries_exceeded(cx: &mut TestAppContext) { let ThreadTest { thread, model, .. } = setup(cx, TestModel::Fake).await; diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index c000027368..43f391ca64 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -123,7 +123,7 @@ impl Message { match self { Message::User(message) => message.to_markdown(), Message::Agent(message) => message.to_markdown(), - Message::Resume => "[resumed after tool use limit was reached]".into(), + Message::Resume => "[resume]\n".into(), } } @@ -1085,11 +1085,6 @@ impl Thread { &mut self, cx: &mut Context, ) -> Result>> { - anyhow::ensure!( - self.tool_use_limit_reached, - "can only resume after tool use limit is reached" - ); - self.messages.push(Message::Resume); cx.notify(); @@ -1216,12 +1211,13 @@ impl Thread { cx: &mut AsyncApp, ) -> Result<()> { log::debug!("Stream completion started successfully"); - let request = this.update(cx, |this, cx| { - this.build_completion_request(completion_intent, cx) - })??; let mut attempt = None; - 'retry: loop { + loop { + let request = this.update(cx, |this, cx| { + this.build_completion_request(completion_intent, cx) + })??; + telemetry::event!( "Agent Thread Completion", thread_id = this.read_with(cx, |this, _| this.id.to_string())?, @@ -1236,10 +1232,11 @@ impl Thread { attempt.unwrap_or(0) ); let mut events = model - .stream_completion(request.clone(), cx) + .stream_completion(request, cx) .await .map_err(|error| anyhow!(error))?; let mut tool_results = FuturesUnordered::new(); + let mut error = None; while let Some(event) = events.next().await { match event { @@ -1249,51 +1246,9 @@ impl Thread { this.handle_streamed_completion_event(event, event_stream, cx) })??); } - Err(error) => { - let completion_mode = - this.read_with(cx, |thread, _cx| thread.completion_mode())?; - if completion_mode == CompletionMode::Normal { - return Err(anyhow!(error))?; - } - - let Some(strategy) = Self::retry_strategy_for(&error) else { - return Err(anyhow!(error))?; - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - let attempt = attempt.get_or_insert(0u8); - - *attempt += 1; - - let attempt = *attempt; - if attempt > max_attempts { - return Err(anyhow!(error))?; - } - - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = - initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; - log::debug!("Retry attempt {attempt} with delay {delay:?}"); - - event_stream.send_retry(acp_thread::RetryStatus { - last_error: error.to_string().into(), - attempt: attempt as usize, - max_attempts: max_attempts as usize, - started_at: Instant::now(), - duration: delay, - }); - - cx.background_executor().timer(delay).await; - continue 'retry; + Err(err) => { + error = Some(err); + break; } } } @@ -1320,7 +1275,58 @@ impl Thread { })?; } - return Ok(()); + if let Some(error) = error { + let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?; + if completion_mode == CompletionMode::Normal { + return Err(anyhow!(error))?; + } + + let Some(strategy) = Self::retry_strategy_for(&error) else { + return Err(anyhow!(error))?; + }; + + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; + + let attempt = attempt.get_or_insert(0u8); + + *attempt += 1; + + let attempt = *attempt; + if attempt > max_attempts { + return Err(anyhow!(error))?; + } + + let delay = match &strategy { + RetryStrategy::ExponentialBackoff { initial_delay, .. } => { + let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); + Duration::from_secs(delay_secs) + } + RetryStrategy::Fixed { delay, .. } => *delay, + }; + log::debug!("Retry attempt {attempt} with delay {delay:?}"); + + event_stream.send_retry(acp_thread::RetryStatus { + last_error: error.to_string().into(), + attempt: attempt as usize, + max_attempts: max_attempts as usize, + started_at: Instant::now(), + duration: delay, + }); + cx.background_executor().timer(delay).await; + this.update(cx, |this, cx| { + this.flush_pending_message(cx); + if let Some(Message::Agent(message)) = this.messages.last() { + if message.tool_results.is_empty() { + this.messages.push(Message::Resume); + } + } + })?; + } else { + return Ok(()); + } } } @@ -1737,6 +1743,10 @@ impl Thread { return; }; + if message.content.is_empty() { + return; + } + for content in &message.content { let AgentMessageContent::ToolUse(tool_use) = content else { continue; diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 0b987e25b6..5674b15c98 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -820,6 +820,9 @@ impl AcpThreadView { let Some(thread) = self.thread() else { return; }; + if !thread.read(cx).can_resume(cx) { + return; + } let task = thread.update(cx, |thread, cx| thread.resume(cx)); cx.spawn(async move |this, cx| { @@ -4459,12 +4462,53 @@ impl AcpThreadView { } fn render_any_thread_error(&self, error: SharedString, cx: &mut Context<'_, Self>) -> Callout { + let can_resume = self + .thread() + .map_or(false, |thread| thread.read(cx).can_resume(cx)); + + let can_enable_burn_mode = self.as_native_thread(cx).map_or(false, |thread| { + let thread = thread.read(cx); + let supports_burn_mode = thread + .model() + .map_or(false, |model| model.supports_burn_mode()); + supports_burn_mode && thread.completion_mode() == CompletionMode::Normal + }); + Callout::new() .severity(Severity::Error) .title("Error") .icon(IconName::XCircle) .description(error.clone()) - .actions_slot(self.create_copy_button(error.to_string())) + .actions_slot( + h_flex() + .gap_0p5() + .when(can_resume && can_enable_burn_mode, |this| { + this.child( + Button::new("enable-burn-mode-and-retry", "Enable Burn Mode and Retry") + .icon(IconName::ZedBurnMode) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, window, cx| { + this.toggle_burn_mode(&ToggleBurnMode, window, cx); + this.resume_chat(cx); + })), + ) + }) + .when(can_resume, |this| { + this.child( + Button::new("retry", "Retry") + .icon(IconName::RotateCw) + .icon_position(IconPosition::Start) + .icon_size(IconSize::Small) + .label_size(LabelSize::Small) + .on_click(cx.listener(|this, _, _window, cx| { + this.resume_chat(cx); + })), + ) + }) + .child(self.create_copy_button(error.to_string())), + ) .dismiss_action(self.dismiss_error_button(cx)) } From 2b5a3029727f6aa031f6771add3cc4a8cc85515a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 10:08:48 -0300 Subject: [PATCH 06/54] thread view: Prevent user message controls to be cut-off (#36865) In the thread view, when focusing on the user message, we display the editing control container absolutely-positioned in the top right. However, if there are no rules items and no restore checkpoint button _and_ it is the very first message, the editing controls container would be cut-off. This PR fixes that by giving it a bit more top padding. Release Notes: - N/A --- crates/agent_ui/src/acp/thread_view.rs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 5674b15c98..25f2745f75 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1305,14 +1305,23 @@ impl AcpThreadView { None }; + let has_checkpoint_button = message + .checkpoint + .as_ref() + .is_some_and(|checkpoint| checkpoint.show); + let agent_name = self.agent.name(); v_flex() .id(("user_message", entry_ix)) - .map(|this| if rules_item.is_some() { - this.pt_3() - } else { - this.pt_2() + .map(|this| { + if entry_ix == 0 && !has_checkpoint_button && rules_item.is_none() { + this.pt_4() + } else if rules_item.is_some() { + this.pt_3() + } else { + this.pt_2() + } }) .pb_4() .px_2() From db949546cf477818da206f12f5e0dfa45f2e038a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 15:14:48 +0200 Subject: [PATCH 07/54] agent2: Less noisy logs (#36863) Release Notes: - N/A --- crates/agent2/src/agent.rs | 10 ++++----- crates/agent2/src/native_agent_server.rs | 4 ++-- crates/agent2/src/thread.rs | 26 ++++++++++++------------ 3 files changed, 20 insertions(+), 20 deletions(-) diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 415933b7d1..1576c3cf96 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -180,7 +180,7 @@ impl NativeAgent { fs: Arc, cx: &mut AsyncApp, ) -> Result> { - log::info!("Creating new NativeAgent"); + log::debug!("Creating new NativeAgent"); let project_context = cx .update(|cx| Self::build_project_context(&project, prompt_store.as_ref(), cx))? @@ -756,7 +756,7 @@ impl NativeAgentConnection { } } - log::info!("Response stream completed"); + log::debug!("Response stream completed"); anyhow::Ok(acp::PromptResponse { stop_reason: acp::StopReason::EndTurn, }) @@ -781,7 +781,7 @@ impl AgentModelSelector for NativeAgentConnection { model_id: acp_thread::AgentModelId, cx: &mut App, ) -> Task> { - log::info!("Setting model for session {}: {}", session_id, model_id); + log::debug!("Setting model for session {}: {}", session_id, model_id); let Some(thread) = self .0 .read(cx) @@ -852,7 +852,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { cx: &mut App, ) -> Task>> { let agent = self.0.clone(); - log::info!("Creating new thread for project at: {:?}", cwd); + log::debug!("Creating new thread for project at: {:?}", cwd); cx.spawn(async move |cx| { log::debug!("Starting thread creation in async context"); @@ -917,7 +917,7 @@ impl acp_thread::AgentConnection for NativeAgentConnection { .into_iter() .map(Into::into) .collect::>(); - log::info!("Converted prompt to message: {} chars", content.len()); + log::debug!("Converted prompt to message: {} chars", content.len()); log::debug!("Message id: {:?}", id); log::debug!("Message content: {:?}", content); diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 12d3c79d1b..33ee44c9a3 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -44,7 +44,7 @@ impl AgentServer for NativeAgentServer { project: &Entity, cx: &mut App, ) -> Task>> { - log::info!( + log::debug!( "NativeAgentServer::connect called for path: {:?}", _root_dir ); @@ -63,7 +63,7 @@ impl AgentServer for NativeAgentServer { // Create the connection wrapper let connection = NativeAgentConnection(agent); - log::info!("NativeAgentServer connection established successfully"); + log::debug!("NativeAgentServer connection established successfully"); Ok(Rc::new(connection) as Rc) }) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 43f391ca64..4bbbdbdec7 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1088,7 +1088,7 @@ impl Thread { self.messages.push(Message::Resume); cx.notify(); - log::info!("Total messages in thread: {}", self.messages.len()); + log::debug!("Total messages in thread: {}", self.messages.len()); self.run_turn(cx) } @@ -1106,7 +1106,7 @@ impl Thread { { let model = self.model().context("No language model configured")?; - log::info!("Thread::send called with model: {:?}", model.name()); + log::info!("Thread::send called with model: {}", model.name().0); self.advance_prompt_id(); let content = content.into_iter().map(Into::into).collect::>(); @@ -1116,7 +1116,7 @@ impl Thread { .push(Message::User(UserMessage { id, content })); cx.notify(); - log::info!("Total messages in thread: {}", self.messages.len()); + log::debug!("Total messages in thread: {}", self.messages.len()); self.run_turn(cx) } @@ -1140,7 +1140,7 @@ impl Thread { event_stream: event_stream.clone(), tools: self.enabled_tools(profile, &model, cx), _task: cx.spawn(async move |this, cx| { - log::info!("Starting agent turn execution"); + log::debug!("Starting agent turn execution"); let turn_result: Result<()> = async { let mut intent = CompletionIntent::UserPrompt; @@ -1165,7 +1165,7 @@ impl Thread { log::info!("Tool use limit reached, completing turn"); return Err(language_model::ToolUseLimitReachedError.into()); } else if end_turn { - log::info!("No tool uses found, completing turn"); + log::debug!("No tool uses found, completing turn"); return Ok(()); } else { intent = CompletionIntent::ToolResults; @@ -1177,7 +1177,7 @@ impl Thread { match turn_result { Ok(()) => { - log::info!("Turn execution completed"); + log::debug!("Turn execution completed"); event_stream.send_stop(acp::StopReason::EndTurn); } Err(error) => { @@ -1227,7 +1227,7 @@ impl Thread { attempt ); - log::info!( + log::debug!( "Calling model.stream_completion, attempt {}", attempt.unwrap_or(0) ); @@ -1254,7 +1254,7 @@ impl Thread { } while let Some(tool_result) = tool_results.next().await { - log::info!("Tool finished {:?}", tool_result); + log::debug!("Tool finished {:?}", tool_result); event_stream.update_tool_call_fields( &tool_result.tool_use_id, @@ -1528,7 +1528,7 @@ impl Thread { }); let supports_images = self.model().is_some_and(|model| model.supports_images()); let tool_result = tool.run(tool_use.input, tool_event_stream, cx); - log::info!("Running tool {}", tool_use.name); + log::debug!("Running tool {}", tool_use.name); Some(cx.foreground_executor().spawn(async move { let tool_result = tool_result.await.and_then(|output| { if let LanguageModelToolResultContent::Image(_) = &output.llm_output @@ -1640,7 +1640,7 @@ impl Thread { summary.extend(lines.next()); } - log::info!("Setting summary: {}", summary); + log::debug!("Setting summary: {}", summary); let summary = SharedString::from(summary); this.update(cx, |this, cx| { @@ -1657,7 +1657,7 @@ impl Thread { return; }; - log::info!( + log::debug!( "Generating title with model: {:?}", self.summarization_model.as_ref().map(|model| model.name()) ); @@ -1799,8 +1799,8 @@ impl Thread { log::debug!("Completion mode: {:?}", self.completion_mode); let messages = self.build_request_messages(cx); - log::info!("Request will include {} messages", messages.len()); - log::info!("Request includes {} tools", tools.len()); + log::debug!("Request will include {} messages", messages.len()); + log::debug!("Request includes {} tools", tools.len()); let request = LanguageModelRequest { thread_id: Some(self.id.to_string()), From 69127d2beaf69900505c420adef9310a5aeb9694 Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 25 Aug 2025 15:38:19 +0200 Subject: [PATCH 08/54] acp: Simplify control flow for native agent loop (#36868) Release Notes: - N/A Co-authored-by: Bennet Bo Fenner --- crates/agent2/src/thread.rs | 164 ++++++++++++++++-------------------- 1 file changed, 73 insertions(+), 91 deletions(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 4bbbdbdec7..2d1e608297 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -1142,37 +1142,7 @@ impl Thread { _task: cx.spawn(async move |this, cx| { log::debug!("Starting agent turn execution"); - let turn_result: Result<()> = async { - let mut intent = CompletionIntent::UserPrompt; - loop { - Self::stream_completion(&this, &model, intent, &event_stream, cx).await?; - - let mut end_turn = true; - this.update(cx, |this, cx| { - // Generate title if needed. - if this.title.is_none() && this.pending_title_generation.is_none() { - this.generate_title(cx); - } - - // End the turn if the model didn't use tools. - let message = this.pending_message.as_ref(); - end_turn = - message.map_or(true, |message| message.tool_results.is_empty()); - this.flush_pending_message(cx); - })?; - - if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { - log::info!("Tool use limit reached, completing turn"); - return Err(language_model::ToolUseLimitReachedError.into()); - } else if end_turn { - log::debug!("No tool uses found, completing turn"); - return Ok(()); - } else { - intent = CompletionIntent::ToolResults; - } - } - } - .await; + let turn_result = Self::run_turn_internal(&this, model, &event_stream, cx).await; _ = this.update(cx, |this, cx| this.flush_pending_message(cx)); match turn_result { @@ -1203,20 +1173,17 @@ impl Thread { Ok(events_rx) } - async fn stream_completion( + async fn run_turn_internal( this: &WeakEntity, - model: &Arc, - completion_intent: CompletionIntent, + model: Arc, event_stream: &ThreadEventStream, cx: &mut AsyncApp, ) -> Result<()> { - log::debug!("Stream completion started successfully"); - - let mut attempt = None; + let mut attempt = 0; + let mut intent = CompletionIntent::UserPrompt; loop { - let request = this.update(cx, |this, cx| { - this.build_completion_request(completion_intent, cx) - })??; + let request = + this.update(cx, |this, cx| this.build_completion_request(intent, cx))??; telemetry::event!( "Agent Thread Completion", @@ -1227,23 +1194,19 @@ impl Thread { attempt ); - log::debug!( - "Calling model.stream_completion, attempt {}", - attempt.unwrap_or(0) - ); + log::debug!("Calling model.stream_completion, attempt {}", attempt); let mut events = model .stream_completion(request, cx) .await .map_err(|error| anyhow!(error))?; let mut tool_results = FuturesUnordered::new(); let mut error = None; - while let Some(event) = events.next().await { + log::trace!("Received completion event: {:?}", event); match event { Ok(event) => { - log::trace!("Received completion event: {:?}", event); tool_results.extend(this.update(cx, |this, cx| { - this.handle_streamed_completion_event(event, event_stream, cx) + this.handle_completion_event(event, event_stream, cx) })??); } Err(err) => { @@ -1253,6 +1216,7 @@ impl Thread { } } + let end_turn = tool_results.is_empty(); while let Some(tool_result) = tool_results.next().await { log::debug!("Tool finished {:?}", tool_result); @@ -1275,65 +1239,83 @@ impl Thread { })?; } + this.update(cx, |this, cx| { + this.flush_pending_message(cx); + if this.title.is_none() && this.pending_title_generation.is_none() { + this.generate_title(cx); + } + })?; + if let Some(error) = error { - let completion_mode = this.read_with(cx, |thread, _cx| thread.completion_mode())?; - if completion_mode == CompletionMode::Normal { - return Err(anyhow!(error))?; - } - - let Some(strategy) = Self::retry_strategy_for(&error) else { - return Err(anyhow!(error))?; - }; - - let max_attempts = match &strategy { - RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, - RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, - }; - - let attempt = attempt.get_or_insert(0u8); - - *attempt += 1; - - let attempt = *attempt; - if attempt > max_attempts { - return Err(anyhow!(error))?; - } - - let delay = match &strategy { - RetryStrategy::ExponentialBackoff { initial_delay, .. } => { - let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) - } - RetryStrategy::Fixed { delay, .. } => *delay, - }; - log::debug!("Retry attempt {attempt} with delay {delay:?}"); - - event_stream.send_retry(acp_thread::RetryStatus { - last_error: error.to_string().into(), - attempt: attempt as usize, - max_attempts: max_attempts as usize, - started_at: Instant::now(), - duration: delay, - }); - cx.background_executor().timer(delay).await; - this.update(cx, |this, cx| { - this.flush_pending_message(cx); + attempt += 1; + let retry = + this.update(cx, |this, _| this.handle_completion_error(error, attempt))??; + let timer = cx.background_executor().timer(retry.duration); + event_stream.send_retry(retry); + timer.await; + this.update(cx, |this, _cx| { if let Some(Message::Agent(message)) = this.messages.last() { if message.tool_results.is_empty() { + intent = CompletionIntent::UserPrompt; this.messages.push(Message::Resume); } } })?; - } else { + } else if this.read_with(cx, |this, _| this.tool_use_limit_reached)? { + return Err(language_model::ToolUseLimitReachedError.into()); + } else if end_turn { return Ok(()); + } else { + intent = CompletionIntent::ToolResults; + attempt = 0; } } } + fn handle_completion_error( + &mut self, + error: LanguageModelCompletionError, + attempt: u8, + ) -> Result { + if self.completion_mode == CompletionMode::Normal { + return Err(anyhow!(error)); + } + + let Some(strategy) = Self::retry_strategy_for(&error) else { + return Err(anyhow!(error)); + }; + + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; + + if attempt > max_attempts { + return Err(anyhow!(error)); + } + + let delay = match &strategy { + RetryStrategy::ExponentialBackoff { initial_delay, .. } => { + let delay_secs = initial_delay.as_secs() * 2u64.pow((attempt - 1) as u32); + Duration::from_secs(delay_secs) + } + RetryStrategy::Fixed { delay, .. } => *delay, + }; + log::debug!("Retry attempt {attempt} with delay {delay:?}"); + + Ok(acp_thread::RetryStatus { + last_error: error.to_string().into(), + attempt: attempt as usize, + max_attempts: max_attempts as usize, + started_at: Instant::now(), + duration: delay, + }) + } + /// A helper method that's called on every streamed completion event. /// Returns an optional tool result task, which the main agentic loop will /// send back to the model when it resolves. - fn handle_streamed_completion_event( + fn handle_completion_event( &mut self, event: LanguageModelCompletionEvent, event_stream: &ThreadEventStream, From fda5111dc0239e3003d3c0d26346270c356cbc9f Mon Sep 17 00:00:00 2001 From: Zach Riegel Date: Mon, 25 Aug 2025 08:30:09 -0700 Subject: [PATCH 09/54] Add CSS language injections for calls to `styled` (#33966) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …emotion). Closes: https://github.com/zed-industries/zed/issues/17026 Release Notes: - Added CSS language injection support for styled-components and emotion in JavaScript, TypeScript, and TSX files. --- crates/languages/src/javascript/injections.scm | 15 +++++++++++++++ crates/languages/src/tsx/injections.scm | 15 +++++++++++++++ crates/languages/src/typescript/injections.scm | 15 +++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/crates/languages/src/javascript/injections.scm b/crates/languages/src/javascript/injections.scm index 7baba5f227..dbec1937b1 100644 --- a/crates/languages/src/javascript/injections.scm +++ b/crates/languages/src/javascript/injections.scm @@ -11,6 +11,21 @@ (#set! injection.language "css")) ) +(call_expression + function: (member_expression + object: (identifier) @_obj (#eq? @_obj "styled") + property: (property_identifier)) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + +(call_expression + function: (call_expression + function: (identifier) @_name (#eq? @_name "styled")) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + (call_expression function: (identifier) @_name (#eq? @_name "html") arguments: (template_string) @injection.content diff --git a/crates/languages/src/tsx/injections.scm b/crates/languages/src/tsx/injections.scm index 48da80995b..9eec01cc89 100644 --- a/crates/languages/src/tsx/injections.scm +++ b/crates/languages/src/tsx/injections.scm @@ -11,6 +11,21 @@ (#set! injection.language "css")) ) +(call_expression + function: (member_expression + object: (identifier) @_obj (#eq? @_obj "styled") + property: (property_identifier)) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + +(call_expression + function: (call_expression + function: (identifier) @_name (#eq? @_name "styled")) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + (call_expression function: (identifier) @_name (#eq? @_name "html") arguments: (template_string (string_fragment) @injection.content diff --git a/crates/languages/src/typescript/injections.scm b/crates/languages/src/typescript/injections.scm index 7affdc5b75..1ca1e9ad59 100644 --- a/crates/languages/src/typescript/injections.scm +++ b/crates/languages/src/typescript/injections.scm @@ -15,6 +15,21 @@ (#set! injection.language "css")) ) +(call_expression + function: (member_expression + object: (identifier) @_obj (#eq? @_obj "styled") + property: (property_identifier)) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + +(call_expression + function: (call_expression + function: (identifier) @_name (#eq? @_name "styled")) + arguments: (template_string (string_fragment) @injection.content + (#set! injection.language "css")) +) + (call_expression function: (identifier) @_name (#eq? @_name "html") arguments: (template_string) @injection.content From 2fe3dbed31147cc869bdb01aea4b7fae57f5fdc8 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 25 Aug 2025 21:00:53 +0530 Subject: [PATCH 10/54] project: Remove redundant Option from parse_register_capabilities (#36874) Release Notes: - N/A --- crates/project/src/lsp_store.rs | 83 +++++++++++++++------------------ 1 file changed, 38 insertions(+), 45 deletions(-) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index d2958dce01..853490ddac 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -11706,12 +11706,11 @@ impl LspStore { // Ignore payload since we notify clients of setting changes unconditionally, relying on them pulling the latest settings. } "workspace/symbol" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.workspace_symbol_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.workspace_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "workspace/fileOperations" => { if let Some(options) = reg.register_options { @@ -11735,12 +11734,11 @@ impl LspStore { } } "textDocument/rangeFormatting" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.document_range_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_range_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/onTypeFormatting" => { if let Some(options) = reg @@ -11755,36 +11753,32 @@ impl LspStore { } } "textDocument/formatting" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.document_formatting_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_formatting_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/rename" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.rename_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.rename_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/inlayHint" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.inlay_hint_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.inlay_hint_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/documentSymbol" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.document_symbol_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.document_symbol_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/codeAction" => { if let Some(options) = reg @@ -11800,12 +11794,11 @@ impl LspStore { } } "textDocument/definition" => { - if let Some(options) = parse_register_capabilities(reg)? { - server.update_capabilities(|capabilities| { - capabilities.definition_provider = Some(options); - }); - notify_server_capabilities_updated(&server, cx); - } + let options = parse_register_capabilities(reg)?; + server.update_capabilities(|capabilities| { + capabilities.definition_provider = Some(options); + }); + notify_server_capabilities_updated(&server, cx); } "textDocument/completion" => { if let Some(caps) = reg @@ -12184,10 +12177,10 @@ impl LspStore { // https://github.com/microsoft/vscode-languageserver-node/blob/d90a87f9557a0df9142cfb33e251cfa6fe27d970/client/src/common/client.ts#L2133 fn parse_register_capabilities( reg: lsp::Registration, -) -> anyhow::Result>> { +) -> Result> { Ok(match reg.register_options { - Some(options) => Some(OneOf::Right(serde_json::from_value::(options)?)), - None => Some(OneOf::Left(true)), + Some(options) => OneOf::Right(serde_json::from_value::(options)?), + None => OneOf::Left(true), }) } From 65fb17e2c9f817e7d7776cc406e3c7f3291fd24d Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 09:34:30 -0600 Subject: [PATCH 11/54] acp: Remember following state (#36793) A beta user reported that following was "lost" when asking for confirmation, I suspect they moved their cursor in the agent file while reviewing the change. Now we will resume following when the agent starts up again. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 73 ++++++++++++++++++++------ 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index d9a7a2582a..cc33879586 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -774,7 +774,7 @@ pub enum AcpThreadEvent { impl EventEmitter for AcpThread {} -#[derive(PartialEq, Eq)] +#[derive(PartialEq, Eq, Debug)] pub enum ThreadStatus { Idle, WaitingForToolConfirmation, diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 25f2745f75..609777e2d1 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -274,6 +274,7 @@ pub struct AcpThreadView { edits_expanded: bool, plan_expanded: bool, editor_expanded: bool, + should_be_following: bool, editing_message: Option, prompt_capabilities: Rc>, is_loading_contents: bool, @@ -385,6 +386,7 @@ impl AcpThreadView { edits_expanded: false, plan_expanded: false, editor_expanded: false, + should_be_following: false, history_store, hovered_recent_history_item: None, prompt_capabilities, @@ -897,6 +899,13 @@ impl AcpThreadView { let Some(thread) = self.thread().cloned() else { return; }; + if self.should_be_following { + self.workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } self.is_loading_contents = true; let guard = cx.new(|_| ()); @@ -938,6 +947,16 @@ impl AcpThreadView { this.handle_thread_error(err, cx); }) .ok(); + } else { + this.update(cx, |this, cx| { + this.should_be_following = this + .workspace + .update(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or_default(); + }) + .ok(); } }) .detach(); @@ -1254,6 +1273,7 @@ impl AcpThreadView { tool_call_id: acp::ToolCallId, option_id: acp::PermissionOptionId, option_kind: acp::PermissionOptionKind, + window: &mut Window, cx: &mut Context, ) { let Some(thread) = self.thread() else { @@ -1262,6 +1282,13 @@ impl AcpThreadView { thread.update(cx, |thread, cx| { thread.authorize_tool_call(tool_call_id, option_id, option_kind, cx); }); + if self.should_be_following { + self.workspace + .update(cx, |workspace, cx| { + workspace.follow(CollaboratorId::Agent, window, cx); + }) + .ok(); + } cx.notify(); } @@ -2095,11 +2122,12 @@ impl AcpThreadView { let tool_call_id = tool_call_id.clone(); let option_id = option.id.clone(); let option_kind = option.kind; - move |this, _, _, cx| { + move |this, _, window, cx| { this.authorize_tool_call( tool_call_id.clone(), option_id.clone(), option_kind, + window, cx, ); } @@ -3652,13 +3680,34 @@ impl AcpThreadView { } } - fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { - let following = self - .workspace - .read_with(cx, |workspace, _| { - workspace.is_being_followed(CollaboratorId::Agent) + fn is_following(&self, cx: &App) -> bool { + match self.thread().map(|thread| thread.read(cx).status()) { + Some(ThreadStatus::Generating) => self + .workspace + .read_with(cx, |workspace, _| { + workspace.is_being_followed(CollaboratorId::Agent) + }) + .unwrap_or(false), + _ => self.should_be_following, + } + } + + fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { + let following = self.is_following(cx); + self.should_be_following = !following; + self.workspace + .update(cx, |workspace, cx| { + if following { + workspace.unfollow(CollaboratorId::Agent, window, cx); + } else { + workspace.follow(CollaboratorId::Agent, window, cx); + } }) - .unwrap_or(false); + .ok(); + } + + fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { + let following = self.is_following(cx); IconButton::new("follow-agent", IconName::Crosshair) .icon_size(IconSize::Small) @@ -3679,15 +3728,7 @@ impl AcpThreadView { } }) .on_click(cx.listener(move |this, _, window, cx| { - this.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); + this.toggle_following(window, cx); })) } From 557753d092e167422ae28df5d4399612dc59e893 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 17:46:07 +0200 Subject: [PATCH 12/54] acp: Add Reauthenticate to dropdown (#36878) Release Notes: - N/A Co-authored-by: Conrad Irwin --- crates/agent_ui/src/acp/thread_view.rs | 18 ++++++++++++++++++ crates/agent_ui/src/agent_panel.rs | 13 +++++++++++++ crates/zed_actions/src/lib.rs | 4 +++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 609777e2d1..18a65ec634 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -4746,6 +4746,24 @@ impl AcpThreadView { })) } + pub(crate) fn reauthenticate(&mut self, window: &mut Window, cx: &mut Context) { + let agent = self.agent.clone(); + let ThreadState::Ready { thread, .. } = &self.thread_state else { + return; + }; + + let connection = thread.read(cx).connection().clone(); + let err = AuthRequired { + description: None, + provider_id: None, + }; + self.clear_thread_error(cx); + let this = cx.weak_entity(); + window.defer(cx, |window, cx| { + Self::handle_auth_required(this, err, agent, connection, window, cx); + }) + } + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 50f9fc6a45..f1a8a744ee 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9,6 +9,7 @@ use agent_servers::AgentServerSettings; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; use crate::agent_diff::AgentDiffThread; @@ -2204,6 +2205,8 @@ impl AgentPanel { "Enable Full Screen" }; + let selected_agent = self.selected_agent.clone(); + PopoverMenu::new("agent-options-menu") .trigger_with_tooltip( IconButton::new("agent-options-menu", IconName::Ellipsis) @@ -2283,6 +2286,11 @@ impl AgentPanel { .action("Settings", Box::new(OpenSettings)) .separator() .action(full_screen_label, Box::new(ToggleZoom)); + + if selected_agent == AgentType::Gemini { + menu = menu.action("Reauthenticate", Box::new(ReauthenticateAgent)) + } + menu })) } @@ -3751,6 +3759,11 @@ impl Render for AgentPanel { } })) .on_action(cx.listener(Self::toggle_burn_mode)) + .on_action(cx.listener(|this, _: &ReauthenticateAgent, window, cx| { + if let Some(thread_view) = this.active_thread_view() { + thread_view.update(cx, |thread_view, cx| thread_view.reauthenticate(window, cx)) + } + })) .child(self.render_toolbar(window, cx)) .children(self.render_onboarding(window, cx)) .map(|parent| match &self.active_view { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 069abc0a12..a5223a2cdf 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -290,7 +290,9 @@ pub mod agent { Chat, /// Toggles the language model selector dropdown. #[action(deprecated_aliases = ["assistant::ToggleModelSelector", "assistant2::ToggleModelSelector"])] - ToggleModelSelector + ToggleModelSelector, + /// Triggers re-authentication on Gemini + ReauthenticateAgent ] ); } From 2dc4f156b387ccd4698fbf1a5e54ea7050b738ca Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Aug 2025 11:51:31 -0400 Subject: [PATCH 13/54] Revert "Capture `shorthand_field_initializer` and modules in Rust highlights (#35842)" (#36880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR reverts https://github.com/zed-industries/zed/pull/35842, as it broke the syntax highlighting for `crate`: ### Before Revert Screenshot 2025-08-25 at 11 29 50 AM ### After Revert Screenshot 2025-08-25 at 11 32 17 AM This reverts commit 896a35f7befce468427a30489adf88c851b9507d. Release Notes: - Reverted https://github.com/zed-industries/zed/pull/35842. --- crates/languages/src/rust/highlights.scm | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/crates/languages/src/rust/highlights.scm b/crates/languages/src/rust/highlights.scm index 9c02fbedaa..1c46061827 100644 --- a/crates/languages/src/rust/highlights.scm +++ b/crates/languages/src/rust/highlights.scm @@ -6,9 +6,6 @@ (self) @variable.special (field_identifier) @property -(shorthand_field_initializer - (identifier) @property) - (trait_item name: (type_identifier) @type.interface) (impl_item trait: (type_identifier) @type.interface) (abstract_type trait: (type_identifier) @type.interface) @@ -41,20 +38,11 @@ (identifier) @function.special (scoped_identifier name: (identifier) @function.special) - ] - "!" @function.special) + ]) (macro_definition name: (identifier) @function.special.definition) -(mod_item - name: (identifier) @module) - -(visibility_modifier [ - (crate) @keyword - (super) @keyword -]) - ; Identifier conventions ; Assume uppercase names are types/enum-constructors @@ -127,7 +115,9 @@ "where" "while" "yield" + (crate) (mutable_specifier) + (super) ] @keyword [ @@ -199,7 +189,6 @@ operator: "/" @operator (lifetime) @lifetime -(lifetime (identifier) @lifetime) (parameter (identifier) @variable.parameter) From a102b087438c0424ce32a47177b7e16132aa24df Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 10:03:07 -0600 Subject: [PATCH 14/54] Require confirmation for fetch tool (#36881) Using prompt injection, the agent may be tricked into making a fetch request that includes unexpected data from the conversation in the URL. As agent conversations may contain sensitive information (like private code, or potentially even API keys), this seems bad. The easiest way to prevent this is to require the user to look at the URL before the model is allowed to fetch it. Thanks to @ant4g0nist for bringing this to our attention. Release Notes: - agent panel: The fetch tool now requires confirmation. --- crates/agent2/src/tools/fetch_tool.rs | 9 +++++++-- crates/assistant_tools/src/fetch_tool.rs | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/crates/agent2/src/tools/fetch_tool.rs b/crates/agent2/src/tools/fetch_tool.rs index 0313c4e4c2..dd97271a79 100644 --- a/crates/agent2/src/tools/fetch_tool.rs +++ b/crates/agent2/src/tools/fetch_tool.rs @@ -136,12 +136,17 @@ impl AgentTool for FetchTool { fn run( self: Arc, input: Self::Input, - _event_stream: ToolCallEventStream, + event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { + let authorize = event_stream.authorize(input.url.clone(), cx); + let text = cx.background_spawn({ let http_client = self.http_client.clone(); - async move { Self::build_message(http_client, &input.url).await } + async move { + authorize.await?; + Self::build_message(http_client, &input.url).await + } }); cx.foreground_executor().spawn(async move { diff --git a/crates/assistant_tools/src/fetch_tool.rs b/crates/assistant_tools/src/fetch_tool.rs index 79e205f205..cc22c9fc09 100644 --- a/crates/assistant_tools/src/fetch_tool.rs +++ b/crates/assistant_tools/src/fetch_tool.rs @@ -118,7 +118,7 @@ impl Tool for FetchTool { } fn needs_confirmation(&self, _: &serde_json::Value, _: &Entity, _: &App) -> bool { - false + true } fn may_perform_edits(&self) -> bool { From 5c346a4ccf3642e8e804db70c59616ac3cb0f86a Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Mon, 25 Aug 2025 19:12:33 +0200 Subject: [PATCH 15/54] kotlin: Specify default language server (#36871) As of https://github.com/zed-extensions/kotlin/commit/db52fc3655df8594a89b3a6b539274f23dfa2f28, the Kotlin extension has two language servers. However, following that change, no default language server for Kotlin was configured within this repo, which led to two language servers being activated for Kotlin by default. This PR makes `kotlin-language-server` the default language server for the extension. This also ensures that the [documentation within the repository](https://github.com/zed-extensions/kotlin?tab=readme-ov-file#kotlin-lsp) matches what is actually the case. Release Notes: - kotlin: Made `kotlin-language-server` the default language server. --- assets/settings/default.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/assets/settings/default.json b/assets/settings/default.json index ac26952c7f..59450dcc15 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1629,6 +1629,9 @@ "allowed": true } }, + "Kotlin": { + "language_servers": ["kotlin-language-server", "!kotlin-lsp", "..."] + }, "LaTeX": { "formatter": "language_server", "language_servers": ["texlab", "..."], From 2e1ca472414792eea4f9a8ae4eabb469b76f1cf3 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 25 Aug 2025 13:21:20 -0400 Subject: [PATCH 16/54] Make fields of `AiUpsellCard` private (#36888) This PR makes the fields of the `AiUpsellCard` private, for better encapsulation. Release Notes: - N/A --- crates/ai_onboarding/src/ai_upsell_card.rs | 15 ++++++++++----- crates/onboarding/src/ai_setup_page.rs | 18 +++++++----------- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/crates/ai_onboarding/src/ai_upsell_card.rs b/crates/ai_onboarding/src/ai_upsell_card.rs index e9639ca075..106dcb0aef 100644 --- a/crates/ai_onboarding/src/ai_upsell_card.rs +++ b/crates/ai_onboarding/src/ai_upsell_card.rs @@ -12,11 +12,11 @@ use crate::{SignInStatus, YoungAccountBanner, plan_definitions::PlanDefinitions} #[derive(IntoElement, RegisterComponent)] pub struct AiUpsellCard { - pub sign_in_status: SignInStatus, - pub sign_in: Arc, - pub account_too_young: bool, - pub user_plan: Option, - pub tab_index: Option, + sign_in_status: SignInStatus, + sign_in: Arc, + account_too_young: bool, + user_plan: Option, + tab_index: Option, } impl AiUpsellCard { @@ -43,6 +43,11 @@ impl AiUpsellCard { tab_index: None, } } + + pub fn tab_index(mut self, tab_index: Option) -> Self { + self.tab_index = tab_index; + self + } } impl RenderOnce for AiUpsellCard { diff --git a/crates/onboarding/src/ai_setup_page.rs b/crates/onboarding/src/ai_setup_page.rs index 672bcf1cd9..54c49bc72a 100644 --- a/crates/onboarding/src/ai_setup_page.rs +++ b/crates/onboarding/src/ai_setup_page.rs @@ -283,17 +283,13 @@ pub(crate) fn render_ai_setup_page( v_flex() .mt_2() .gap_6() - .child({ - let mut ai_upsell_card = - AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx); - - ai_upsell_card.tab_index = Some({ - tab_index += 1; - tab_index - 1 - }); - - ai_upsell_card - }) + .child( + AiUpsellCard::new(client, &user_store, user_store.read(cx).plan(), cx) + .tab_index(Some({ + tab_index += 1; + tab_index - 1 + })), + ) .child(render_llm_provider_section( &mut tab_index, workspace, From f1204dfc333ceb83b5769c0f3ab876fc96e39252 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Mon, 25 Aug 2025 10:46:36 -0700 Subject: [PATCH 17/54] Revert "workspace: Disable padding on zoomed panels" (#36884) Reverts zed-industries/zed#36012 We thought we didn't need this UI, but it turns out it was load bearing :) Release Notes: - Restored the zoomed panel padding --- crates/workspace/src/workspace.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index bf58786d67..3654df09be 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -6622,15 +6622,25 @@ impl Render for Workspace { } }) .children(self.zoomed.as_ref().and_then(|view| { - Some(div() + let zoomed_view = view.upgrade()?; + let div = div() .occlude() .absolute() .overflow_hidden() .border_color(colors.border) .bg(colors.background) - .child(view.upgrade()?) + .child(zoomed_view) .inset_0() - .shadow_lg()) + .shadow_lg(); + + Some(match self.zoomed_position { + Some(DockPosition::Left) => div.right_2().border_r_1(), + Some(DockPosition::Right) => div.left_2().border_l_1(), + Some(DockPosition::Bottom) => div.top_2().border_t_1(), + None => { + div.top_2().bottom_2().left_2().right_2().border_1() + } + }) })) .children(self.render_notifications(window, cx)), ) From 5fd29d37a63539c991ebae477bd4a78c849e0a78 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 25 Aug 2025 14:28:11 -0400 Subject: [PATCH 18/54] acp: Model-specific prompt capabilities for 1PA (#36879) Adds support for per-session prompt capabilities and capability changes on the Zed side (ACP itself still only has per-connection static capabilities for now), and uses it to reflect image support accurately in 1PA threads based on the currently-selected model. Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 38 +++++++++++++++++------ crates/acp_thread/src/connection.rs | 18 +++++------ crates/agent2/src/agent.rs | 13 +++----- crates/agent2/src/thread.rs | 21 +++++++++++++ crates/agent_servers/src/acp.rs | 9 +++--- crates/agent_servers/src/claude.rs | 16 +++++----- crates/agent_ui/src/acp/message_editor.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 20 ++++++------ crates/agent_ui/src/agent_diff.rs | 1 + crates/watch/src/watch.rs | 13 ++++++++ 10 files changed, 98 insertions(+), 53 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index cc33879586..779f9964da 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -756,6 +756,8 @@ pub struct AcpThread { connection: Rc, session_id: acp::SessionId, token_usage: Option, + prompt_capabilities: acp::PromptCapabilities, + _observe_prompt_capabilities: Task>, } #[derive(Debug)] @@ -770,6 +772,7 @@ pub enum AcpThreadEvent { Stopped, Error, LoadError(LoadError), + PromptCapabilitiesUpdated, } impl EventEmitter for AcpThread {} @@ -821,7 +824,20 @@ impl AcpThread { project: Entity, action_log: Entity, session_id: acp::SessionId, + mut prompt_capabilities_rx: watch::Receiver, + cx: &mut Context, ) -> Self { + let prompt_capabilities = *prompt_capabilities_rx.borrow(); + let task = cx.spawn::<_, anyhow::Result<()>>(async move |this, cx| { + loop { + let caps = prompt_capabilities_rx.recv().await?; + this.update(cx, |this, cx| { + this.prompt_capabilities = caps; + cx.emit(AcpThreadEvent::PromptCapabilitiesUpdated); + })?; + } + }); + Self { action_log, shared_buffers: Default::default(), @@ -833,9 +849,15 @@ impl AcpThread { connection, session_id, token_usage: None, + prompt_capabilities, + _observe_prompt_capabilities: task, } } + pub fn prompt_capabilities(&self) -> acp::PromptCapabilities { + self.prompt_capabilities + } + pub fn connection(&self) -> &Rc { &self.connection } @@ -2599,13 +2621,19 @@ mod tests { .into(), ); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( "Test", self.clone(), project, action_log, session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }), + cx, ) }); self.sessions.lock().insert(session_id, thread.downgrade()); @@ -2639,14 +2667,6 @@ mod tests { } } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - } - } - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { let sessions = self.sessions.lock(); let thread = sessions.get(session_id).unwrap().clone(); diff --git a/crates/acp_thread/src/connection.rs b/crates/acp_thread/src/connection.rs index 5f5032e588..af229b7545 100644 --- a/crates/acp_thread/src/connection.rs +++ b/crates/acp_thread/src/connection.rs @@ -38,8 +38,6 @@ pub trait AgentConnection { cx: &mut App, ) -> Task>; - fn prompt_capabilities(&self) -> acp::PromptCapabilities; - fn resume( &self, _session_id: &acp::SessionId, @@ -329,13 +327,19 @@ mod test_support { ) -> Task>> { let session_id = acp::SessionId(self.sessions.lock().len().to_string().into()); let action_log = cx.new(|_| ActionLog::new(project.clone())); - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( "Test", self.clone(), project, action_log, session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }), + cx, ) }); self.sessions.lock().insert( @@ -348,14 +352,6 @@ mod test_support { Task::ready(Ok(thread)) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - } - } - fn authenticate( &self, _method_id: acp::AuthMethodId, diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index 1576c3cf96..ecfaea4b49 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -240,13 +240,16 @@ impl NativeAgent { let title = thread.title(); let project = thread.project.clone(); let action_log = thread.action_log.clone(); - let acp_thread = cx.new(|_cx| { + let prompt_capabilities_rx = thread.prompt_capabilities_rx.clone(); + let acp_thread = cx.new(|cx| { acp_thread::AcpThread::new( title, connection, project.clone(), action_log.clone(), session_id.clone(), + prompt_capabilities_rx, + cx, ) }); let subscriptions = vec![ @@ -925,14 +928,6 @@ impl acp_thread::AgentConnection for NativeAgentConnection { }) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: false, - embedded_context: true, - } - } - fn resume( &self, session_id: &acp::SessionId, diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 2d1e608297..1b1c014b79 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -575,11 +575,22 @@ pub struct Thread { templates: Arc, model: Option>, summarization_model: Option>, + prompt_capabilities_tx: watch::Sender, + pub(crate) prompt_capabilities_rx: watch::Receiver, pub(crate) project: Entity, pub(crate) action_log: Entity, } impl Thread { + fn prompt_capabilities(model: Option<&dyn LanguageModel>) -> acp::PromptCapabilities { + let image = model.map_or(true, |model| model.supports_images()); + acp::PromptCapabilities { + image, + audio: false, + embedded_context: true, + } + } + pub fn new( project: Entity, project_context: Entity, @@ -590,6 +601,8 @@ impl Thread { ) -> Self { let profile_id = AgentSettings::get_global(cx).default_profile.clone(); let action_log = cx.new(|_cx| ActionLog::new(project.clone())); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); Self { id: acp::SessionId(uuid::Uuid::new_v4().to_string().into()), prompt_id: PromptId::new(), @@ -617,6 +630,8 @@ impl Thread { templates, model, summarization_model: None, + prompt_capabilities_tx, + prompt_capabilities_rx, project, action_log, } @@ -750,6 +765,8 @@ impl Thread { .or_else(|| registry.default_model()) .map(|model| model.model) }); + let (prompt_capabilities_tx, prompt_capabilities_rx) = + watch::channel(Self::prompt_capabilities(model.as_deref())); Self { id, @@ -779,6 +796,8 @@ impl Thread { project, action_log, updated_at: db_thread.updated_at, + prompt_capabilities_tx, + prompt_capabilities_rx, } } @@ -946,10 +965,12 @@ impl Thread { pub fn set_model(&mut self, model: Arc, cx: &mut Context) { let old_usage = self.latest_token_usage(); self.model = Some(model); + let new_caps = Self::prompt_capabilities(self.model.as_deref()); let new_usage = self.latest_token_usage(); if old_usage != new_usage { cx.emit(TokenUsageUpdated(new_usage)); } + self.prompt_capabilities_tx.send(new_caps).log_err(); cx.notify() } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index c9c938c6c0..5a4efe12e5 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -185,13 +185,16 @@ impl AgentConnection for AcpConnection { let session_id = response.session_id; let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( self.server_name.clone(), self.clone(), project, action_log, session_id.clone(), + // ACP doesn't currently support per-session prompt capabilities or changing capabilities dynamically. + watch::Receiver::constant(self.prompt_capabilities), + cx, ) })?; @@ -279,10 +282,6 @@ impl AgentConnection for AcpConnection { }) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - self.prompt_capabilities - } - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { session.suppress_abort_err = true; diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 048563103f..6006bf3edb 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -249,13 +249,19 @@ impl AgentConnection for ClaudeAgentConnection { }); let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|_cx| { + let thread = cx.new(|cx| { AcpThread::new( "Claude Code", self.clone(), project, action_log, session_id.clone(), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: false, + embedded_context: true, + }), + cx, ) })?; @@ -319,14 +325,6 @@ impl AgentConnection for ClaudeAgentConnection { cx.foreground_executor().spawn(async move { end_rx.await? }) } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: false, - embedded_context: true, - } - } - fn cancel(&self, session_id: &acp::SessionId, _cx: &mut App) { let sessions = self.sessions.borrow(); let Some(session) = sessions.get(session_id) else { diff --git a/crates/agent_ui/src/acp/message_editor.rs b/crates/agent_ui/src/acp/message_editor.rs index 70faa0ed27..12ae893c31 100644 --- a/crates/agent_ui/src/acp/message_editor.rs +++ b/crates/agent_ui/src/acp/message_editor.rs @@ -373,7 +373,7 @@ impl MessageEditor { if Img::extensions().contains(&extension) && !extension.contains("svg") { if !self.prompt_capabilities.get().image { - return Task::ready(Err(anyhow!("This agent does not support images yet"))); + return Task::ready(Err(anyhow!("This model does not support images yet"))); } let task = self .project diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 18a65ec634..faba18acb1 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -474,7 +474,7 @@ impl AcpThreadView { let action_log = thread.read(cx).action_log().clone(); this.prompt_capabilities - .set(connection.prompt_capabilities()); + .set(thread.read(cx).prompt_capabilities()); let count = thread.read(cx).entries().len(); this.list_state.splice(0..0, count); @@ -1163,6 +1163,10 @@ impl AcpThreadView { }); } } + AcpThreadEvent::PromptCapabilitiesUpdated => { + self.prompt_capabilities + .set(thread.read(cx).prompt_capabilities()); + } AcpThreadEvent::TokenUsageUpdated => {} } cx.notify(); @@ -5367,6 +5371,12 @@ pub(crate) mod tests { project, action_log, SessionId("test".into()), + watch::Receiver::constant(acp::PromptCapabilities { + image: true, + audio: true, + embedded_context: true, + }), + cx, ) }))) } @@ -5375,14 +5385,6 @@ pub(crate) mod tests { &[] } - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: true, - audio: true, - embedded_context: true, - } - } - fn authenticate( &self, _method_id: acp::AuthMethodId, diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index e07424987c..1e1ff95178 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1529,6 +1529,7 @@ impl AgentDiff { | AcpThreadEvent::TokenUsageUpdated | AcpThreadEvent::EntriesRemoved(_) | AcpThreadEvent::ToolAuthorizationRequired + | AcpThreadEvent::PromptCapabilitiesUpdated | AcpThreadEvent::Retry(_) => {} } } diff --git a/crates/watch/src/watch.rs b/crates/watch/src/watch.rs index f0ed5b4a18..71dab74820 100644 --- a/crates/watch/src/watch.rs +++ b/crates/watch/src/watch.rs @@ -162,6 +162,19 @@ impl Receiver { pending_waker_id: None, } } + + /// Creates a new [`Receiver`] holding an initial value that will never change. + pub fn constant(value: T) -> Self { + let state = Arc::new(RwLock::new(State { + value, + wakers: BTreeMap::new(), + next_waker_id: WakerId::default(), + version: 0, + closed: false, + })); + + Self { state, version: 0 } + } } impl Receiver { From c786c0150f6b315ba4074117241b90bddd8f00fc Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 15:45:24 -0300 Subject: [PATCH 19/54] agent: Add section for agent servers in settings view (#35206) Release Notes: - N/A --------- Co-authored-by: Cole Miller --- crates/agent_servers/src/agent_servers.rs | 2 +- crates/agent_servers/src/gemini.rs | 18 +- crates/agent_ui/src/acp/thread_view.rs | 4 +- crates/agent_ui/src/agent_configuration.rs | 249 +++++++++++++++++++-- crates/agent_ui/src/agent_panel.rs | 2 + crates/agent_ui/src/agent_ui.rs | 1 + 6 files changed, 254 insertions(+), 22 deletions(-) diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index fa59201338..0439934094 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -97,7 +97,7 @@ pub struct AgentServerCommand { } impl AgentServerCommand { - pub(crate) async fn resolve( + pub async fn resolve( path_bin_name: &'static str, extra_args: &[&'static str], fallback_path: Option<&Path>, diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index 9ebcee745c..d09829fe65 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -53,7 +53,7 @@ impl AgentServer for Gemini { return Err(LoadError::NotInstalled { error_message: "Failed to find Gemini CLI binary".into(), install_message: "Install Gemini CLI".into(), - install_command: "npm install -g @google/gemini-cli@preview".into() + install_command: Self::install_command().into(), }.into()); }; @@ -88,7 +88,7 @@ impl AgentServer for Gemini { current_version ).into(), upgrade_message: "Upgrade Gemini CLI to latest".into(), - upgrade_command: "npm install -g @google/gemini-cli@preview".into(), + upgrade_command: Self::upgrade_command().into(), }.into()) } } @@ -101,6 +101,20 @@ impl AgentServer for Gemini { } } +impl Gemini { + pub fn binary_name() -> &'static str { + "gemini" + } + + pub fn install_command() -> &'static str { + "npm install -g @google/gemini-cli@preview" + } + + pub fn upgrade_command() -> &'static str { + "npm install -g @google/gemini-cli@preview" + } +} + #[cfg(test)] pub(crate) mod tests { use super::*; diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index faba18acb1..97af249ae5 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -2811,7 +2811,7 @@ impl AcpThreadView { let cwd = project.first_project_directory(cx); let shell = project.terminal_settings(&cwd, cx).shell.clone(); let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId("install".to_string()), + id: task::TaskId(install_command.clone()), full_label: install_command.clone(), label: install_command.clone(), command: Some(install_command.clone()), @@ -2868,7 +2868,7 @@ impl AcpThreadView { let cwd = project.first_project_directory(cx); let shell = project.terminal_settings(&cwd, cx).shell.clone(); let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId("upgrade".to_string()), + id: task::TaskId(upgrade_command.to_string()), full_label: upgrade_command.clone(), label: upgrade_command.clone(), command: Some(upgrade_command.clone()), diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index f33f0ba032..52fb7eed4b 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -5,6 +5,7 @@ mod tool_picker; use std::{sync::Arc, time::Duration}; +use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini}; use agent_settings::AgentSettings; use assistant_tool::{ToolSource, ToolWorkingSet}; use cloud_llm_client::Plan; @@ -15,7 +16,7 @@ use extension_host::ExtensionStore; use fs::Fs; use gpui::{ Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, - Focusable, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, + Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, }; use language::LanguageRegistry; use language_model::{ @@ -23,10 +24,11 @@ use language_model::{ }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::{ + Project, context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; -use settings::{Settings, update_settings_file}; +use settings::{Settings, SettingsStore, update_settings_file}; use ui::{ Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, @@ -39,7 +41,7 @@ pub(crate) use configure_context_server_modal::ConfigureContextServerModal; pub(crate) use manage_profiles_modal::ManageProfilesModal; use crate::{ - AddContextServer, + AddContextServer, ExternalAgent, NewExternalAgentThread, agent_configuration::add_llm_provider_modal::{AddLlmProviderModal, LlmCompatibleProvider}, }; @@ -47,6 +49,7 @@ pub struct AgentConfiguration { fs: Arc, language_registry: Arc, workspace: WeakEntity, + project: WeakEntity, focus_handle: FocusHandle, configuration_views_by_provider: HashMap, context_server_store: Entity, @@ -56,6 +59,8 @@ pub struct AgentConfiguration { _registry_subscription: Subscription, scroll_handle: ScrollHandle, scrollbar_state: ScrollbarState, + gemini_is_installed: bool, + _check_for_gemini: Task<()>, } impl AgentConfiguration { @@ -65,6 +70,7 @@ impl AgentConfiguration { tools: Entity, language_registry: Arc, workspace: WeakEntity, + project: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { @@ -89,6 +95,11 @@ impl AgentConfiguration { cx.subscribe(&context_server_store, |_, _, _, cx| cx.notify()) .detach(); + cx.observe_global_in::(window, |this, _, cx| { + this.check_for_gemini(cx); + cx.notify(); + }) + .detach(); let scroll_handle = ScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); @@ -97,6 +108,7 @@ impl AgentConfiguration { fs, language_registry, workspace, + project, focus_handle, configuration_views_by_provider: HashMap::default(), context_server_store, @@ -106,8 +118,11 @@ impl AgentConfiguration { _registry_subscription: registry_subscription, scroll_handle, scrollbar_state, + gemini_is_installed: false, + _check_for_gemini: Task::ready(()), }; this.build_provider_configuration_views(window, cx); + this.check_for_gemini(cx); this } @@ -137,6 +152,34 @@ impl AgentConfiguration { self.configuration_views_by_provider .insert(provider.id(), configuration_view); } + + fn check_for_gemini(&mut self, cx: &mut Context) { + let project = self.project.clone(); + let settings = AllAgentServersSettings::get_global(cx).clone(); + self._check_for_gemini = cx.spawn({ + async move |this, cx| { + let Some(project) = project.upgrade() else { + return; + }; + let gemini_is_installed = AgentServerCommand::resolve( + Gemini::binary_name(), + &[], + // TODO expose fallback path from the Gemini/CC types so we don't have to hardcode it again here + None, + settings.gemini, + &project, + cx, + ) + .await + .is_some(); + this.update(cx, |this, cx| { + this.gemini_is_installed = gemini_is_installed; + cx.notify(); + }) + .ok(); + } + }); + } } impl Focusable for AgentConfiguration { @@ -211,7 +254,6 @@ impl AgentConfiguration { .child( h_flex() .id(provider_id_string.clone()) - .cursor_pointer() .px_2() .py_0p5() .w_full() @@ -231,10 +273,7 @@ impl AgentConfiguration { h_flex() .w_full() .gap_1() - .child( - Label::new(provider_name.clone()) - .size(LabelSize::Large), - ) + .child(Label::new(provider_name.clone())) .map(|this| { if is_zed_provider && is_signed_in { this.child( @@ -279,7 +318,7 @@ impl AgentConfiguration { "Start New Thread", ) .icon_position(IconPosition::Start) - .icon(IconName::Plus) + .icon(IconName::Thread) .icon_size(IconSize::Small) .icon_color(Color::Muted) .label_size(LabelSize::Small) @@ -378,7 +417,7 @@ impl AgentConfiguration { ), ) .child( - Label::new("Add at least one provider to use AI-powered features.") + Label::new("Add at least one provider to use AI-powered features with Zed's native agent.") .color(Color::Muted), ), ), @@ -519,6 +558,14 @@ impl AgentConfiguration { } } + fn card_item_bg_color(&self, cx: &mut Context) -> Hsla { + cx.theme().colors().background.opacity(0.25) + } + + fn card_item_border_color(&self, cx: &mut Context) -> Hsla { + cx.theme().colors().border.opacity(0.6) + } + fn render_context_servers_section( &mut self, window: &mut Window, @@ -536,7 +583,12 @@ impl AgentConfiguration { v_flex() .gap_0p5() .child(Headline::new("Model Context Protocol (MCP) Servers")) - .child(Label::new("Connect to context servers through the Model Context Protocol, either using Zed extensions or directly.").color(Color::Muted)), + .child( + Label::new( + "All context servers connected through the Model Context Protocol.", + ) + .color(Color::Muted), + ), ) .children( context_server_ids.into_iter().map(|context_server_id| { @@ -546,7 +598,7 @@ impl AgentConfiguration { .child( h_flex() .justify_between() - .gap_2() + .gap_1p5() .child( h_flex().w_full().child( Button::new("add-context-server", "Add Custom Server") @@ -637,8 +689,6 @@ impl AgentConfiguration { .map_or([].as_slice(), |tools| tools.as_slice()); let tool_count = tools.len(); - let border_color = cx.theme().colors().border.opacity(0.6); - let (source_icon, source_tooltip) = if is_from_extension { ( IconName::ZedMcpExtension, @@ -781,8 +831,8 @@ impl AgentConfiguration { .id(item_id.clone()) .border_1() .rounded_md() - .border_color(border_color) - .bg(cx.theme().colors().background.opacity(0.2)) + .border_color(self.card_item_border_color(cx)) + .bg(self.card_item_bg_color(cx)) .overflow_hidden() .child( h_flex() @@ -790,7 +840,11 @@ impl AgentConfiguration { .justify_between() .when( error.is_some() || are_tools_expanded && tool_count >= 1, - |element| element.border_b_1().border_color(border_color), + |element| { + element + .border_b_1() + .border_color(self.card_item_border_color(cx)) + }, ) .child( h_flex() @@ -972,6 +1026,166 @@ impl AgentConfiguration { )) }) } + + fn render_agent_servers_section(&mut self, cx: &mut Context) -> impl IntoElement { + let settings = AllAgentServersSettings::get_global(cx).clone(); + let user_defined_agents = settings + .custom + .iter() + .map(|(name, settings)| { + self.render_agent_server( + IconName::Ai, + name.clone(), + ExternalAgent::Custom { + name: name.clone(), + settings: settings.clone(), + }, + None, + cx, + ) + .into_any_element() + }) + .collect::>(); + + v_flex() + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + v_flex() + .p(DynamicSpacing::Base16.rems(cx)) + .pr(DynamicSpacing::Base20.rems(cx)) + .gap_2() + .child( + v_flex() + .gap_0p5() + .child(Headline::new("External Agents")) + .child( + Label::new( + "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", + ) + .color(Color::Muted), + ), + ) + .child(self.render_agent_server( + IconName::AiGemini, + "Gemini CLI", + ExternalAgent::Gemini, + (!self.gemini_is_installed).then_some(Gemini::install_command().into()), + cx, + )) + // TODO add CC + .children(user_defined_agents), + ) + } + + fn render_agent_server( + &self, + icon: IconName, + name: impl Into, + agent: ExternalAgent, + install_command: Option, + cx: &mut Context, + ) -> impl IntoElement { + let name = name.into(); + h_flex() + .p_1() + .pl_2() + .gap_1p5() + .justify_between() + .border_1() + .rounded_md() + .border_color(self.card_item_border_color(cx)) + .bg(self.card_item_bg_color(cx)) + .overflow_hidden() + .child( + h_flex() + .gap_1p5() + .child(Icon::new(icon).size(IconSize::Small).color(Color::Muted)) + .child(Label::new(name.clone())), + ) + .map(|this| { + if let Some(install_command) = install_command { + this.child( + Button::new( + SharedString::from(format!("install_external_agent-{name}")), + "Install Agent", + ) + .label_size(LabelSize::Small) + .icon(IconName::Plus) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .tooltip(Tooltip::text(install_command.clone())) + .on_click(cx.listener( + move |this, _, window, cx| { + let Some(project) = this.project.upgrade() else { + return; + }; + let Some(workspace) = this.workspace.upgrade() else { + return; + }; + let cwd = project.read(cx).first_project_directory(cx); + let shell = + project.read(cx).terminal_settings(&cwd, cx).shell.clone(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId(install_command.to_string()), + full_label: install_command.to_string(), + label: install_command.to_string(), + command: Some(install_command.to_string()), + args: Vec::new(), + command_label: install_command.to_string(), + cwd, + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: Default::default(), + reveal_target: Default::default(), + hide: Default::default(), + shell, + show_summary: true, + show_command: true, + show_rerun: false, + }; + let task = workspace.update(cx, |workspace, cx| { + workspace.spawn_in_terminal(spawn_in_terminal, window, cx) + }); + cx.spawn(async move |this, cx| { + task.await; + this.update(cx, |this, cx| { + this.check_for_gemini(cx); + }) + .ok(); + }) + .detach(); + }, + )), + ) + } else { + this.child( + h_flex().gap_1().child( + Button::new( + SharedString::from(format!("start_acp_thread-{name}")), + "Start New Thread", + ) + .label_size(LabelSize::Small) + .icon(IconName::Thread) + .icon_position(IconPosition::Start) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + .on_click(move |_, window, cx| { + window.dispatch_action( + NewExternalAgentThread { + agent: Some(agent.clone()), + } + .boxed_clone(), + cx, + ); + }), + ), + ) + } + }) + } } impl Render for AgentConfiguration { @@ -991,6 +1205,7 @@ impl Render for AgentConfiguration { .size_full() .overflow_y_scroll() .child(self.render_general_settings_section(cx)) + .child(self.render_agent_servers_section(cx)) .child(self.render_context_servers_section(window, cx)) .child(self.render_provider_configuration_section(cx)), ) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index f1a8a744ee..c825785755 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -241,6 +241,7 @@ enum WhichFontSize { None, } +// TODO unify this with ExternalAgent #[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)] pub enum AgentType { #[default] @@ -1474,6 +1475,7 @@ impl AgentPanel { tools, self.language_registry.clone(), self.workspace.clone(), + self.project.downgrade(), window, cx, ) diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 40f6c6a2bb..d159f375b5 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -160,6 +160,7 @@ pub struct NewNativeAgentThreadFromSummary { from_session_id: agent_client_protocol::SessionId, } +// TODO unify this with AgentType #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum ExternalAgent { From 59af2a7d1f513d9f58fc07d4429d118c6a944069 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 25 Aug 2025 20:51:23 +0200 Subject: [PATCH 20/54] acp: Add telemetry (#36894) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- crates/agent2/src/native_agent_server.rs | 4 ++ crates/agent2/src/tests/mod.rs | 1 + crates/agent_servers/src/agent_servers.rs | 1 + crates/agent_servers/src/claude.rs | 4 ++ crates/agent_servers/src/custom.rs | 4 ++ crates/agent_servers/src/gemini.rs | 4 ++ crates/agent_ui/src/acp/thread_view.rs | 79 ++++++++++++++++------- crates/agent_ui/src/agent_panel.rs | 8 +++ crates/agent_ui/src/agent_ui.rs | 9 +++ crates/agent_ui/src/text_thread_editor.rs | 1 + 10 files changed, 93 insertions(+), 22 deletions(-) diff --git a/crates/agent2/src/native_agent_server.rs b/crates/agent2/src/native_agent_server.rs index 33ee44c9a3..9ff98ccd18 100644 --- a/crates/agent2/src/native_agent_server.rs +++ b/crates/agent2/src/native_agent_server.rs @@ -22,6 +22,10 @@ impl NativeAgentServer { } impl AgentServer for NativeAgentServer { + fn telemetry_id(&self) -> &'static str { + "zed" + } + fn name(&self) -> SharedString { "Zed Agent".into() } diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 87ecc1037c..864fbf8b10 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1685,6 +1685,7 @@ async fn test_truncate_second_message(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_title_generation(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 0439934094..7c7e124ca7 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -36,6 +36,7 @@ pub trait AgentServer: Send { fn name(&self) -> SharedString; fn empty_state_headline(&self) -> SharedString; fn empty_state_message(&self) -> SharedString; + fn telemetry_id(&self) -> &'static str; fn connect( &self, diff --git a/crates/agent_servers/src/claude.rs b/crates/agent_servers/src/claude.rs index 6006bf3edb..250e564526 100644 --- a/crates/agent_servers/src/claude.rs +++ b/crates/agent_servers/src/claude.rs @@ -43,6 +43,10 @@ use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError, MentionUri pub struct ClaudeCode; impl AgentServer for ClaudeCode { + fn telemetry_id(&self) -> &'static str { + "claude-code" + } + fn name(&self) -> SharedString { "Claude Code".into() } diff --git a/crates/agent_servers/src/custom.rs b/crates/agent_servers/src/custom.rs index e544c4f21f..72823026d7 100644 --- a/crates/agent_servers/src/custom.rs +++ b/crates/agent_servers/src/custom.rs @@ -22,6 +22,10 @@ impl CustomAgentServer { } impl crate::AgentServer for CustomAgentServer { + fn telemetry_id(&self) -> &'static str { + "custom" + } + fn name(&self) -> SharedString { self.name.clone() } diff --git a/crates/agent_servers/src/gemini.rs b/crates/agent_servers/src/gemini.rs index d09829fe65..5d6a70fa64 100644 --- a/crates/agent_servers/src/gemini.rs +++ b/crates/agent_servers/src/gemini.rs @@ -17,6 +17,10 @@ pub struct Gemini; const ACP_ARG: &str = "--experimental-acp"; impl AgentServer for Gemini { + fn telemetry_id(&self) -> &'static str { + "gemini-cli" + } + fn name(&self) -> SharedString { "Gemini CLI".into() } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 97af249ae5..d80f4eabce 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -892,6 +892,8 @@ impl AcpThreadView { window: &mut Window, cx: &mut Context, ) { + let agent_telemetry_id = self.agent.telemetry_id(); + self.thread_error.take(); self.editing_message.take(); self.thread_feedback.clear(); @@ -936,6 +938,9 @@ impl AcpThreadView { } }); drop(guard); + + telemetry::event!("Agent Message Sent", agent = agent_telemetry_id); + thread.send(contents, cx) })?; send.await @@ -1246,30 +1251,44 @@ impl AcpThreadView { pending_auth_method.replace(method.clone()); let authenticate = connection.authenticate(method, cx); cx.notify(); - self.auth_task = Some(cx.spawn_in(window, { - let project = self.project.clone(); - let agent = self.agent.clone(); - async move |this, cx| { - let result = authenticate.await; + self.auth_task = + Some(cx.spawn_in(window, { + let project = self.project.clone(); + let agent = self.agent.clone(); + async move |this, cx| { + let result = authenticate.await; - this.update_in(cx, |this, window, cx| { - if let Err(err) = result { - this.handle_thread_error(err, cx); - } else { - this.thread_state = Self::initial_state( - agent, - None, - this.workspace.clone(), - project.clone(), - window, - cx, - ) + match &result { + Ok(_) => telemetry::event!( + "Authenticate Agent Succeeded", + agent = agent.telemetry_id() + ), + Err(_) => { + telemetry::event!( + "Authenticate Agent Failed", + agent = agent.telemetry_id(), + ) + } } - this.auth_task.take() - }) - .ok(); - } - })); + + this.update_in(cx, |this, window, cx| { + if let Err(err) = result { + this.handle_thread_error(err, cx); + } else { + this.thread_state = Self::initial_state( + agent, + None, + this.workspace.clone(), + project.clone(), + window, + cx, + ) + } + this.auth_task.take() + }) + .ok(); + } + })); } fn authorize_tool_call( @@ -2776,6 +2795,12 @@ impl AcpThreadView { .on_click({ let method_id = method.id.clone(); cx.listener(move |this, _, window, cx| { + telemetry::event!( + "Authenticate Agent Started", + agent = this.agent.telemetry_id(), + method = method_id + ); + this.authenticate(method_id.clone(), window, cx) }) }) @@ -2804,6 +2829,8 @@ impl AcpThreadView { .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(cx.listener(move |this, _, window, cx| { + telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id()); + let task = this .workspace .update(cx, |workspace, cx| { @@ -2861,6 +2888,8 @@ impl AcpThreadView { .icon_color(Color::Muted) .icon_position(IconPosition::Start) .on_click(cx.listener(move |this, _, window, cx| { + telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id()); + let task = this .workspace .update(cx, |workspace, cx| { @@ -3708,6 +3737,8 @@ impl AcpThreadView { } }) .ok(); + + telemetry::event!("Follow Agent Selected", following = !following); } fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { @@ -5323,6 +5354,10 @@ pub(crate) mod tests { where C: 'static + AgentConnection + Send + Clone, { + fn telemetry_id(&self) -> &'static str { + "test" + } + fn logo(&self) -> ui::IconName { ui::IconName::Ai } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index c825785755..1eafb8dd4d 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1026,6 +1026,8 @@ impl AgentPanel { } fn new_prompt_editor(&mut self, window: &mut Window, cx: &mut Context) { + telemetry::event!("Agent Thread Started", agent = "zed-text"); + let context = self .context_store .update(cx, |context_store, cx| context_store.create(cx)); @@ -1118,6 +1120,8 @@ impl AgentPanel { } }; + telemetry::event!("Agent Thread Started", agent = ext_agent.name()); + let server = ext_agent.server(fs, history); this.update_in(cx, |this, window, cx| { @@ -2327,6 +2331,8 @@ impl AgentPanel { .menu({ let menu = self.assistant_navigation_menu.clone(); move |window, cx| { + telemetry::event!("View Thread History Clicked"); + if let Some(menu) = menu.as_ref() { menu.update(cx, |_, cx| { cx.defer_in(window, |menu, window, cx| { @@ -2505,6 +2511,8 @@ impl AgentPanel { let workspace = self.workspace.clone(); move |window, cx| { + telemetry::event!("New Thread Clicked"); + let active_thread = active_thread.clone(); Some(ContextMenu::build(window, cx, |mut menu, _window, cx| { menu = menu diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index d159f375b5..110c432df3 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -175,6 +175,15 @@ enum ExternalAgent { } impl ExternalAgent { + fn name(&self) -> &'static str { + match self { + Self::NativeAgent => "zed", + Self::Gemini => "gemini-cli", + Self::ClaudeCode => "claude-code", + Self::Custom { .. } => "custom", + } + } + pub fn server( &self, fs: Arc, diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index edb672a872..e9e7eba4b6 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -361,6 +361,7 @@ impl TextThreadEditor { if self.sending_disabled(cx) { return; } + telemetry::event!("Agent Message Sent", agent = "zed-text"); self.send_to_model(window, cx); } From 79e74b880bafaf32778eead2e336cb0f7d68cde7 Mon Sep 17 00:00:00 2001 From: Cretezy Date: Mon, 25 Aug 2025 15:02:19 -0400 Subject: [PATCH 21/54] workspace: Allow disabling of padding on zoomed panels (#31913) Screenshot: | Before | After | | -------|------| | ![image](https://github.com/user-attachments/assets/629e7da2-6070-4abb-b469-3b0824524ca4) | ![image](https://github.com/user-attachments/assets/99e54412-2e0b-4df9-9c40-a89b0411f6d8) | | ![image](https://github.com/user-attachments/assets/e99da846-f39b-47b5-808e-65c22a1af47b) | ![image](https://github.com/user-attachments/assets/ccd4408f-8cce-44ec-a69a-81794125ec99) | Release Notes: - Added `zoomed_padding` to allow disabling of padding around zoomed panels Co-authored-by: Mikayla Maki --- assets/settings/default.json | 6 ++++++ crates/workspace/src/workspace.rs | 4 ++++ crates/workspace/src/workspace_settings.rs | 7 +++++++ 3 files changed, 17 insertions(+) diff --git a/assets/settings/default.json b/assets/settings/default.json index 59450dcc15..f0b9e11e57 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -162,6 +162,12 @@ // 2. Always quit the application // "on_last_window_closed": "quit_app", "on_last_window_closed": "platform_default", + // Whether to show padding for zoomed panels. + // When enabled, zoomed center panels (e.g. code editor) will have padding all around, + // while zoomed bottom/left/right panels will have padding to the top/right/left (respectively). + // + // Default: true + "zoomed_padding": true, // Whether to use the system provided dialogs for Open and Save As. // When set to false, Zed will use the built-in keyboard-first pickers. "use_system_path_prompts": true, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 3654df09be..0b4694601e 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -6633,6 +6633,10 @@ impl Render for Workspace { .inset_0() .shadow_lg(); + if !WorkspaceSettings::get_global(cx).zoomed_padding { + return Some(div); + } + Some(match self.zoomed_position { Some(DockPosition::Left) => div.right_2().border_r_1(), Some(DockPosition::Right) => div.left_2().border_l_1(), diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index 5635347514..3b6bc1ea97 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -29,6 +29,7 @@ pub struct WorkspaceSettings { pub on_last_window_closed: OnLastWindowClosed, pub resize_all_panels_in_dock: Vec, pub close_on_file_delete: bool, + pub zoomed_padding: bool, } #[derive(Copy, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -202,6 +203,12 @@ pub struct WorkspaceSettingsContent { /// /// Default: false pub close_on_file_delete: Option, + /// Whether to show padding for zoomed panels. + /// When enabled, zoomed bottom panels will have some top padding, + /// while zoomed left/right panels will have padding to the right/left (respectively). + /// + /// Default: true + pub zoomed_padding: Option, } #[derive(Deserialize)] From 949398cb93b6f68986cab45dc4ef91e6b54fb2b6 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:07:30 -0300 Subject: [PATCH 22/54] thread view: Fix some design papercuts (#36893) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin Co-authored-by: Ben Brandt Co-authored-by: Matt Miller --- crates/agent2/src/tools/find_path_tool.rs | 27 +- crates/agent_ui/src/acp/thread_history.rs | 2 +- crates/agent_ui/src/acp/thread_view.rs | 296 +++++++++---------- crates/assistant_tools/src/find_path_tool.rs | 8 +- crates/assistant_tools/src/read_file_tool.rs | 2 +- 5 files changed, 152 insertions(+), 183 deletions(-) diff --git a/crates/agent2/src/tools/find_path_tool.rs b/crates/agent2/src/tools/find_path_tool.rs index 5b35c40f85..384bd56e77 100644 --- a/crates/agent2/src/tools/find_path_tool.rs +++ b/crates/agent2/src/tools/find_path_tool.rs @@ -165,16 +165,17 @@ fn search_paths(glob: &str, project: Entity, cx: &mut App) -> Task(if hovered || selected { + .end_slot::(if hovered { Some( IconButton::new("delete", IconName::Trash) .shape(IconButtonShape::Square) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index d80f4eabce..837ce6f90a 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -35,6 +35,7 @@ use prompt_store::{PromptId, PromptStore}; use rope::Point; use settings::{Settings as _, SettingsStore}; use std::cell::Cell; +use std::path::Path; use std::sync::Arc; use std::time::Instant; use std::{collections::BTreeMap, rc::Rc, time::Duration}; @@ -1551,12 +1552,11 @@ impl AcpThreadView { return primary; }; - let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); - let primary = if entry_ix == total_entries - 1 && !is_generating { + let primary = if entry_ix == total_entries - 1 { v_flex() .w_full() .child(primary) - .child(self.render_thread_controls(cx)) + .child(self.render_thread_controls(&thread, cx)) .when_some( self.thread_feedback.comments_editor.clone(), |this, editor| this.child(Self::render_feedback_feedback_editor(editor, cx)), @@ -1698,15 +1698,16 @@ impl AcpThreadView { .into_any_element() } - fn render_tool_call_icon( + fn render_tool_call( &self, - group_name: SharedString, entry_ix: usize, - is_collapsible: bool, - is_open: bool, tool_call: &ToolCall, + window: &Window, cx: &Context, ) -> Div { + let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); + let card_header_id = SharedString::from("inner-tool-call-header"); + let tool_icon = if tool_call.kind == acp::ToolKind::Edit && tool_call.locations.len() == 1 { FileIcons::get_icon(&tool_call.locations[0].path, cx) @@ -1714,7 +1715,7 @@ impl AcpThreadView { .unwrap_or(Icon::new(IconName::ToolPencil)) } else { Icon::new(match tool_call.kind { - acp::ToolKind::Read => IconName::ToolRead, + acp::ToolKind::Read => IconName::ToolSearch, acp::ToolKind::Edit => IconName::ToolPencil, acp::ToolKind::Delete => IconName::ToolDeleteFile, acp::ToolKind::Move => IconName::ArrowRightLeft, @@ -1728,59 +1729,6 @@ impl AcpThreadView { .size(IconSize::Small) .color(Color::Muted); - let base_container = h_flex().flex_shrink_0().size_4().justify_center(); - - if is_collapsible { - base_container - .child( - div() - .group_hover(&group_name, |s| s.invisible().w_0()) - .child(tool_icon), - ) - .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&group_name, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })), - ), - ) - } else { - base_container.child(tool_icon) - } - } - - fn render_tool_call( - &self, - entry_ix: usize, - tool_call: &ToolCall, - window: &Window, - cx: &Context, - ) -> Div { - let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); - let card_header_id = SharedString::from("inner-tool-call-header"); - - let in_progress = match &tool_call.status { - ToolCallStatus::InProgress => true, - _ => false, - }; - let failed_or_canceled = match &tool_call.status { ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed => true, _ => false, @@ -1880,6 +1828,7 @@ impl AcpThreadView { .child( h_flex() .id(header_id) + .group(&card_header_id) .relative() .w_full() .max_w_full() @@ -1897,19 +1846,11 @@ impl AcpThreadView { }) .child( h_flex() - .group(&card_header_id) .relative() .w_full() .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) - .child(self.render_tool_call_icon( - card_header_id, - entry_ix, - is_collapsible, - is_open, - tool_call, - cx, - )) + .child(tool_icon) .child(if tool_call.locations.len() == 1 { let name = tool_call.locations[0] .path @@ -1937,13 +1878,13 @@ impl AcpThreadView { }) .child(name) .tooltip(Tooltip::text("Jump to File")) + .cursor(gpui::CursorStyle::PointingHand) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) .into_any_element() } else { h_flex() - .id("non-card-label-container") .relative() .w_full() .max_w_full() @@ -1954,47 +1895,39 @@ impl AcpThreadView { default_markdown_style(false, true, window, cx), ))) .child(gradient_overlay(gradient_color)) - .on_click(cx.listener({ - let id = tool_call.id.clone(); - move |this: &mut Self, _, _, cx: &mut Context| { - if is_open { - this.expanded_tool_calls.remove(&id); - } else { - this.expanded_tool_calls.insert(id.clone()); - } - cx.notify(); - } - })) .into_any() }), ) - .when(in_progress && use_card_layout && !is_open, |this| { - this.child( - div().absolute().right_2().child( - Icon::new(IconName::ArrowCircle) - .color(Color::Muted) - .size(IconSize::Small) - .with_animation( - "running", - Animation::new(Duration::from_secs(3)).repeat(), - |icon, delta| { - icon.transform(Transformation::rotate(percentage( - delta, - ))) - }, - ), - ), - ) - }) - .when(failed_or_canceled, |this| { - this.child( - div().absolute().right_2().child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ), - ) - }), + .child( + h_flex() + .gap_px() + .when(is_collapsible, |this| { + this.child( + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + let id = tool_call.id.clone(); + move |this: &mut Self, _, _, cx: &mut Context| { + if is_open { + this.expanded_tool_calls.remove(&id); + } else { + this.expanded_tool_calls.insert(id.clone()); + } + cx.notify(); + } + })), + ) + }) + .when(failed_or_canceled, |this| { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + }), + ), ) .children(tool_output_display) } @@ -2064,9 +1997,27 @@ impl AcpThreadView { cx: &Context, ) -> AnyElement { let uri: SharedString = resource_link.uri.clone().into(); + let is_file = resource_link.uri.strip_prefix("file://"); - let label: SharedString = if let Some(path) = resource_link.uri.strip_prefix("file://") { - path.to_string().into() + let label: SharedString = if let Some(abs_path) = is_file { + if let Some(project_path) = self + .project + .read(cx) + .project_path_for_absolute_path(&Path::new(abs_path), cx) + && let Some(worktree) = self + .project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + { + worktree + .read(cx) + .full_path(&project_path.path) + .to_string_lossy() + .to_string() + .into() + } else { + abs_path.to_string().into() + } } else { uri.clone() }; @@ -2083,10 +2034,12 @@ impl AcpThreadView { Button::new(button_id, label) .label_size(LabelSize::Small) .color(Color::Muted) - .icon(IconName::ArrowUpRight) - .icon_size(IconSize::XSmall) - .icon_color(Color::Muted) .truncate(true) + .when(is_file.is_none(), |this| { + this.icon(IconName::ArrowUpRight) + .icon_size(IconSize::XSmall) + .icon_color(Color::Muted) + }) .on_click(cx.listener({ let workspace = self.workspace.clone(); move |_, _, window, cx: &mut Context| { @@ -3727,16 +3680,19 @@ impl AcpThreadView { fn toggle_following(&mut self, window: &mut Window, cx: &mut Context) { let following = self.is_following(cx); + self.should_be_following = !following; - self.workspace - .update(cx, |workspace, cx| { - if following { - workspace.unfollow(CollaboratorId::Agent, window, cx); - } else { - workspace.follow(CollaboratorId::Agent, window, cx); - } - }) - .ok(); + if self.thread().map(|thread| thread.read(cx).status()) == Some(ThreadStatus::Generating) { + self.workspace + .update(cx, |workspace, cx| { + if following { + workspace.unfollow(CollaboratorId::Agent, window, cx); + } else { + workspace.follow(CollaboratorId::Agent, window, cx); + } + }) + .ok(); + } telemetry::event!("Follow Agent Selected", following = !following); } @@ -3744,6 +3700,20 @@ impl AcpThreadView { fn render_follow_toggle(&self, cx: &mut Context) -> impl IntoElement { let following = self.is_following(cx); + let tooltip_label = if following { + if self.agent.name() == "Zed Agent" { + format!("Stop Following the {}", self.agent.name()) + } else { + format!("Stop Following {}", self.agent.name()) + } + } else { + if self.agent.name() == "Zed Agent" { + format!("Follow the {}", self.agent.name()) + } else { + format!("Follow {}", self.agent.name()) + } + }; + IconButton::new("follow-agent", IconName::Crosshair) .icon_size(IconSize::Small) .icon_color(Color::Muted) @@ -3751,10 +3721,10 @@ impl AcpThreadView { .selected_icon_color(Some(Color::Custom(cx.theme().players().agent().cursor))) .tooltip(move |window, cx| { if following { - Tooltip::for_action("Stop Following Agent", &Follow, window, cx) + Tooltip::for_action(tooltip_label.clone(), &Follow, window, cx) } else { Tooltip::with_meta( - "Follow Agent", + tooltip_label.clone(), Some(&Follow), "Track the agent's location as it reads and edits files.", window, @@ -4175,7 +4145,20 @@ impl AcpThreadView { } } - fn render_thread_controls(&self, cx: &Context) -> impl IntoElement { + fn render_thread_controls( + &self, + thread: &Entity, + cx: &Context, + ) -> impl IntoElement { + let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); + if is_generating { + return h_flex().id("thread-controls-container").ml_1().child( + div() + .py_2() + .px(rems_from_px(22.)) + .child(SpinnerLabel::new().size(LabelSize::Small)), + ); + } let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -4899,45 +4882,30 @@ impl Render for AcpThreadView { .items_center() .justify_end() .child(self.render_load_error(e, cx)), - ThreadState::Ready { thread, .. } => { - let thread_clone = thread.clone(); - - v_flex().flex_1().map(|this| { - if has_messages { - this.child( - list( - self.list_state.clone(), - cx.processor(|this, index: usize, window, cx| { - let Some((entry, len)) = this.thread().and_then(|thread| { - let entries = &thread.read(cx).entries(); - Some((entries.get(index)?, entries.len())) - }) else { - return Empty.into_any(); - }; - this.render_entry(index, len, entry, window, cx) - }), - ) - .with_sizing_behavior(gpui::ListSizingBehavior::Auto) - .flex_grow() - .into_any(), + ThreadState::Ready { .. } => v_flex().flex_1().map(|this| { + if has_messages { + this.child( + list( + self.list_state.clone(), + cx.processor(|this, index: usize, window, cx| { + let Some((entry, len)) = this.thread().and_then(|thread| { + let entries = &thread.read(cx).entries(); + Some((entries.get(index)?, entries.len())) + }) else { + return Empty.into_any(); + }; + this.render_entry(index, len, entry, window, cx) + }), ) - .child(self.render_vertical_scrollbar(cx)) - .children( - match thread_clone.read(cx).status() { - ThreadStatus::Idle - | ThreadStatus::WaitingForToolConfirmation => None, - ThreadStatus::Generating => div() - .py_2() - .px(rems_from_px(22.)) - .child(SpinnerLabel::new().size(LabelSize::Small)) - .into(), - }, - ) - } else { - this.child(self.render_recent_history(window, cx)) - } - }) - } + .with_sizing_behavior(gpui::ListSizingBehavior::Auto) + .flex_grow() + .into_any(), + ) + .child(self.render_vertical_scrollbar(cx)) + } else { + this.child(self.render_recent_history(window, cx)) + } + }), }) // The activity bar is intentionally rendered outside of the ThreadState::Ready match // above so that the scrollbar doesn't render behind it. The current setup allows diff --git a/crates/assistant_tools/src/find_path_tool.rs b/crates/assistant_tools/src/find_path_tool.rs index ac2c7a32ab..d1451132ae 100644 --- a/crates/assistant_tools/src/find_path_tool.rs +++ b/crates/assistant_tools/src/find_path_tool.rs @@ -435,8 +435,8 @@ mod test { assert_eq!( matches, &[ - PathBuf::from("root/apple/banana/carrot"), - PathBuf::from("root/apple/bandana/carbonara") + PathBuf::from(path!("root/apple/banana/carrot")), + PathBuf::from(path!("root/apple/bandana/carbonara")) ] ); @@ -447,8 +447,8 @@ mod test { assert_eq!( matches, &[ - PathBuf::from("root/apple/banana/carrot"), - PathBuf::from("root/apple/bandana/carbonara") + PathBuf::from(path!("root/apple/banana/carrot")), + PathBuf::from(path!("root/apple/bandana/carbonara")) ] ); } diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index 766ee3b161..a6e984fca6 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -68,7 +68,7 @@ impl Tool for ReadFileTool { } fn icon(&self) -> IconName { - IconName::ToolRead + IconName::ToolSearch } fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { From 4605b9663059df38176db1c19c9edb1d52ac0ece Mon Sep 17 00:00:00 2001 From: John Tur Date: Mon, 25 Aug 2025 15:45:28 -0400 Subject: [PATCH 23/54] Fix constant thread creation on Windows (#36779) See https://github.com/zed-industries/zed/issues/36057#issuecomment-3215808649 Fixes https://github.com/zed-industries/zed/issues/36057 Release Notes: - N/A --- crates/gpui/src/platform/windows/dispatcher.rs | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/crates/gpui/src/platform/windows/dispatcher.rs b/crates/gpui/src/platform/windows/dispatcher.rs index e5b9c020d5..f554dea128 100644 --- a/crates/gpui/src/platform/windows/dispatcher.rs +++ b/crates/gpui/src/platform/windows/dispatcher.rs @@ -9,10 +9,8 @@ use parking::Parker; use parking_lot::Mutex; use util::ResultExt; use windows::{ - Foundation::TimeSpan, System::Threading::{ - ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemOptions, - WorkItemPriority, + ThreadPool, ThreadPoolTimer, TimerElapsedHandler, WorkItemHandler, WorkItemPriority, }, Win32::{ Foundation::{LPARAM, WPARAM}, @@ -56,12 +54,7 @@ impl WindowsDispatcher { Ok(()) }) }; - ThreadPool::RunWithPriorityAndOptionsAsync( - &handler, - WorkItemPriority::High, - WorkItemOptions::TimeSliced, - ) - .log_err(); + ThreadPool::RunWithPriorityAsync(&handler, WorkItemPriority::High).log_err(); } fn dispatch_on_threadpool_after(&self, runnable: Runnable, duration: Duration) { @@ -72,12 +65,7 @@ impl WindowsDispatcher { Ok(()) }) }; - let delay = TimeSpan { - // A time period expressed in 100-nanosecond units. - // 10,000,000 ticks per second - Duration: (duration.as_nanos() / 100) as i64, - }; - ThreadPoolTimer::CreateTimer(&handler, delay).log_err(); + ThreadPoolTimer::CreateTimer(&handler, duration.into()).log_err(); } } From 0470baca50a557491d0a193ec125500c5bf22770 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 25 Aug 2025 13:50:08 -0600 Subject: [PATCH 24/54] open_ai: Remove `model` field from ResponseStreamEvent (#36902) Closes #36901 Release Notes: - Fixed use of Open WebUI as an LLM provider. --- crates/open_ai/src/open_ai.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/open_ai/src/open_ai.rs b/crates/open_ai/src/open_ai.rs index acf6ec434a..08be82b830 100644 --- a/crates/open_ai/src/open_ai.rs +++ b/crates/open_ai/src/open_ai.rs @@ -446,7 +446,6 @@ pub enum ResponseStreamResult { #[derive(Serialize, Deserialize, Debug)] pub struct ResponseStreamEvent { - pub model: String, pub choices: Vec, pub usage: Option, } From 9cc006ff7473afbfa999c3424b221326ade4ccf1 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 14:07:10 -0600 Subject: [PATCH 25/54] acp: Update error matching (#36898) Release Notes: - N/A --- crates/acp_thread/src/acp_thread.rs | 21 ++++++++++++--------- crates/agent_servers/src/acp.rs | 4 +++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 779f9964da..4ded647a74 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -183,16 +183,15 @@ impl ToolCall { language_registry: Arc, cx: &mut App, ) -> Self { + let title = if let Some((first_line, _)) = tool_call.title.split_once("\n") { + first_line.to_owned() + "…" + } else { + tool_call.title + }; Self { id: tool_call.id, - label: cx.new(|cx| { - Markdown::new( - tool_call.title.into(), - Some(language_registry.clone()), - None, - cx, - ) - }), + label: cx + .new(|cx| Markdown::new(title.into(), Some(language_registry.clone()), None, cx)), kind: tool_call.kind, content: tool_call .content @@ -233,7 +232,11 @@ impl ToolCall { if let Some(title) = title { self.label.update(cx, |label, cx| { - label.replace(title, cx); + if let Some((first_line, _)) = title.split_once("\n") { + label.replace(first_line.to_owned() + "…", cx) + } else { + label.replace(title, cx); + } }); } diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 5a4efe12e5..9080fc1ab0 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -266,7 +266,9 @@ impl AgentConnection for AcpConnection { match serde_json::from_value(data.clone()) { Ok(ErrorDetails { details }) => { - if suppress_abort_err && details.contains("This operation was aborted") + if suppress_abort_err + && (details.contains("This operation was aborted") + || details.contains("The user aborted a request")) { Ok(acp::PromptResponse { stop_reason: acp::StopReason::Cancelled, From 823a0018e5a5f758c63ab68622db23e1dfa45fba Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 25 Aug 2025 16:10:17 -0400 Subject: [PATCH 26/54] acp: Show output for read_file tool in a code block (#36900) Release Notes: - N/A --- crates/agent2/src/tools/read_file_tool.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/agent2/src/tools/read_file_tool.rs b/crates/agent2/src/tools/read_file_tool.rs index fea9732093..e771c26eca 100644 --- a/crates/agent2/src/tools/read_file_tool.rs +++ b/crates/agent2/src/tools/read_file_tool.rs @@ -11,6 +11,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::{path::Path, sync::Arc}; +use util::markdown::MarkdownCodeBlock; use crate::{AgentTool, ToolCallEventStream}; @@ -243,6 +244,19 @@ impl AgentTool for ReadFileTool { }]), ..Default::default() }); + if let Ok(LanguageModelToolResultContent::Text(text)) = &result { + let markdown = MarkdownCodeBlock { + tag: &input.path, + text, + } + .to_string(); + event_stream.update_fields(ToolCallUpdateFields { + content: Some(vec![acp::ToolCallContent::Content { + content: markdown.into(), + }]), + ..Default::default() + }) + } } })?; From 99cee8778cc7c6ee9ddd405f5f00caa713299d68 Mon Sep 17 00:00:00 2001 From: Alvaro Parker <64918109+AlvaroParker@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:18:03 -0400 Subject: [PATCH 27/54] tab_switcher: Add support for diagnostics (#34547) Support to show diagnostics on the tab switcher in the same way they are displayed on the tab bar. This follows the setting `tabs.show_diagnostics`. This will improve user experience when disabling the tab bar and still being able to see the diagnostics when switching tabs Preview: Screenshot From 2025-07-16 11-02-42 Release Notes: - Added diagnostics indicators to the tab switcher --------- Co-authored-by: Kirill Bulatov --- crates/language/src/buffer.rs | 20 +++-- crates/project/src/lsp_store.rs | 23 +++-- crates/tab_switcher/src/tab_switcher.rs | 114 +++++++++++++++++------- 3 files changed, 108 insertions(+), 49 deletions(-) diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index b106110c33..4ddc2b3018 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -1569,11 +1569,21 @@ impl Buffer { self.send_operation(op, true, cx); } - pub fn get_diagnostics(&self, server_id: LanguageServerId) -> Option<&DiagnosticSet> { - let Ok(idx) = self.diagnostics.binary_search_by_key(&server_id, |v| v.0) else { - return None; - }; - Some(&self.diagnostics[idx].1) + pub fn buffer_diagnostics( + &self, + for_server: Option, + ) -> Vec<&DiagnosticEntry> { + match for_server { + Some(server_id) => match self.diagnostics.binary_search_by_key(&server_id, |v| v.0) { + Ok(idx) => self.diagnostics[idx].1.iter().collect(), + Err(_) => Vec::new(), + }, + None => self + .diagnostics + .iter() + .flat_map(|(_, diagnostic_set)| diagnostic_set.iter()) + .collect(), + } } fn request_autoindent(&mut self, cx: &mut Context) { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 853490ddac..deebaedd74 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -7588,19 +7588,16 @@ impl LspStore { let snapshot = buffer_handle.read(cx).snapshot(); let buffer = buffer_handle.read(cx); let reused_diagnostics = buffer - .get_diagnostics(server_id) - .into_iter() - .flat_map(|diag| { - diag.iter() - .filter(|v| merge(buffer, &v.diagnostic, cx)) - .map(|v| { - let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); - let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); - DiagnosticEntry { - range: start..end, - diagnostic: v.diagnostic.clone(), - } - }) + .buffer_diagnostics(Some(server_id)) + .iter() + .filter(|v| merge(buffer, &v.diagnostic, cx)) + .map(|v| { + let start = Unclipped(v.range.start.to_point_utf16(&snapshot)); + let end = Unclipped(v.range.end.to_point_utf16(&snapshot)); + DiagnosticEntry { + range: start..end, + diagnostic: v.diagnostic.clone(), + } }) .collect::>(); diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index 7c70bcd5b5..bf3ce7b568 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -2,12 +2,14 @@ mod tab_switcher_tests; use collections::HashMap; -use editor::items::entry_git_aware_label_color; +use editor::items::{ + entry_diagnostic_aware_icon_decoration_and_color, entry_git_aware_label_color, +}; use fuzzy::StringMatchCandidate; use gpui::{ Action, AnyElement, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, - Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Render, - Styled, Task, WeakEntity, Window, actions, rems, + Focusable, Modifiers, ModifiersChangedEvent, MouseButton, MouseUpEvent, ParentElement, Point, + Render, Styled, Task, WeakEntity, Window, actions, rems, }; use picker::{Picker, PickerDelegate}; use project::Project; @@ -15,11 +17,14 @@ use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::{cmp::Reverse, sync::Arc}; -use ui::{ListItem, ListItemSpacing, Tooltip, prelude::*}; +use ui::{ + DecoratedIcon, IconDecoration, IconDecorationKind, ListItem, ListItemSpacing, Tooltip, + prelude::*, +}; use util::ResultExt; use workspace::{ ModalView, Pane, SaveIntent, Workspace, - item::{ItemHandle, ItemSettings, TabContentParams}, + item::{ItemHandle, ItemSettings, ShowDiagnostics, TabContentParams}, pane::{Event as PaneEvent, render_item_indicator, tab_details}, }; @@ -233,6 +238,77 @@ pub struct TabSwitcherDelegate { restored_items: bool, } +impl TabMatch { + fn icon( + &self, + project: &Entity, + selected: bool, + window: &Window, + cx: &App, + ) -> Option { + let icon = self.item.tab_icon(window, cx)?; + let item_settings = ItemSettings::get_global(cx); + let show_diagnostics = item_settings.show_diagnostics; + let git_status_color = item_settings + .git_status + .then(|| { + let path = self.item.project_path(cx)?; + let project = project.read(cx); + let entry = project.entry_for_path(&path, cx)?; + let git_status = project + .project_path_git_status(&path, cx) + .map(|status| status.summary()) + .unwrap_or_default(); + Some(entry_git_aware_label_color( + git_status, + entry.is_ignored, + selected, + )) + }) + .flatten(); + let colored_icon = icon.color(git_status_color.unwrap_or_default()); + + let most_sever_diagostic_level = if show_diagnostics == ShowDiagnostics::Off { + None + } else { + let buffer_store = project.read(cx).buffer_store().read(cx); + let buffer = self + .item + .project_path(cx) + .and_then(|path| buffer_store.get_by_path(&path)) + .map(|buffer| buffer.read(cx)); + buffer.and_then(|buffer| { + buffer + .buffer_diagnostics(None) + .iter() + .map(|diagnostic_entry| diagnostic_entry.diagnostic.severity) + .min() + }) + }; + + let decorations = + entry_diagnostic_aware_icon_decoration_and_color(most_sever_diagostic_level) + .filter(|(d, _)| { + *d != IconDecorationKind::Triangle + || show_diagnostics != ShowDiagnostics::Errors + }) + .map(|(icon, color)| { + let knockout_item_color = if selected { + cx.theme().colors().element_selected + } else { + cx.theme().colors().element_background + }; + IconDecoration::new(icon, knockout_item_color, cx) + .color(color.color(cx)) + .position(Point { + x: px(-2.), + y: px(-2.), + }) + }); + Some(DecoratedIcon::new(colored_icon, decorations)) + } +} + impl TabSwitcherDelegate { #[allow(clippy::complexity)] fn new( @@ -574,31 +650,7 @@ impl PickerDelegate for TabSwitcherDelegate { }; let label = tab_match.item.tab_content(params, window, cx); - let icon = tab_match.item.tab_icon(window, cx).map(|icon| { - let git_status_color = ItemSettings::get_global(cx) - .git_status - .then(|| { - tab_match - .item - .project_path(cx) - .as_ref() - .and_then(|path| { - let project = self.project.read(cx); - let entry = project.entry_for_path(path, cx)?; - let git_status = project - .project_path_git_status(path, cx) - .map(|status| status.summary()) - .unwrap_or_default(); - Some((entry, git_status)) - }) - .map(|(entry, git_status)| { - entry_git_aware_label_color(git_status, entry.is_ignored, selected) - }) - }) - .flatten(); - - icon.color(git_status_color.unwrap_or_default()) - }); + let icon = tab_match.icon(&self.project, selected, window, cx); let indicator = render_item_indicator(tab_match.item.boxed_clone(), cx); let indicator_color = if let Some(ref indicator) = indicator { @@ -640,7 +692,7 @@ impl PickerDelegate for TabSwitcherDelegate { .inset(true) .toggle_state(selected) .child(h_flex().w_full().child(label)) - .start_slot::(icon) + .start_slot::(icon) .map(|el| { if self.selected_index == ix { el.end_slot::(close_button) From ad25aba990cc26b41903a91cdbff9bfec07ff95c Mon Sep 17 00:00:00 2001 From: Gwen Lg <105106246+gwen-lg@users.noreply.github.com> Date: Mon, 25 Aug 2025 22:23:29 +0200 Subject: [PATCH 28/54] remote_server: Improve error reporting (#33770) Closes #33736 Use `thiserror` to implement error stack and `anyhow` to report is to user. Also move some code from main to remote_server to have better crate isolation. Release Notes: - N/A --------- Co-authored-by: Kirill Bulatov --- Cargo.lock | 1 + crates/remote_server/Cargo.toml | 1 + crates/remote_server/src/main.rs | 90 ++----------- crates/remote_server/src/remote_server.rs | 74 +++++++++++ crates/remote_server/src/unix.rs | 155 ++++++++++++++++++---- 5 files changed, 216 insertions(+), 105 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c835b503ad..42649b137f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13521,6 +13521,7 @@ dependencies = [ "smol", "sysinfo", "telemetry_events", + "thiserror 2.0.12", "toml 0.8.20", "unindent", "util", diff --git a/crates/remote_server/Cargo.toml b/crates/remote_server/Cargo.toml index dcec9f6fe0..5dbb9a2771 100644 --- a/crates/remote_server/Cargo.toml +++ b/crates/remote_server/Cargo.toml @@ -65,6 +65,7 @@ telemetry_events.workspace = true util.workspace = true watch.workspace = true worktree.workspace = true +thiserror.workspace = true [target.'cfg(not(windows))'.dependencies] crashes.workspace = true diff --git a/crates/remote_server/src/main.rs b/crates/remote_server/src/main.rs index 03b0c3eda3..368c7cb639 100644 --- a/crates/remote_server/src/main.rs +++ b/crates/remote_server/src/main.rs @@ -1,6 +1,7 @@ #![cfg_attr(target_os = "windows", allow(unused, dead_code))] -use clap::{Parser, Subcommand}; +use clap::Parser; +use remote_server::Commands; use std::path::PathBuf; #[derive(Parser)] @@ -21,105 +22,34 @@ struct Cli { printenv: bool, } -#[derive(Subcommand)] -enum Commands { - Run { - #[arg(long)] - log_file: PathBuf, - #[arg(long)] - pid_file: PathBuf, - #[arg(long)] - stdin_socket: PathBuf, - #[arg(long)] - stdout_socket: PathBuf, - #[arg(long)] - stderr_socket: PathBuf, - }, - Proxy { - #[arg(long)] - reconnect: bool, - #[arg(long)] - identifier: String, - }, - Version, -} - #[cfg(windows)] fn main() { unimplemented!() } #[cfg(not(windows))] -fn main() { - use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; - use remote::proxy::ProxyLaunchError; - use remote_server::unix::{execute_proxy, execute_run}; - +fn main() -> anyhow::Result<()> { let cli = Cli::parse(); if let Some(socket_path) = &cli.askpass { askpass::main(socket_path); - return; + return Ok(()); } if let Some(socket) = &cli.crash_handler { crashes::crash_server(socket.as_path()); - return; + return Ok(()); } if cli.printenv { util::shell_env::print_env(); - return; + return Ok(()); } - let result = match cli.command { - Some(Commands::Run { - log_file, - pid_file, - stdin_socket, - stdout_socket, - stderr_socket, - }) => execute_run( - log_file, - pid_file, - stdin_socket, - stdout_socket, - stderr_socket, - ), - Some(Commands::Proxy { - identifier, - reconnect, - }) => match execute_proxy(identifier, reconnect) { - Ok(_) => Ok(()), - Err(err) => { - if let Some(err) = err.downcast_ref::() { - std::process::exit(err.to_exit_code()); - } - Err(err) - } - }, - Some(Commands::Version) => { - let release_channel = *RELEASE_CHANNEL; - match release_channel { - ReleaseChannel::Stable | ReleaseChannel::Preview => { - println!("{}", env!("ZED_PKG_VERSION")) - } - ReleaseChannel::Nightly | ReleaseChannel::Dev => { - println!( - "{}", - option_env!("ZED_COMMIT_SHA").unwrap_or(release_channel.dev_name()) - ) - } - }; - std::process::exit(0); - } - None => { - eprintln!("usage: remote "); - std::process::exit(1); - } - }; - if let Err(error) = result { - log::error!("exiting due to error: {}", error); + if let Some(command) = cli.command { + remote_server::run(command) + } else { + eprintln!("usage: remote "); std::process::exit(1); } } diff --git a/crates/remote_server/src/remote_server.rs b/crates/remote_server/src/remote_server.rs index 52003969af..c14a4828ac 100644 --- a/crates/remote_server/src/remote_server.rs +++ b/crates/remote_server/src/remote_server.rs @@ -6,4 +6,78 @@ pub mod unix; #[cfg(test)] mod remote_editing_tests; +use clap::Subcommand; +use std::path::PathBuf; + pub use headless_project::{HeadlessAppState, HeadlessProject}; + +#[derive(Subcommand)] +pub enum Commands { + Run { + #[arg(long)] + log_file: PathBuf, + #[arg(long)] + pid_file: PathBuf, + #[arg(long)] + stdin_socket: PathBuf, + #[arg(long)] + stdout_socket: PathBuf, + #[arg(long)] + stderr_socket: PathBuf, + }, + Proxy { + #[arg(long)] + reconnect: bool, + #[arg(long)] + identifier: String, + }, + Version, +} + +#[cfg(not(windows))] +pub fn run(command: Commands) -> anyhow::Result<()> { + use anyhow::Context; + use release_channel::{RELEASE_CHANNEL, ReleaseChannel}; + use unix::{ExecuteProxyError, execute_proxy, execute_run}; + + match command { + Commands::Run { + log_file, + pid_file, + stdin_socket, + stdout_socket, + stderr_socket, + } => execute_run( + log_file, + pid_file, + stdin_socket, + stdout_socket, + stderr_socket, + ), + Commands::Proxy { + identifier, + reconnect, + } => execute_proxy(identifier, reconnect) + .inspect_err(|err| { + if let ExecuteProxyError::ServerNotRunning(err) = err { + std::process::exit(err.to_exit_code()); + } + }) + .context("running proxy on the remote server"), + Commands::Version => { + let release_channel = *RELEASE_CHANNEL; + match release_channel { + ReleaseChannel::Stable | ReleaseChannel::Preview => { + println!("{}", env!("ZED_PKG_VERSION")) + } + ReleaseChannel::Nightly | ReleaseChannel::Dev => { + println!( + "{}", + option_env!("ZED_COMMIT_SHA").unwrap_or(release_channel.dev_name()) + ) + } + }; + Ok(()) + } + } +} diff --git a/crates/remote_server/src/unix.rs b/crates/remote_server/src/unix.rs index b8a7351552..c6d1566d60 100644 --- a/crates/remote_server/src/unix.rs +++ b/crates/remote_server/src/unix.rs @@ -36,6 +36,7 @@ use smol::Async; use smol::{net::unix::UnixListener, stream::StreamExt as _}; use std::ffi::OsStr; use std::ops::ControlFlow; +use std::process::ExitStatus; use std::str::FromStr; use std::sync::LazyLock; use std::{env, thread}; @@ -46,6 +47,7 @@ use std::{ sync::Arc, }; use telemetry_events::LocationData; +use thiserror::Error; use util::ResultExt; pub static VERSION: LazyLock<&str> = LazyLock::new(|| match *RELEASE_CHANNEL { @@ -526,7 +528,23 @@ pub fn execute_run( Ok(()) } -#[derive(Clone)] +#[derive(Debug, Error)] +pub(crate) enum ServerPathError { + #[error("Failed to create server_dir `{path}`")] + CreateServerDir { + #[source] + source: std::io::Error, + path: PathBuf, + }, + #[error("Failed to create logs_dir `{path}`")] + CreateLogsDir { + #[source] + source: std::io::Error, + path: PathBuf, + }, +} + +#[derive(Clone, Debug)] struct ServerPaths { log_file: PathBuf, pid_file: PathBuf, @@ -536,10 +554,19 @@ struct ServerPaths { } impl ServerPaths { - fn new(identifier: &str) -> Result { + fn new(identifier: &str) -> Result { let server_dir = paths::remote_server_state_dir().join(identifier); - std::fs::create_dir_all(&server_dir)?; - std::fs::create_dir_all(&logs_dir())?; + std::fs::create_dir_all(&server_dir).map_err(|source| { + ServerPathError::CreateServerDir { + source, + path: server_dir.clone(), + } + })?; + let log_dir = logs_dir(); + std::fs::create_dir_all(log_dir).map_err(|source| ServerPathError::CreateLogsDir { + source: source, + path: log_dir.clone(), + })?; let pid_file = server_dir.join("server.pid"); let stdin_socket = server_dir.join("stdin.sock"); @@ -557,7 +584,43 @@ impl ServerPaths { } } -pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { +#[derive(Debug, Error)] +pub(crate) enum ExecuteProxyError { + #[error("Failed to init server paths")] + ServerPath(#[from] ServerPathError), + + #[error(transparent)] + ServerNotRunning(#[from] ProxyLaunchError), + + #[error("Failed to check PidFile '{path}'")] + CheckPidFile { + #[source] + source: CheckPidError, + path: PathBuf, + }, + + #[error("Failed to kill existing server with pid '{pid}'")] + KillRunningServer { + #[source] + source: std::io::Error, + pid: u32, + }, + + #[error("failed to spawn server")] + SpawnServer(#[source] SpawnServerError), + + #[error("stdin_task failed")] + StdinTask(#[source] anyhow::Error), + #[error("stdout_task failed")] + StdoutTask(#[source] anyhow::Error), + #[error("stderr_task failed")] + StderrTask(#[source] anyhow::Error), +} + +pub(crate) fn execute_proxy( + identifier: String, + is_reconnecting: bool, +) -> Result<(), ExecuteProxyError> { init_logging_proxy(); let server_paths = ServerPaths::new(&identifier)?; @@ -574,12 +637,19 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { log::info!("starting proxy process. PID: {}", std::process::id()); - let server_pid = check_pid_file(&server_paths.pid_file)?; + let server_pid = check_pid_file(&server_paths.pid_file).map_err(|source| { + ExecuteProxyError::CheckPidFile { + source, + path: server_paths.pid_file.clone(), + } + })?; let server_running = server_pid.is_some(); if is_reconnecting { if !server_running { log::error!("attempted to reconnect, but no server running"); - anyhow::bail!(ProxyLaunchError::ServerNotRunning); + return Err(ExecuteProxyError::ServerNotRunning( + ProxyLaunchError::ServerNotRunning, + )); } } else { if let Some(pid) = server_pid { @@ -590,7 +660,7 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { kill_running_server(pid, &server_paths)?; } - spawn_server(&server_paths)?; + spawn_server(&server_paths).map_err(ExecuteProxyError::SpawnServer)?; }; let stdin_task = smol::spawn(async move { @@ -630,9 +700,9 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { if let Err(forwarding_result) = smol::block_on(async move { futures::select! { - result = stdin_task.fuse() => result.context("stdin_task failed"), - result = stdout_task.fuse() => result.context("stdout_task failed"), - result = stderr_task.fuse() => result.context("stderr_task failed"), + result = stdin_task.fuse() => result.map_err(ExecuteProxyError::StdinTask), + result = stdout_task.fuse() => result.map_err(ExecuteProxyError::StdoutTask), + result = stderr_task.fuse() => result.map_err(ExecuteProxyError::StderrTask), } }) { log::error!( @@ -645,12 +715,12 @@ pub fn execute_proxy(identifier: String, is_reconnecting: bool) -> Result<()> { Ok(()) } -fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<()> { +fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<(), ExecuteProxyError> { log::info!("killing existing server with PID {}", pid); std::process::Command::new("kill") .arg(pid.to_string()) .output() - .context("failed to kill existing server")?; + .map_err(|source| ExecuteProxyError::KillRunningServer { source, pid })?; for file in [ &paths.pid_file, @@ -664,18 +734,39 @@ fn kill_running_server(pid: u32, paths: &ServerPaths) -> Result<()> { Ok(()) } -fn spawn_server(paths: &ServerPaths) -> Result<()> { +#[derive(Debug, Error)] +pub(crate) enum SpawnServerError { + #[error("failed to remove stdin socket")] + RemoveStdinSocket(#[source] std::io::Error), + + #[error("failed to remove stdout socket")] + RemoveStdoutSocket(#[source] std::io::Error), + + #[error("failed to remove stderr socket")] + RemoveStderrSocket(#[source] std::io::Error), + + #[error("failed to get current_exe")] + CurrentExe(#[source] std::io::Error), + + #[error("failed to launch server process")] + ProcessStatus(#[source] std::io::Error), + + #[error("failed to launch and detach server process: {status}\n{paths}")] + LaunchStatus { status: ExitStatus, paths: String }, +} + +fn spawn_server(paths: &ServerPaths) -> Result<(), SpawnServerError> { if paths.stdin_socket.exists() { - std::fs::remove_file(&paths.stdin_socket)?; + std::fs::remove_file(&paths.stdin_socket).map_err(SpawnServerError::RemoveStdinSocket)?; } if paths.stdout_socket.exists() { - std::fs::remove_file(&paths.stdout_socket)?; + std::fs::remove_file(&paths.stdout_socket).map_err(SpawnServerError::RemoveStdoutSocket)?; } if paths.stderr_socket.exists() { - std::fs::remove_file(&paths.stderr_socket)?; + std::fs::remove_file(&paths.stderr_socket).map_err(SpawnServerError::RemoveStderrSocket)?; } - let binary_name = std::env::current_exe()?; + let binary_name = std::env::current_exe().map_err(SpawnServerError::CurrentExe)?; let mut server_process = std::process::Command::new(binary_name); server_process .arg("run") @@ -692,11 +783,17 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> { let status = server_process .status() - .context("failed to launch server process")?; - anyhow::ensure!( - status.success(), - "failed to launch and detach server process" - ); + .map_err(SpawnServerError::ProcessStatus)?; + + if !status.success() { + return Err(SpawnServerError::LaunchStatus { + status, + paths: format!( + "log file: {:?}, pid file: {:?}", + paths.log_file, paths.pid_file, + ), + }); + } let mut total_time_waited = std::time::Duration::from_secs(0); let wait_duration = std::time::Duration::from_millis(20); @@ -717,7 +814,15 @@ fn spawn_server(paths: &ServerPaths) -> Result<()> { Ok(()) } -fn check_pid_file(path: &Path) -> Result> { +#[derive(Debug, Error)] +#[error("Failed to remove PID file for missing process (pid `{pid}`")] +pub(crate) struct CheckPidError { + #[source] + source: std::io::Error, + pid: u32, +} + +fn check_pid_file(path: &Path) -> Result, CheckPidError> { let Some(pid) = std::fs::read_to_string(&path) .ok() .and_then(|contents| contents.parse::().ok()) @@ -742,7 +847,7 @@ fn check_pid_file(path: &Path) -> Result> { log::debug!( "Found PID file, but process with that PID does not exist. Removing PID file." ); - std::fs::remove_file(&path).context("Failed to remove PID file")?; + std::fs::remove_file(&path).map_err(|source| CheckPidError { source, pid })?; Ok(None) } } From 628a9cd8eab0c41aee0011bfab1462c7bc54adf5 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Mon, 25 Aug 2025 17:34:55 -0300 Subject: [PATCH 29/54] thread view: Add link to docs in the toolbar plus menu (#36883) Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 10 ++++++++++ crates/ui/src/components/context_menu.rs | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 1eafb8dd4d..269aec3365 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -9,6 +9,7 @@ use agent_servers::AgentServerSettings; use agent2::{DbThreadMetadata, HistoryEntry}; use db::kvp::{Dismissable, KEY_VALUE_STORE}; use serde::{Deserialize, Serialize}; +use zed_actions::OpenBrowser; use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; @@ -2689,6 +2690,15 @@ impl AgentPanel { } menu + }) + .when(cx.has_flag::(), |menu| { + menu.separator().link( + "Add Your Own Agent", + OpenBrowser { + url: "https://agentclientprotocol.com/".into(), + } + .boxed_clone(), + ) }); menu })) diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 25575c4f1e..21ab283d88 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -561,7 +561,7 @@ impl ContextMenu { action: Some(action.boxed_clone()), handler: Rc::new(move |_, window, cx| window.dispatch_action(action.boxed_clone(), cx)), icon: Some(IconName::ArrowUpRight), - icon_size: IconSize::Small, + icon_size: IconSize::XSmall, icon_position: IconPosition::End, icon_color: None, disabled: false, From 65de969cc858fe2d309895643754d5a0ad3d7880 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Tue, 26 Aug 2025 00:16:37 +0300 Subject: [PATCH 30/54] Do not show directories in the `InvalidBufferView` (#36906) Follow-up of https://github.com/zed-industries/zed/pull/36764 Release Notes: - N/A --- crates/editor/src/items.rs | 2 +- crates/language_tools/src/lsp_log.rs | 1 - crates/workspace/src/invalid_buffer_view.rs | 10 +- crates/workspace/src/item.rs | 4 +- crates/workspace/src/workspace.rs | 119 +++++++------------- 5 files changed, 50 insertions(+), 86 deletions(-) diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 641e8a97ed..b7110190fd 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1404,7 +1404,7 @@ impl ProjectItem for Editor { } fn for_broken_project_item( - abs_path: PathBuf, + abs_path: &Path, is_local: bool, e: &anyhow::Error, window: &mut Window, diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index 43c0365291..d5206c1f26 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -1743,6 +1743,5 @@ pub enum Event { } impl EventEmitter for LogStore {} -impl EventEmitter for LspLogView {} impl EventEmitter for LspLogView {} impl EventEmitter for LspLogView {} diff --git a/crates/workspace/src/invalid_buffer_view.rs b/crates/workspace/src/invalid_buffer_view.rs index b017373474..b8c0db29d3 100644 --- a/crates/workspace/src/invalid_buffer_view.rs +++ b/crates/workspace/src/invalid_buffer_view.rs @@ -1,4 +1,4 @@ -use std::{path::PathBuf, sync::Arc}; +use std::{path::Path, sync::Arc}; use gpui::{EventEmitter, FocusHandle, Focusable}; use ui::{ @@ -12,7 +12,7 @@ use crate::Item; /// A view to display when a certain buffer fails to open. pub struct InvalidBufferView { /// Which path was attempted to open. - pub abs_path: Arc, + pub abs_path: Arc, /// An error message, happened when opening the buffer. pub error: SharedString, is_local: bool, @@ -21,7 +21,7 @@ pub struct InvalidBufferView { impl InvalidBufferView { pub fn new( - abs_path: PathBuf, + abs_path: &Path, is_local: bool, e: &anyhow::Error, _: &mut Window, @@ -29,7 +29,7 @@ impl InvalidBufferView { ) -> Self { Self { is_local, - abs_path: Arc::new(abs_path), + abs_path: Arc::from(abs_path), error: format!("{e}").into(), focus_handle: cx.focus_handle(), } @@ -43,7 +43,7 @@ impl Item for InvalidBufferView { // Ensure we always render at least the filename. detail += 1; - let path = self.abs_path.as_path(); + let path = self.abs_path.as_ref(); let mut prefix = path; while detail > 0 { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 3485fcca43..db91bd82b9 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -23,7 +23,7 @@ use std::{ any::{Any, TypeId}, cell::RefCell, ops::Range, - path::PathBuf, + path::Path, rc::Rc, sync::Arc, time::Duration, @@ -1168,7 +1168,7 @@ pub trait ProjectItem: Item { /// with the error from that failure as an argument. /// Allows to open an item that can gracefully display and handle errors. fn for_broken_project_item( - _abs_path: PathBuf, + _abs_path: &Path, _is_local: bool, _e: &anyhow::Error, _window: &mut Window, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 0b4694601e..044601df97 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -613,48 +613,59 @@ impl ProjectItemRegistry { self.build_project_item_for_path_fns .push(|project, project_path, window, cx| { let project_path = project_path.clone(); - let abs_path = project.read(cx).absolute_path(&project_path, cx); + let is_file = project + .read(cx) + .entry_for_path(&project_path, cx) + .is_some_and(|entry| entry.is_file()); + let entry_abs_path = project.read(cx).absolute_path(&project_path, cx); let is_local = project.read(cx).is_local(); let project_item = ::try_open(project, &project_path, cx)?; let project = project.clone(); - Some(window.spawn(cx, async move |cx| match project_item.await { - Ok(project_item) => { - let project_item = project_item; - let project_entry_id: Option = - project_item.read_with(cx, project::ProjectItem::entry_id)?; - let build_workspace_item = Box::new( - |pane: &mut Pane, window: &mut Window, cx: &mut Context| { - Box::new(cx.new(|cx| { - T::for_project_item( - project, - Some(pane), - project_item, - window, - cx, - ) - })) as Box - }, - ) as Box<_>; - Ok((project_entry_id, build_workspace_item)) - } - Err(e) => match abs_path { - Some(abs_path) => match cx.update(|window, cx| { - T::for_broken_project_item(abs_path, is_local, &e, window, cx) - })? { - Some(broken_project_item_view) => { - let build_workspace_item = Box::new( + Some(window.spawn(cx, async move |cx| { + match project_item.await.with_context(|| { + format!( + "opening project path {:?}", + entry_abs_path.as_deref().unwrap_or(&project_path.path) + ) + }) { + Ok(project_item) => { + let project_item = project_item; + let project_entry_id: Option = + project_item.read_with(cx, project::ProjectItem::entry_id)?; + let build_workspace_item = Box::new( + |pane: &mut Pane, window: &mut Window, cx: &mut Context| { + Box::new(cx.new(|cx| { + T::for_project_item( + project, + Some(pane), + project_item, + window, + cx, + ) + })) as Box + }, + ) as Box<_>; + Ok((project_entry_id, build_workspace_item)) + } + Err(e) => match entry_abs_path.as_deref().filter(|_| is_file) { + Some(abs_path) => match cx.update(|window, cx| { + T::for_broken_project_item(abs_path, is_local, &e, window, cx) + })? { + Some(broken_project_item_view) => { + let build_workspace_item = Box::new( move |_: &mut Pane, _: &mut Window, cx: &mut Context| { cx.new(|_| broken_project_item_view).boxed_clone() }, ) as Box<_>; - Ok((None, build_workspace_item)) - } + Ok((None, build_workspace_item)) + } + None => Err(e)?, + }, None => Err(e)?, }, - None => Err(e)?, - }, + } })) }); } @@ -4011,52 +4022,6 @@ impl Workspace { maybe_pane_handle } - pub fn split_pane_with_item( - &mut self, - pane_to_split: WeakEntity, - split_direction: SplitDirection, - from: WeakEntity, - item_id_to_move: EntityId, - window: &mut Window, - cx: &mut Context, - ) { - let Some(pane_to_split) = pane_to_split.upgrade() else { - return; - }; - let Some(from) = from.upgrade() else { - return; - }; - - let new_pane = self.add_pane(window, cx); - move_item(&from, &new_pane, item_id_to_move, 0, true, window, cx); - self.center - .split(&pane_to_split, &new_pane, split_direction) - .unwrap(); - cx.notify(); - } - - pub fn split_pane_with_project_entry( - &mut self, - pane_to_split: WeakEntity, - split_direction: SplitDirection, - project_entry: ProjectEntryId, - window: &mut Window, - cx: &mut Context, - ) -> Option>> { - let pane_to_split = pane_to_split.upgrade()?; - let new_pane = self.add_pane(window, cx); - self.center - .split(&pane_to_split, &new_pane, split_direction) - .unwrap(); - - let path = self.project.read(cx).path_for_entry(project_entry, cx)?; - let task = self.open_path(path, Some(new_pane.downgrade()), true, window, cx); - Some(cx.foreground_executor().spawn(async move { - task.await?; - Ok(()) - })) - } - pub fn join_all_panes(&mut self, window: &mut Window, cx: &mut Context) { let active_item = self.active_pane.read(cx).active_item(); for pane in &self.panes { From 1460573dd4397e193764e80f2854ba33d94495ce Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 16:04:44 -0600 Subject: [PATCH 31/54] acp: Rename dev command (#36908) Release Notes: - N/A --- crates/acp_tools/src/acp_tools.rs | 4 ++-- crates/zed/src/zed.rs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/acp_tools/src/acp_tools.rs b/crates/acp_tools/src/acp_tools.rs index ee12b04cde..e20a040e9d 100644 --- a/crates/acp_tools/src/acp_tools.rs +++ b/crates/acp_tools/src/acp_tools.rs @@ -21,12 +21,12 @@ use ui::prelude::*; use util::ResultExt as _; use workspace::{Item, Workspace}; -actions!(acp, [OpenDebugTools]); +actions!(dev, [OpenAcpLogs]); pub fn init(cx: &mut App) { cx.observe_new( |workspace: &mut Workspace, _window, _cx: &mut Context| { - workspace.register_action(|workspace, _: &OpenDebugTools, window, cx| { + workspace.register_action(|workspace, _: &OpenAcpLogs, window, cx| { let acp_tools = Box::new(cx.new(|cx| AcpTools::new(workspace.project().clone(), cx))); workspace.add_item_to_active_pane(acp_tools, None, true, window, cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 1b9657dcc6..638e1dca0e 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4434,7 +4434,6 @@ mod tests { assert_eq!(actions_without_namespace, Vec::<&str>::new()); let expected_namespaces = vec![ - "acp", "activity_indicator", "agent", #[cfg(not(target_os = "macos"))] From f8667a837949597200e2ae8e490d947c6cda75aa Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 16:23:58 -0600 Subject: [PATCH 32/54] Remove unused files (#36909) Closes #ISSUE Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 1 + crates/agent_servers/src/acp/v0.rs | 524 ----------------------------- crates/agent_servers/src/acp/v1.rs | 376 --------------------- 3 files changed, 1 insertion(+), 900 deletions(-) delete mode 100644 crates/agent_servers/src/acp/v0.rs delete mode 100644 crates/agent_servers/src/acp/v1.rs diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 864fbf8b10..093b8ba971 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1347,6 +1347,7 @@ async fn test_cancellation(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_in_progress_send_canceled_by_next_send(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); diff --git a/crates/agent_servers/src/acp/v0.rs b/crates/agent_servers/src/acp/v0.rs deleted file mode 100644 index be96048929..0000000000 --- a/crates/agent_servers/src/acp/v0.rs +++ /dev/null @@ -1,524 +0,0 @@ -// Translates old acp agents into the new schema -use action_log::ActionLog; -use agent_client_protocol as acp; -use agentic_coding_protocol::{self as acp_old, AgentRequest as _}; -use anyhow::{Context as _, Result, anyhow}; -use futures::channel::oneshot; -use gpui::{AppContext as _, AsyncApp, Entity, Task, WeakEntity}; -use project::Project; -use std::{any::Any, cell::RefCell, path::Path, rc::Rc}; -use ui::App; -use util::ResultExt as _; - -use crate::AgentServerCommand; -use acp_thread::{AcpThread, AgentConnection, AuthRequired}; - -#[derive(Clone)] -struct OldAcpClientDelegate { - thread: Rc>>, - cx: AsyncApp, - next_tool_call_id: Rc>, - // sent_buffer_versions: HashMap, HashMap>, -} - -impl OldAcpClientDelegate { - fn new(thread: Rc>>, cx: AsyncApp) -> Self { - Self { - thread, - cx, - next_tool_call_id: Rc::new(RefCell::new(0)), - } - } -} - -impl acp_old::Client for OldAcpClientDelegate { - async fn stream_assistant_message_chunk( - &self, - params: acp_old::StreamAssistantMessageChunkParams, - ) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread - .borrow() - .update(cx, |thread, cx| match params.chunk { - acp_old::AssistantMessageChunk::Text { text } => { - thread.push_assistant_content_block(text.into(), false, cx) - } - acp_old::AssistantMessageChunk::Thought { thought } => { - thread.push_assistant_content_block(thought.into(), true, cx) - } - }) - .log_err(); - })?; - - Ok(()) - } - - async fn request_tool_call_confirmation( - &self, - request: acp_old::RequestToolCallConfirmationParams, - ) -> Result { - let cx = &mut self.cx.clone(); - - let old_acp_id = *self.next_tool_call_id.borrow() + 1; - self.next_tool_call_id.replace(old_acp_id); - - let tool_call = into_new_tool_call( - acp::ToolCallId(old_acp_id.to_string().into()), - request.tool_call, - ); - - let mut options = match request.confirmation { - acp_old::ToolCallConfirmation::Edit { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow Edits".to_string(), - )], - acp_old::ToolCallConfirmation::Execute { root_command, .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", root_command), - )], - acp_old::ToolCallConfirmation::Mcp { - server_name, - tool_name, - .. - } => vec![ - ( - acp_old::ToolCallConfirmationOutcome::AlwaysAllowMcpServer, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", server_name), - ), - ( - acp_old::ToolCallConfirmationOutcome::AlwaysAllowTool, - acp::PermissionOptionKind::AllowAlways, - format!("Always Allow {}", tool_name), - ), - ], - acp_old::ToolCallConfirmation::Fetch { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow".to_string(), - )], - acp_old::ToolCallConfirmation::Other { .. } => vec![( - acp_old::ToolCallConfirmationOutcome::AlwaysAllow, - acp::PermissionOptionKind::AllowAlways, - "Always Allow".to_string(), - )], - }; - - options.extend([ - ( - acp_old::ToolCallConfirmationOutcome::Allow, - acp::PermissionOptionKind::AllowOnce, - "Allow".to_string(), - ), - ( - acp_old::ToolCallConfirmationOutcome::Reject, - acp::PermissionOptionKind::RejectOnce, - "Reject".to_string(), - ), - ]); - - let mut outcomes = Vec::with_capacity(options.len()); - let mut acp_options = Vec::with_capacity(options.len()); - - for (index, (outcome, kind, label)) in options.into_iter().enumerate() { - outcomes.push(outcome); - acp_options.push(acp::PermissionOption { - id: acp::PermissionOptionId(index.to_string().into()), - name: label, - kind, - }) - } - - let response = cx - .update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.request_tool_call_authorization(tool_call.into(), acp_options, cx) - }) - })?? - .context("Failed to update thread")? - .await; - - let outcome = match response { - Ok(option_id) => outcomes[option_id.0.parse::().unwrap_or(0)], - Err(oneshot::Canceled) => acp_old::ToolCallConfirmationOutcome::Cancel, - }; - - Ok(acp_old::RequestToolCallConfirmationResponse { - id: acp_old::ToolCallId(old_acp_id), - outcome, - }) - } - - async fn push_tool_call( - &self, - request: acp_old::PushToolCallParams, - ) -> Result { - let cx = &mut self.cx.clone(); - - let old_acp_id = *self.next_tool_call_id.borrow() + 1; - self.next_tool_call_id.replace(old_acp_id); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.upsert_tool_call( - into_new_tool_call(acp::ToolCallId(old_acp_id.to_string().into()), request), - cx, - ) - }) - })?? - .context("Failed to update thread")?; - - Ok(acp_old::PushToolCallResponse { - id: acp_old::ToolCallId(old_acp_id), - }) - } - - async fn update_tool_call( - &self, - request: acp_old::UpdateToolCallParams, - ) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.update_tool_call( - acp::ToolCallUpdate { - id: acp::ToolCallId(request.tool_call_id.0.to_string().into()), - fields: acp::ToolCallUpdateFields { - status: Some(into_new_tool_call_status(request.status)), - content: Some( - request - .content - .into_iter() - .map(into_new_tool_call_content) - .collect::>(), - ), - ..Default::default() - }, - }, - cx, - ) - }) - })? - .context("Failed to update thread")??; - - Ok(()) - } - - async fn update_plan(&self, request: acp_old::UpdatePlanParams) -> Result<(), acp_old::Error> { - let cx = &mut self.cx.clone(); - - cx.update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.update_plan( - acp::Plan { - entries: request - .entries - .into_iter() - .map(into_new_plan_entry) - .collect(), - }, - cx, - ) - }) - })? - .context("Failed to update thread")?; - - Ok(()) - } - - async fn read_text_file( - &self, - acp_old::ReadTextFileParams { path, line, limit }: acp_old::ReadTextFileParams, - ) -> Result { - let content = self - .cx - .update(|cx| { - self.thread.borrow().update(cx, |thread, cx| { - thread.read_text_file(path, line, limit, false, cx) - }) - })? - .context("Failed to update thread")? - .await?; - Ok(acp_old::ReadTextFileResponse { content }) - } - - async fn write_text_file( - &self, - acp_old::WriteTextFileParams { path, content }: acp_old::WriteTextFileParams, - ) -> Result<(), acp_old::Error> { - self.cx - .update(|cx| { - self.thread - .borrow() - .update(cx, |thread, cx| thread.write_text_file(path, content, cx)) - })? - .context("Failed to update thread")? - .await?; - - Ok(()) - } -} - -fn into_new_tool_call(id: acp::ToolCallId, request: acp_old::PushToolCallParams) -> acp::ToolCall { - acp::ToolCall { - id, - title: request.label, - kind: acp_kind_from_old_icon(request.icon), - status: acp::ToolCallStatus::InProgress, - content: request - .content - .into_iter() - .map(into_new_tool_call_content) - .collect(), - locations: request - .locations - .into_iter() - .map(into_new_tool_call_location) - .collect(), - raw_input: None, - raw_output: None, - } -} - -fn acp_kind_from_old_icon(icon: acp_old::Icon) -> acp::ToolKind { - match icon { - acp_old::Icon::FileSearch => acp::ToolKind::Search, - acp_old::Icon::Folder => acp::ToolKind::Search, - acp_old::Icon::Globe => acp::ToolKind::Search, - acp_old::Icon::Hammer => acp::ToolKind::Other, - acp_old::Icon::LightBulb => acp::ToolKind::Think, - acp_old::Icon::Pencil => acp::ToolKind::Edit, - acp_old::Icon::Regex => acp::ToolKind::Search, - acp_old::Icon::Terminal => acp::ToolKind::Execute, - } -} - -fn into_new_tool_call_status(status: acp_old::ToolCallStatus) -> acp::ToolCallStatus { - match status { - acp_old::ToolCallStatus::Running => acp::ToolCallStatus::InProgress, - acp_old::ToolCallStatus::Finished => acp::ToolCallStatus::Completed, - acp_old::ToolCallStatus::Error => acp::ToolCallStatus::Failed, - } -} - -fn into_new_tool_call_content(content: acp_old::ToolCallContent) -> acp::ToolCallContent { - match content { - acp_old::ToolCallContent::Markdown { markdown } => markdown.into(), - acp_old::ToolCallContent::Diff { diff } => acp::ToolCallContent::Diff { - diff: into_new_diff(diff), - }, - } -} - -fn into_new_diff(diff: acp_old::Diff) -> acp::Diff { - acp::Diff { - path: diff.path, - old_text: diff.old_text, - new_text: diff.new_text, - } -} - -fn into_new_tool_call_location(location: acp_old::ToolCallLocation) -> acp::ToolCallLocation { - acp::ToolCallLocation { - path: location.path, - line: location.line, - } -} - -fn into_new_plan_entry(entry: acp_old::PlanEntry) -> acp::PlanEntry { - acp::PlanEntry { - content: entry.content, - priority: into_new_plan_priority(entry.priority), - status: into_new_plan_status(entry.status), - } -} - -fn into_new_plan_priority(priority: acp_old::PlanEntryPriority) -> acp::PlanEntryPriority { - match priority { - acp_old::PlanEntryPriority::Low => acp::PlanEntryPriority::Low, - acp_old::PlanEntryPriority::Medium => acp::PlanEntryPriority::Medium, - acp_old::PlanEntryPriority::High => acp::PlanEntryPriority::High, - } -} - -fn into_new_plan_status(status: acp_old::PlanEntryStatus) -> acp::PlanEntryStatus { - match status { - acp_old::PlanEntryStatus::Pending => acp::PlanEntryStatus::Pending, - acp_old::PlanEntryStatus::InProgress => acp::PlanEntryStatus::InProgress, - acp_old::PlanEntryStatus::Completed => acp::PlanEntryStatus::Completed, - } -} - -pub struct AcpConnection { - pub name: &'static str, - pub connection: acp_old::AgentConnection, - pub _child_status: Task>, - pub current_thread: Rc>>, -} - -impl AcpConnection { - pub fn stdio( - name: &'static str, - command: AgentServerCommand, - root_dir: &Path, - cx: &mut AsyncApp, - ) -> Task> { - let root_dir = root_dir.to_path_buf(); - - cx.spawn(async move |cx| { - let mut child = util::command::new_smol_command(&command.path) - .args(command.args.iter()) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::inherit()) - .kill_on_drop(true) - .spawn()?; - - let stdin = child.stdin.take().unwrap(); - let stdout = child.stdout.take().unwrap(); - log::trace!("Spawned (pid: {})", child.id()); - - let foreground_executor = cx.foreground_executor().clone(); - - let thread_rc = Rc::new(RefCell::new(WeakEntity::new_invalid())); - - let (connection, io_fut) = acp_old::AgentConnection::connect_to_agent( - OldAcpClientDelegate::new(thread_rc.clone(), cx.clone()), - stdin, - stdout, - move |fut| foreground_executor.spawn(fut).detach(), - ); - - let io_task = cx.background_spawn(async move { - io_fut.await.log_err(); - }); - - let child_status = cx.background_spawn(async move { - let result = match child.status().await { - Err(e) => Err(anyhow!(e)), - Ok(result) if result.success() => Ok(()), - Ok(result) => Err(anyhow!(result)), - }; - drop(io_task); - result - }); - - Ok(Self { - name, - connection, - _child_status: child_status, - current_thread: thread_rc, - }) - }) - } -} - -impl AgentConnection for AcpConnection { - fn new_thread( - self: Rc, - project: Entity, - _cwd: &Path, - cx: &mut App, - ) -> Task>> { - let task = self.connection.request_any( - acp_old::InitializeParams { - protocol_version: acp_old::ProtocolVersion::latest(), - } - .into_any(), - ); - let current_thread = self.current_thread.clone(); - cx.spawn(async move |cx| { - let result = task.await?; - let result = acp_old::InitializeParams::response_from_any(result)?; - - if !result.is_authenticated { - anyhow::bail!(AuthRequired::new()) - } - - cx.update(|cx| { - let thread = cx.new(|cx| { - let session_id = acp::SessionId("acp-old-no-id".into()); - let action_log = cx.new(|_| ActionLog::new(project.clone())); - AcpThread::new(self.name, self.clone(), project, action_log, session_id) - }); - current_thread.replace(thread.downgrade()); - thread - }) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &[] - } - - fn authenticate(&self, _method_id: acp::AuthMethodId, cx: &mut App) -> Task> { - let task = self - .connection - .request_any(acp_old::AuthenticateParams.into_any()); - cx.foreground_executor().spawn(async move { - task.await?; - Ok(()) - }) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let chunks = params - .prompt - .into_iter() - .filter_map(|block| match block { - acp::ContentBlock::Text(text) => { - Some(acp_old::UserMessageChunk::Text { text: text.text }) - } - acp::ContentBlock::ResourceLink(link) => Some(acp_old::UserMessageChunk::Path { - path: link.uri.into(), - }), - _ => None, - }) - .collect(); - - let task = self - .connection - .request_any(acp_old::SendUserMessageParams { chunks }.into_any()); - cx.foreground_executor().spawn(async move { - task.await?; - anyhow::Ok(acp::PromptResponse { - stop_reason: acp::StopReason::EndTurn, - }) - }) - } - - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - acp::PromptCapabilities { - image: false, - audio: false, - embedded_context: false, - } - } - - fn cancel(&self, _session_id: &acp::SessionId, cx: &mut App) { - let task = self - .connection - .request_any(acp_old::CancelSendMessageParams.into_any()); - cx.foreground_executor() - .spawn(async move { - task.await?; - anyhow::Ok(()) - }) - .detach_and_log_err(cx) - } - - fn into_any(self: Rc) -> Rc { - self - } -} diff --git a/crates/agent_servers/src/acp/v1.rs b/crates/agent_servers/src/acp/v1.rs deleted file mode 100644 index 1945ad2483..0000000000 --- a/crates/agent_servers/src/acp/v1.rs +++ /dev/null @@ -1,376 +0,0 @@ -use acp_tools::AcpConnectionRegistry; -use action_log::ActionLog; -use agent_client_protocol::{self as acp, Agent as _, ErrorCode}; -use anyhow::anyhow; -use collections::HashMap; -use futures::AsyncBufReadExt as _; -use futures::channel::oneshot; -use futures::io::BufReader; -use project::Project; -use serde::Deserialize; -use std::path::Path; -use std::rc::Rc; -use std::{any::Any, cell::RefCell}; - -use anyhow::{Context as _, Result}; -use gpui::{App, AppContext as _, AsyncApp, Entity, Task, WeakEntity}; - -use crate::{AgentServerCommand, acp::UnsupportedVersion}; -use acp_thread::{AcpThread, AgentConnection, AuthRequired, LoadError}; - -pub struct AcpConnection { - server_name: &'static str, - connection: Rc, - sessions: Rc>>, - auth_methods: Vec, - prompt_capabilities: acp::PromptCapabilities, - _io_task: Task>, -} - -pub struct AcpSession { - thread: WeakEntity, - suppress_abort_err: bool, -} - -const MINIMUM_SUPPORTED_VERSION: acp::ProtocolVersion = acp::V1; - -impl AcpConnection { - pub async fn stdio( - server_name: &'static str, - command: AgentServerCommand, - root_dir: &Path, - cx: &mut AsyncApp, - ) -> Result { - let mut child = util::command::new_smol_command(&command.path) - .args(command.args.iter().map(|arg| arg.as_str())) - .envs(command.env.iter().flatten()) - .current_dir(root_dir) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .kill_on_drop(true) - .spawn()?; - - let stdout = child.stdout.take().context("Failed to take stdout")?; - let stdin = child.stdin.take().context("Failed to take stdin")?; - let stderr = child.stderr.take().context("Failed to take stderr")?; - log::trace!("Spawned (pid: {})", child.id()); - - let sessions = Rc::new(RefCell::new(HashMap::default())); - - let client = ClientDelegate { - sessions: sessions.clone(), - cx: cx.clone(), - }; - let (connection, io_task) = acp::ClientSideConnection::new(client, stdin, stdout, { - let foreground_executor = cx.foreground_executor().clone(); - move |fut| { - foreground_executor.spawn(fut).detach(); - } - }); - - let io_task = cx.background_spawn(io_task); - - cx.background_spawn(async move { - let mut stderr = BufReader::new(stderr); - let mut line = String::new(); - while let Ok(n) = stderr.read_line(&mut line).await - && n > 0 - { - log::warn!("agent stderr: {}", &line); - line.clear(); - } - }) - .detach(); - - cx.spawn({ - let sessions = sessions.clone(); - async move |cx| { - let status = child.status().await?; - - for session in sessions.borrow().values() { - session - .thread - .update(cx, |thread, cx| { - thread.emit_load_error(LoadError::Exited { status }, cx) - }) - .ok(); - } - - anyhow::Ok(()) - } - }) - .detach(); - - let connection = Rc::new(connection); - - cx.update(|cx| { - AcpConnectionRegistry::default_global(cx).update(cx, |registry, cx| { - registry.set_active_connection(server_name, &connection, cx) - }); - })?; - - let response = connection - .initialize(acp::InitializeRequest { - protocol_version: acp::VERSION, - client_capabilities: acp::ClientCapabilities { - fs: acp::FileSystemCapability { - read_text_file: true, - write_text_file: true, - }, - }, - }) - .await?; - - if response.protocol_version < MINIMUM_SUPPORTED_VERSION { - return Err(UnsupportedVersion.into()); - } - - Ok(Self { - auth_methods: response.auth_methods, - connection, - server_name, - sessions, - prompt_capabilities: response.agent_capabilities.prompt_capabilities, - _io_task: io_task, - }) - } -} - -impl AgentConnection for AcpConnection { - fn new_thread( - self: Rc, - project: Entity, - cwd: &Path, - cx: &mut App, - ) -> Task>> { - let conn = self.connection.clone(); - let sessions = self.sessions.clone(); - let cwd = cwd.to_path_buf(); - cx.spawn(async move |cx| { - let response = conn - .new_session(acp::NewSessionRequest { - mcp_servers: vec![], - cwd, - }) - .await - .map_err(|err| { - if err.code == acp::ErrorCode::AUTH_REQUIRED.code { - let mut error = AuthRequired::new(); - - if err.message != acp::ErrorCode::AUTH_REQUIRED.message { - error = error.with_description(err.message); - } - - anyhow!(error) - } else { - anyhow!(err) - } - })?; - - let session_id = response.session_id; - let action_log = cx.new(|_| ActionLog::new(project.clone()))?; - let thread = cx.new(|_cx| { - AcpThread::new( - self.server_name, - self.clone(), - project, - action_log, - session_id.clone(), - ) - })?; - - let session = AcpSession { - thread: thread.downgrade(), - suppress_abort_err: false, - }; - sessions.borrow_mut().insert(session_id, session); - - Ok(thread) - }) - } - - fn auth_methods(&self) -> &[acp::AuthMethod] { - &self.auth_methods - } - - fn authenticate(&self, method_id: acp::AuthMethodId, cx: &mut App) -> Task> { - let conn = self.connection.clone(); - cx.foreground_executor().spawn(async move { - let result = conn - .authenticate(acp::AuthenticateRequest { - method_id: method_id.clone(), - }) - .await?; - - Ok(result) - }) - } - - fn prompt( - &self, - _id: Option, - params: acp::PromptRequest, - cx: &mut App, - ) -> Task> { - let conn = self.connection.clone(); - let sessions = self.sessions.clone(); - let session_id = params.session_id.clone(); - cx.foreground_executor().spawn(async move { - let result = conn.prompt(params).await; - - let mut suppress_abort_err = false; - - if let Some(session) = sessions.borrow_mut().get_mut(&session_id) { - suppress_abort_err = session.suppress_abort_err; - session.suppress_abort_err = false; - } - - match result { - Ok(response) => Ok(response), - Err(err) => { - if err.code != ErrorCode::INTERNAL_ERROR.code { - anyhow::bail!(err) - } - - let Some(data) = &err.data else { - anyhow::bail!(err) - }; - - // Temporary workaround until the following PR is generally available: - // https://github.com/google-gemini/gemini-cli/pull/6656 - - #[derive(Deserialize)] - #[serde(deny_unknown_fields)] - struct ErrorDetails { - details: Box, - } - - match serde_json::from_value(data.clone()) { - Ok(ErrorDetails { details }) => { - if suppress_abort_err && details.contains("This operation was aborted") - { - Ok(acp::PromptResponse { - stop_reason: acp::StopReason::Cancelled, - }) - } else { - Err(anyhow!(details)) - } - } - Err(_) => Err(anyhow!(err)), - } - } - } - }) - } - - fn prompt_capabilities(&self) -> acp::PromptCapabilities { - self.prompt_capabilities - } - - fn cancel(&self, session_id: &acp::SessionId, cx: &mut App) { - if let Some(session) = self.sessions.borrow_mut().get_mut(session_id) { - session.suppress_abort_err = true; - } - let conn = self.connection.clone(); - let params = acp::CancelNotification { - session_id: session_id.clone(), - }; - cx.foreground_executor() - .spawn(async move { conn.cancel(params).await }) - .detach(); - } - - fn into_any(self: Rc) -> Rc { - self - } -} - -struct ClientDelegate { - sessions: Rc>>, - cx: AsyncApp, -} - -impl acp::Client for ClientDelegate { - async fn request_permission( - &self, - arguments: acp::RequestPermissionRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let rx = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.request_tool_call_authorization(arguments.tool_call, arguments.options, cx) - })?; - - let result = rx?.await; - - let outcome = match result { - Ok(option) => acp::RequestPermissionOutcome::Selected { option_id: option }, - Err(oneshot::Canceled) => acp::RequestPermissionOutcome::Cancelled, - }; - - Ok(acp::RequestPermissionResponse { outcome }) - } - - async fn write_text_file( - &self, - arguments: acp::WriteTextFileRequest, - ) -> Result<(), acp::Error> { - let cx = &mut self.cx.clone(); - let task = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.write_text_file(arguments.path, arguments.content, cx) - })?; - - task.await?; - - Ok(()) - } - - async fn read_text_file( - &self, - arguments: acp::ReadTextFileRequest, - ) -> Result { - let cx = &mut self.cx.clone(); - let task = self - .sessions - .borrow() - .get(&arguments.session_id) - .context("Failed to get session")? - .thread - .update(cx, |thread, cx| { - thread.read_text_file(arguments.path, arguments.line, arguments.limit, false, cx) - })?; - - let content = task.await?; - - Ok(acp::ReadTextFileResponse { content }) - } - - async fn session_notification( - &self, - notification: acp::SessionNotification, - ) -> Result<(), acp::Error> { - let cx = &mut self.cx.clone(); - let sessions = self.sessions.borrow(); - let session = sessions - .get(¬ification.session_id) - .context("Failed to get session")?; - - session.thread.update(cx, |thread, cx| { - thread.handle_session_update(notification.update, cx) - })??; - - Ok(()) - } -} From d43df9e841bce3af1df219690c5c796f8bbff99a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 25 Aug 2025 17:27:52 -0700 Subject: [PATCH 33/54] Fix workspace migration failure (#36911) This fixes a regression on nightly introduced in https://github.com/zed-industries/zed/pull/36714 Release Notes: - N/A --- crates/command_palette/src/persistence.rs | 18 +- crates/db/src/db.rs | 118 +--- crates/db/src/kvp.rs | 30 +- crates/editor/src/persistence.rs | 27 +- crates/image_viewer/src/image_viewer.rs | 19 +- crates/onboarding/src/onboarding.rs | 21 +- crates/onboarding/src/welcome.rs | 21 +- crates/settings_ui/src/keybindings.rs | 15 +- crates/sqlez/src/domain.rs | 14 +- crates/sqlez/src/migrations.rs | 64 +- crates/sqlez/src/thread_safe_connection.rs | 18 +- crates/terminal_view/src/persistence.rs | 18 +- crates/vim/src/state.rs | 18 +- crates/workspace/src/path_list.rs | 14 +- crates/workspace/src/persistence.rs | 643 +++++++++--------- .../src/zed/component_preview/persistence.rs | 19 +- 16 files changed, 582 insertions(+), 495 deletions(-) diff --git a/crates/command_palette/src/persistence.rs b/crates/command_palette/src/persistence.rs index 5be97c36bc..01cf403083 100644 --- a/crates/command_palette/src/persistence.rs +++ b/crates/command_palette/src/persistence.rs @@ -1,7 +1,10 @@ use anyhow::Result; use db::{ - define_connection, query, - sqlez::{bindable::Column, statement::Statement}, + query, + sqlez::{ + bindable::Column, domain::Domain, statement::Statement, + thread_safe_connection::ThreadSafeConnection, + }, sqlez_macros::sql, }; use serde::{Deserialize, Serialize}; @@ -50,8 +53,11 @@ impl Column for SerializedCommandInvocation { } } -define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> = - &[sql!( +pub struct CommandPaletteDB(ThreadSafeConnection); + +impl Domain for CommandPaletteDB { + const NAME: &str = stringify!(CommandPaletteDB); + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS command_invocations( id INTEGER PRIMARY KEY AUTOINCREMENT, command_name TEXT NOT NULL, @@ -59,7 +65,9 @@ define_connection!(pub static ref COMMAND_PALETTE_HISTORY: CommandPaletteDB<()> last_invoked INTEGER DEFAULT (unixepoch()) NOT NULL ) STRICT; )]; -); +} + +db::static_connection!(COMMAND_PALETTE_HISTORY, CommandPaletteDB, []); impl CommandPaletteDB { pub async fn write_command_invocation( diff --git a/crates/db/src/db.rs b/crates/db/src/db.rs index 8b790cbec8..0802bd8bb7 100644 --- a/crates/db/src/db.rs +++ b/crates/db/src/db.rs @@ -110,11 +110,14 @@ pub async fn open_test_db(db_name: &str) -> ThreadSafeConnection { } /// Implements a basic DB wrapper for a given domain +/// +/// Arguments: +/// - static variable name for connection +/// - type of connection wrapper +/// - dependencies, whose migrations should be run prior to this domain's migrations #[macro_export] -macro_rules! define_connection { - (pub static ref $id:ident: $t:ident<()> = $migrations:expr; $($global:ident)?) => { - pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection); - +macro_rules! static_connection { + ($id:ident, $t:ident, [ $($d:ty),* ] $(, $global:ident)?) => { impl ::std::ops::Deref for $t { type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; @@ -123,16 +126,6 @@ macro_rules! define_connection { } } - impl $crate::sqlez::domain::Domain for $t { - fn name() -> &'static str { - stringify!($t) - } - - fn migrations() -> &'static [&'static str] { - $migrations - } - } - impl $t { #[cfg(any(test, feature = "test-support"))] pub async fn open_test_db(name: &'static str) -> Self { @@ -142,7 +135,8 @@ macro_rules! define_connection { #[cfg(any(test, feature = "test-support"))] pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - $t($crate::smol::block_on($crate::open_test_db::<$t>(stringify!($id)))) + #[allow(unused_parens)] + $t($crate::smol::block_on($crate::open_test_db::<($($d,)* $t)>(stringify!($id)))) }); #[cfg(not(any(test, feature = "test-support")))] @@ -153,46 +147,10 @@ macro_rules! define_connection { } else { $crate::RELEASE_CHANNEL.dev_name() }; - $t($crate::smol::block_on($crate::open_db::<$t>(db_dir, scope))) + #[allow(unused_parens)] + $t($crate::smol::block_on($crate::open_db::<($($d,)* $t)>(db_dir, scope))) }); - }; - (pub static ref $id:ident: $t:ident<$($d:ty),+> = $migrations:expr; $($global:ident)?) => { - pub struct $t($crate::sqlez::thread_safe_connection::ThreadSafeConnection); - - impl ::std::ops::Deref for $t { - type Target = $crate::sqlez::thread_safe_connection::ThreadSafeConnection; - - fn deref(&self) -> &Self::Target { - &self.0 - } - } - - impl $crate::sqlez::domain::Domain for $t { - fn name() -> &'static str { - stringify!($t) - } - - fn migrations() -> &'static [&'static str] { - $migrations - } - } - - #[cfg(any(test, feature = "test-support"))] - pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - $t($crate::smol::block_on($crate::open_test_db::<($($d),+, $t)>(stringify!($id)))) - }); - - #[cfg(not(any(test, feature = "test-support")))] - pub static $id: std::sync::LazyLock<$t> = std::sync::LazyLock::new(|| { - let db_dir = $crate::database_dir(); - let scope = if false $(|| stringify!($global) == "global")? { - "global" - } else { - $crate::RELEASE_CHANNEL.dev_name() - }; - $t($crate::smol::block_on($crate::open_db::<($($d),+, $t)>(db_dir, scope))) - }); - }; + } } pub fn write_and_log(cx: &App, db_write: impl FnOnce() -> F + Send + 'static) @@ -219,17 +177,12 @@ mod tests { enum BadDB {} impl Domain for BadDB { - fn name() -> &'static str { - "db_tests" - } - - fn migrations() -> &'static [&'static str] { - &[ - sql!(CREATE TABLE test(value);), - // failure because test already exists - sql!(CREATE TABLE test(value);), - ] - } + const NAME: &str = "db_tests"; + const MIGRATIONS: &[&str] = &[ + sql!(CREATE TABLE test(value);), + // failure because test already exists + sql!(CREATE TABLE test(value);), + ]; } let tempdir = tempfile::Builder::new() @@ -251,25 +204,15 @@ mod tests { enum CorruptedDB {} impl Domain for CorruptedDB { - fn name() -> &'static str { - "db_tests" - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test(value);)] - } + const NAME: &str = "db_tests"; + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)]; } enum GoodDB {} impl Domain for GoodDB { - fn name() -> &'static str { - "db_tests" //Notice same name - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test2(value);)] //But different migration - } + const NAME: &str = "db_tests"; //Notice same name + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; } let tempdir = tempfile::Builder::new() @@ -305,25 +248,16 @@ mod tests { enum CorruptedDB {} impl Domain for CorruptedDB { - fn name() -> &'static str { - "db_tests" - } + const NAME: &str = "db_tests"; - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test(value);)] - } + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test(value);)]; } enum GoodDB {} impl Domain for GoodDB { - fn name() -> &'static str { - "db_tests" //Notice same name - } - - fn migrations() -> &'static [&'static str] { - &[sql!(CREATE TABLE test2(value);)] //But different migration - } + const NAME: &str = "db_tests"; //Notice same name + const MIGRATIONS: &[&str] = &[sql!(CREATE TABLE test2(value);)]; // But different migration } let tempdir = tempfile::Builder::new() diff --git a/crates/db/src/kvp.rs b/crates/db/src/kvp.rs index 256b789c9b..8ea877b35b 100644 --- a/crates/db/src/kvp.rs +++ b/crates/db/src/kvp.rs @@ -2,16 +2,26 @@ use gpui::App; use sqlez_macros::sql; use util::ResultExt as _; -use crate::{define_connection, query, write_and_log}; +use crate::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + write_and_log, +}; -define_connection!(pub static ref KEY_VALUE_STORE: KeyValueStore<()> = - &[sql!( +pub struct KeyValueStore(crate::sqlez::thread_safe_connection::ThreadSafeConnection); + +impl Domain for KeyValueStore { + const NAME: &str = stringify!(KeyValueStore); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS kv_store( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; )]; -); +} + +crate::static_connection!(KEY_VALUE_STORE, KeyValueStore, []); pub trait Dismissable { const KEY: &'static str; @@ -91,15 +101,19 @@ mod tests { } } -define_connection!(pub static ref GLOBAL_KEY_VALUE_STORE: GlobalKeyValueStore<()> = - &[sql!( +pub struct GlobalKeyValueStore(ThreadSafeConnection); + +impl Domain for GlobalKeyValueStore { + const NAME: &str = stringify!(GlobalKeyValueStore); + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE IF NOT EXISTS kv_store( key TEXT PRIMARY KEY, value TEXT NOT NULL ) STRICT; )]; - global -); +} + +crate::static_connection!(GLOBAL_KEY_VALUE_STORE, GlobalKeyValueStore, [], global); impl GlobalKeyValueStore { query! { diff --git a/crates/editor/src/persistence.rs b/crates/editor/src/persistence.rs index 88fde53947..ec7c149b4e 100644 --- a/crates/editor/src/persistence.rs +++ b/crates/editor/src/persistence.rs @@ -1,13 +1,17 @@ use anyhow::Result; -use db::sqlez::bindable::{Bind, Column, StaticColumnCount}; -use db::sqlez::statement::Statement; +use db::{ + query, + sqlez::{ + bindable::{Bind, Column, StaticColumnCount}, + domain::Domain, + statement::Statement, + }, + sqlez_macros::sql, +}; use fs::MTime; use itertools::Itertools as _; use std::path::PathBuf; -use db::sqlez_macros::sql; -use db::{define_connection, query}; - use workspace::{ItemId, WorkspaceDb, WorkspaceId}; #[derive(Clone, Debug, PartialEq, Default)] @@ -83,7 +87,11 @@ impl Column for SerializedEditor { } } -define_connection!( +pub struct EditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection); + +impl Domain for EditorDb { + const NAME: &str = stringify!(EditorDb); + // Current schema shape using pseudo-rust syntax: // editors( // item_id: usize, @@ -113,7 +121,8 @@ define_connection!( // start: usize, // end: usize, // ) - pub static ref DB: EditorDb = &[ + + const MIGRATIONS: &[&str] = &[ sql! ( CREATE TABLE editors( item_id INTEGER NOT NULL, @@ -189,7 +198,9 @@ define_connection!( ) STRICT; ), ]; -); +} + +db::static_connection!(DB, EditorDb, [WorkspaceDb]); // https://www.sqlite.org/limits.html // > <..> the maximum value of a host parameter number is SQLITE_MAX_VARIABLE_NUMBER, diff --git a/crates/image_viewer/src/image_viewer.rs b/crates/image_viewer/src/image_viewer.rs index b96557b391..2dca57424b 100644 --- a/crates/image_viewer/src/image_viewer.rs +++ b/crates/image_viewer/src/image_viewer.rs @@ -401,12 +401,19 @@ pub fn init(cx: &mut App) { mod persistence { use std::path::PathBuf; - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; - define_connection! { - pub static ref IMAGE_VIEWER: ImageViewerDb = - &[sql!( + pub struct ImageViewerDb(ThreadSafeConnection); + + impl Domain for ImageViewerDb { + const NAME: &str = stringify!(ImageViewerDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE image_viewers ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -417,9 +424,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } + db::static_connection!(IMAGE_VIEWER, ImageViewerDb, [WorkspaceDb]); + impl ImageViewerDb { query! { pub async fn save_image_path( diff --git a/crates/onboarding/src/onboarding.rs b/crates/onboarding/src/onboarding.rs index 884374a72f..873dd63201 100644 --- a/crates/onboarding/src/onboarding.rs +++ b/crates/onboarding/src/onboarding.rs @@ -850,13 +850,19 @@ impl workspace::SerializableItem for Onboarding { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::WorkspaceDb; - define_connection! { - pub static ref ONBOARDING_PAGES: OnboardingPagesDb = - &[ - sql!( + pub struct OnboardingPagesDb(ThreadSafeConnection); + + impl Domain for OnboardingPagesDb { + const NAME: &str = stringify!(OnboardingPagesDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE onboarding_pages ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -866,10 +872,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - ), - ]; + )]; } + db::static_connection!(ONBOARDING_PAGES, OnboardingPagesDb, [WorkspaceDb]); + impl OnboardingPagesDb { query! { pub async fn save_onboarding_page( diff --git a/crates/onboarding/src/welcome.rs b/crates/onboarding/src/welcome.rs index 3fe9c32a48..8ff55d812b 100644 --- a/crates/onboarding/src/welcome.rs +++ b/crates/onboarding/src/welcome.rs @@ -414,13 +414,19 @@ impl workspace::SerializableItem for WelcomePage { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, + }; use workspace::WorkspaceDb; - define_connection! { - pub static ref WELCOME_PAGES: WelcomePagesDb = - &[ - sql!( + pub struct WelcomePagesDb(ThreadSafeConnection); + + impl Domain for WelcomePagesDb { + const NAME: &str = stringify!(WelcomePagesDb); + + const MIGRATIONS: &[&str] = (&[sql!( CREATE TABLE welcome_pages ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -430,10 +436,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - ), - ]; + )]); } + db::static_connection!(WELCOME_PAGES, WelcomePagesDb, [WorkspaceDb]); + impl WelcomePagesDb { query! { pub async fn save_welcome_page( diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 9c76725972..288f59c8e0 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -3348,12 +3348,15 @@ impl SerializableItem for KeymapEditor { } mod persistence { - use db::{define_connection, query, sqlez_macros::sql}; + use db::{query, sqlez::domain::Domain, sqlez_macros::sql}; use workspace::WorkspaceDb; - define_connection! { - pub static ref KEYBINDING_EDITORS: KeybindingEditorDb = - &[sql!( + pub struct KeybindingEditorDb(db::sqlez::thread_safe_connection::ThreadSafeConnection); + + impl Domain for KeybindingEditorDb { + const NAME: &str = stringify!(KeybindingEditorDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE keybinding_editors ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -3362,9 +3365,11 @@ mod persistence { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } + db::static_connection!(KEYBINDING_EDITORS, KeybindingEditorDb, [WorkspaceDb]); + impl KeybindingEditorDb { query! { pub async fn save_keybinding_editor( diff --git a/crates/sqlez/src/domain.rs b/crates/sqlez/src/domain.rs index a83f4e18d6..5744a67da2 100644 --- a/crates/sqlez/src/domain.rs +++ b/crates/sqlez/src/domain.rs @@ -1,8 +1,12 @@ use crate::connection::Connection; pub trait Domain: 'static { - fn name() -> &'static str; - fn migrations() -> &'static [&'static str]; + const NAME: &str; + const MIGRATIONS: &[&str]; + + fn should_allow_migration_change(_index: usize, _old: &str, _new: &str) -> bool { + false + } } pub trait Migrator: 'static { @@ -17,7 +21,11 @@ impl Migrator for () { impl Migrator for D { fn migrate(connection: &Connection) -> anyhow::Result<()> { - connection.migrate(Self::name(), Self::migrations()) + connection.migrate( + Self::NAME, + Self::MIGRATIONS, + Self::should_allow_migration_change, + ) } } diff --git a/crates/sqlez/src/migrations.rs b/crates/sqlez/src/migrations.rs index 7c59ffe658..2429ddeb41 100644 --- a/crates/sqlez/src/migrations.rs +++ b/crates/sqlez/src/migrations.rs @@ -34,7 +34,12 @@ impl Connection { /// Note: Unlike everything else in SQLez, migrations are run eagerly, without first /// preparing the SQL statements. This makes it possible to do multi-statement schema /// updates in a single string without running into prepare errors. - pub fn migrate(&self, domain: &'static str, migrations: &[&'static str]) -> Result<()> { + pub fn migrate( + &self, + domain: &'static str, + migrations: &[&'static str], + mut should_allow_migration_change: impl FnMut(usize, &str, &str) -> bool, + ) -> Result<()> { self.with_savepoint("migrating", || { // Setup the migrations table unconditionally self.exec(indoc! {" @@ -65,9 +70,14 @@ impl Connection { &sqlformat::QueryParams::None, Default::default(), ); - if completed_migration == migration { + if completed_migration == migration + || migration.trim().starts_with("-- ALLOW_MIGRATION_CHANGE") + { // Migration already run. Continue continue; + } else if should_allow_migration_change(index, &completed_migration, &migration) + { + continue; } else { anyhow::bail!(formatdoc! {" Migration changed for {domain} at step {index} @@ -108,6 +118,7 @@ mod test { a TEXT, b TEXT )"}], + disallow_migration_change, ) .unwrap(); @@ -136,6 +147,7 @@ mod test { d TEXT )"}, ], + disallow_migration_change, ) .unwrap(); @@ -214,7 +226,11 @@ mod test { // Run the migration verifying that the row got dropped connection - .migrate("test", &["DELETE FROM test_table"]) + .migrate( + "test", + &["DELETE FROM test_table"], + disallow_migration_change, + ) .unwrap(); assert_eq!( connection @@ -232,7 +248,11 @@ mod test { // Run the same migration again and verify that the table was left unchanged connection - .migrate("test", &["DELETE FROM test_table"]) + .migrate( + "test", + &["DELETE FROM test_table"], + disallow_migration_change, + ) .unwrap(); assert_eq!( connection @@ -252,27 +272,28 @@ mod test { .migrate( "test migration", &[ - indoc! {" - CREATE TABLE test ( - col INTEGER - )"}, - indoc! {" - INSERT INTO test (col) VALUES (1)"}, + "CREATE TABLE test (col INTEGER)", + "INSERT INTO test (col) VALUES (1)", ], + disallow_migration_change, ) .unwrap(); + let mut migration_changed = false; + // Create another migration with the same domain but different steps let second_migration_result = connection.migrate( "test migration", &[ - indoc! {" - CREATE TABLE test ( - color INTEGER - )"}, - indoc! {" - INSERT INTO test (color) VALUES (1)"}, + "CREATE TABLE test (color INTEGER )", + "INSERT INTO test (color) VALUES (1)", ], + |_, old, new| { + assert_eq!(old, "CREATE TABLE test (col INTEGER)"); + assert_eq!(new, "CREATE TABLE test (color INTEGER)"); + migration_changed = true; + false + }, ); // Verify new migration returns error when run @@ -284,7 +305,11 @@ mod test { let connection = Connection::open_memory(Some("test_create_alter_drop")); connection - .migrate("first_migration", &["CREATE TABLE table1(a TEXT) STRICT;"]) + .migrate( + "first_migration", + &["CREATE TABLE table1(a TEXT) STRICT;"], + disallow_migration_change, + ) .unwrap(); connection @@ -305,6 +330,7 @@ mod test { ALTER TABLE table2 RENAME TO table1; "}], + disallow_migration_change, ) .unwrap(); @@ -312,4 +338,8 @@ mod test { assert_eq!(res, "test text"); } + + fn disallow_migration_change(_: usize, _: &str, _: &str) -> bool { + false + } } diff --git a/crates/sqlez/src/thread_safe_connection.rs b/crates/sqlez/src/thread_safe_connection.rs index afdc96586e..58d3afe78f 100644 --- a/crates/sqlez/src/thread_safe_connection.rs +++ b/crates/sqlez/src/thread_safe_connection.rs @@ -278,12 +278,8 @@ mod test { enum TestDomain {} impl Domain for TestDomain { - fn name() -> &'static str { - "test" - } - fn migrations() -> &'static [&'static str] { - &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"] - } + const NAME: &str = "test"; + const MIGRATIONS: &[&str] = &["CREATE TABLE test(col1 TEXT, col2 TEXT) STRICT;"]; } for _ in 0..100 { @@ -312,12 +308,9 @@ mod test { fn wild_zed_lost_failure() { enum TestWorkspace {} impl Domain for TestWorkspace { - fn name() -> &'static str { - "workspace" - } + const NAME: &str = "workspace"; - fn migrations() -> &'static [&'static str] { - &[" + const MIGRATIONS: &[&str] = &[" CREATE TABLE workspaces( workspace_id INTEGER PRIMARY KEY, dock_visible INTEGER, -- Boolean @@ -336,8 +329,7 @@ mod test { ON DELETE CASCADE ON UPDATE CASCADE ) STRICT; - "] - } + "]; } let builder = diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index b93b267f58..c7ebd314e4 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -9,7 +9,11 @@ use std::path::{Path, PathBuf}; use ui::{App, Context, Pixels, Window}; use util::ResultExt as _; -use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; +use db::{ + query, + sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use workspace::{ ItemHandle, ItemId, Member, Pane, PaneAxis, PaneGroup, SerializableItem as _, Workspace, WorkspaceDb, WorkspaceId, @@ -375,9 +379,13 @@ impl<'de> Deserialize<'de> for SerializedAxis { } } -define_connection! { - pub static ref TERMINAL_DB: TerminalDb = - &[sql!( +pub struct TerminalDb(ThreadSafeConnection); + +impl Domain for TerminalDb { + const NAME: &str = stringify!(TerminalDb); + + const MIGRATIONS: &[&str] = &[ + sql!( CREATE TABLE terminals ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -414,6 +422,8 @@ define_connection! { ]; } +db::static_connection!(TERMINAL_DB, TerminalDb, [WorkspaceDb]); + impl TerminalDb { query! { pub async fn update_workspace_id( diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index c0176cb12c..fe4bc7433d 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -7,8 +7,10 @@ use crate::{motion::Motion, object::Object}; use anyhow::Result; use collections::HashMap; use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor}; -use db::define_connection; -use db::sqlez_macros::sql; +use db::{ + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use editor::display_map::{is_invisible, replacement}; use editor::{Anchor, ClipboardSelection, Editor, MultiBuffer, ToPoint as EditorToPoint}; use gpui::{ @@ -1668,8 +1670,12 @@ impl MarksView { } } -define_connection! ( - pub static ref DB: VimDb = &[ +pub struct VimDb(ThreadSafeConnection); + +impl Domain for VimDb { + const NAME: &str = stringify!(VimDb); + + const MIGRATIONS: &[&str] = &[ sql! ( CREATE TABLE vim_marks ( workspace_id INTEGER, @@ -1689,7 +1695,9 @@ define_connection! ( ON vim_global_marks_paths(workspace_id, mark_name); ), ]; -); +} + +db::static_connection!(DB, VimDb, [WorkspaceDb]); struct SerializedMark { path: Arc, diff --git a/crates/workspace/src/path_list.rs b/crates/workspace/src/path_list.rs index 4f9ed42312..cf463e6b22 100644 --- a/crates/workspace/src/path_list.rs +++ b/crates/workspace/src/path_list.rs @@ -58,11 +58,7 @@ impl PathList { let mut paths: Vec = if serialized.paths.is_empty() { Vec::new() } else { - serde_json::from_str::>(&serialized.paths) - .unwrap_or(Vec::new()) - .into_iter() - .map(|s| SanitizedPath::from(s).into()) - .collect() + serialized.paths.split('\n').map(PathBuf::from).collect() }; let mut order: Vec = serialized @@ -85,7 +81,13 @@ impl PathList { pub fn serialize(&self) -> SerializedPathList { use std::fmt::Write as _; - let paths = serde_json::to_string(&self.paths).unwrap_or_default(); + let mut paths = String::new(); + for path in self.paths.iter() { + if !paths.is_empty() { + paths.push('\n'); + } + paths.push_str(&path.to_string_lossy()); + } let mut order = String::new(); for ix in self.order.iter() { diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 39a1e08c93..89e1147d8a 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -10,7 +10,11 @@ use std::{ use anyhow::{Context as _, Result, bail}; use collections::HashMap; -use db::{define_connection, query, sqlez::connection::Connection, sqlez_macros::sql}; +use db::{ + query, + sqlez::{connection::Connection, domain::Domain}, + sqlez_macros::sql, +}; use gpui::{Axis, Bounds, Task, WindowBounds, WindowId, point, size}; use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint}; @@ -275,186 +279,189 @@ impl sqlez::bindable::Bind for SerializedPixels { } } -define_connection! { - pub static ref DB: WorkspaceDb<()> = - &[ - sql!( - CREATE TABLE workspaces( - workspace_id INTEGER PRIMARY KEY, - workspace_location BLOB UNIQUE, - dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. - dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. - dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. - left_sidebar_open INTEGER, // Boolean - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - FOREIGN KEY(dock_pane) REFERENCES panes(pane_id) - ) STRICT; +pub struct WorkspaceDb(ThreadSafeConnection); - CREATE TABLE pane_groups( - group_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - parent_group_id INTEGER, // NULL indicates that this is a root node - position INTEGER, // NULL indicates that this is a root node - axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal' - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE, - FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE - ) STRICT; +impl Domain for WorkspaceDb { + const NAME: &str = stringify!(WorkspaceDb); - CREATE TABLE panes( - pane_id INTEGER PRIMARY KEY, - workspace_id INTEGER NOT NULL, - active INTEGER NOT NULL, // Boolean - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE - ) STRICT; + const MIGRATIONS: &[&str] = &[ + sql!( + CREATE TABLE workspaces( + workspace_id INTEGER PRIMARY KEY, + workspace_location BLOB UNIQUE, + dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. + dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. + dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. + left_sidebar_open INTEGER, // Boolean + timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + FOREIGN KEY(dock_pane) REFERENCES panes(pane_id) + ) STRICT; - CREATE TABLE center_panes( - pane_id INTEGER PRIMARY KEY, - parent_group_id INTEGER, // NULL means that this is a root pane - position INTEGER, // NULL means that this is a root pane - FOREIGN KEY(pane_id) REFERENCES panes(pane_id) - ON DELETE CASCADE, - FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE - ) STRICT; + CREATE TABLE pane_groups( + group_id INTEGER PRIMARY KEY, + workspace_id INTEGER NOT NULL, + parent_group_id INTEGER, // NULL indicates that this is a root node + position INTEGER, // NULL indicates that this is a root node + axis TEXT NOT NULL, // Enum: 'Vertical' / 'Horizontal' + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE, + FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE + ) STRICT; - CREATE TABLE items( - item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique - workspace_id INTEGER NOT NULL, - pane_id INTEGER NOT NULL, - kind TEXT NOT NULL, - position INTEGER NOT NULL, - active INTEGER NOT NULL, - FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) - ON DELETE CASCADE - ON UPDATE CASCADE, - FOREIGN KEY(pane_id) REFERENCES panes(pane_id) - ON DELETE CASCADE, - PRIMARY KEY(item_id, workspace_id) - ) STRICT; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN window_state TEXT; - ALTER TABLE workspaces ADD COLUMN window_x REAL; - ALTER TABLE workspaces ADD COLUMN window_y REAL; - ALTER TABLE workspaces ADD COLUMN window_width REAL; - ALTER TABLE workspaces ADD COLUMN window_height REAL; - ALTER TABLE workspaces ADD COLUMN display BLOB; - ), - // Drop foreign key constraint from workspaces.dock_pane to panes table. - sql!( - CREATE TABLE workspaces_2( - workspace_id INTEGER PRIMARY KEY, - workspace_location BLOB UNIQUE, - dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. - dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. - dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. - left_sidebar_open INTEGER, // Boolean - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - window_state TEXT, - window_x REAL, - window_y REAL, - window_width REAL, - window_height REAL, - display BLOB - ) STRICT; - INSERT INTO workspaces_2 SELECT * FROM workspaces; - DROP TABLE workspaces; - ALTER TABLE workspaces_2 RENAME TO workspaces; - ), - // Add panels related information - sql!( - ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT; - ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT; - ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT; - ), - // Add panel zoom persistence - sql!( - ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool - ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool - ), - // Add pane group flex data - sql!( - ALTER TABLE pane_groups ADD COLUMN flexes TEXT; - ), - // Add fullscreen field to workspace - // Deprecated, `WindowBounds` holds the fullscreen state now. - // Preserving so users can downgrade Zed. - sql!( - ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool - ), - // Add preview field to items - sql!( - ALTER TABLE items ADD COLUMN preview INTEGER; //bool - ), - // Add centered_layout field to workspace - sql!( - ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool - ), - sql!( - CREATE TABLE remote_projects ( - remote_project_id INTEGER NOT NULL UNIQUE, - path TEXT, - dev_server_name TEXT - ); - ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; - ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; - ), - sql!( - DROP TABLE remote_projects; - CREATE TABLE dev_server_projects ( - id INTEGER NOT NULL UNIQUE, - path TEXT, - dev_server_name TEXT - ); - ALTER TABLE workspaces DROP COLUMN remote_project_id; - ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL; - ), - sql!( - ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL; - ), - sql!( - ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0; - ), - sql!( - CREATE TABLE ssh_projects ( - id INTEGER PRIMARY KEY, - host TEXT NOT NULL, - port INTEGER, - path TEXT NOT NULL, - user TEXT - ); - ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; - ), - sql!( - ALTER TABLE ssh_projects RENAME COLUMN path TO paths; - ), - sql!( - CREATE TABLE toolchains ( - workspace_id INTEGER, - worktree_id INTEGER, - language_name TEXT NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - PRIMARY KEY (workspace_id, worktree_id, language_name) - ); - ), - sql!( - ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; - ), - sql!( + CREATE TABLE panes( + pane_id INTEGER PRIMARY KEY, + workspace_id INTEGER NOT NULL, + active INTEGER NOT NULL, // Boolean + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE + ) STRICT; + + CREATE TABLE center_panes( + pane_id INTEGER PRIMARY KEY, + parent_group_id INTEGER, // NULL means that this is a root pane + position INTEGER, // NULL means that this is a root pane + FOREIGN KEY(pane_id) REFERENCES panes(pane_id) + ON DELETE CASCADE, + FOREIGN KEY(parent_group_id) REFERENCES pane_groups(group_id) ON DELETE CASCADE + ) STRICT; + + CREATE TABLE items( + item_id INTEGER NOT NULL, // This is the item's view id, so this is not unique + workspace_id INTEGER NOT NULL, + pane_id INTEGER NOT NULL, + kind TEXT NOT NULL, + position INTEGER NOT NULL, + active INTEGER NOT NULL, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ON UPDATE CASCADE, + FOREIGN KEY(pane_id) REFERENCES panes(pane_id) + ON DELETE CASCADE, + PRIMARY KEY(item_id, workspace_id) + ) STRICT; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN window_state TEXT; + ALTER TABLE workspaces ADD COLUMN window_x REAL; + ALTER TABLE workspaces ADD COLUMN window_y REAL; + ALTER TABLE workspaces ADD COLUMN window_width REAL; + ALTER TABLE workspaces ADD COLUMN window_height REAL; + ALTER TABLE workspaces ADD COLUMN display BLOB; + ), + // Drop foreign key constraint from workspaces.dock_pane to panes table. + sql!( + CREATE TABLE workspaces_2( + workspace_id INTEGER PRIMARY KEY, + workspace_location BLOB UNIQUE, + dock_visible INTEGER, // Deprecated. Preserving so users can downgrade Zed. + dock_anchor TEXT, // Deprecated. Preserving so users can downgrade Zed. + dock_pane INTEGER, // Deprecated. Preserving so users can downgrade Zed. + left_sidebar_open INTEGER, // Boolean + timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + window_state TEXT, + window_x REAL, + window_y REAL, + window_width REAL, + window_height REAL, + display BLOB + ) STRICT; + INSERT INTO workspaces_2 SELECT * FROM workspaces; + DROP TABLE workspaces; + ALTER TABLE workspaces_2 RENAME TO workspaces; + ), + // Add panels related information + sql!( + ALTER TABLE workspaces ADD COLUMN left_dock_visible INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN left_dock_active_panel TEXT; + ALTER TABLE workspaces ADD COLUMN right_dock_visible INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN right_dock_active_panel TEXT; + ALTER TABLE workspaces ADD COLUMN bottom_dock_visible INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN bottom_dock_active_panel TEXT; + ), + // Add panel zoom persistence + sql!( + ALTER TABLE workspaces ADD COLUMN left_dock_zoom INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN right_dock_zoom INTEGER; //bool + ALTER TABLE workspaces ADD COLUMN bottom_dock_zoom INTEGER; //bool + ), + // Add pane group flex data + sql!( + ALTER TABLE pane_groups ADD COLUMN flexes TEXT; + ), + // Add fullscreen field to workspace + // Deprecated, `WindowBounds` holds the fullscreen state now. + // Preserving so users can downgrade Zed. + sql!( + ALTER TABLE workspaces ADD COLUMN fullscreen INTEGER; //bool + ), + // Add preview field to items + sql!( + ALTER TABLE items ADD COLUMN preview INTEGER; //bool + ), + // Add centered_layout field to workspace + sql!( + ALTER TABLE workspaces ADD COLUMN centered_layout INTEGER; //bool + ), + sql!( + CREATE TABLE remote_projects ( + remote_project_id INTEGER NOT NULL UNIQUE, + path TEXT, + dev_server_name TEXT + ); + ALTER TABLE workspaces ADD COLUMN remote_project_id INTEGER; + ALTER TABLE workspaces RENAME COLUMN workspace_location TO local_paths; + ), + sql!( + DROP TABLE remote_projects; + CREATE TABLE dev_server_projects ( + id INTEGER NOT NULL UNIQUE, + path TEXT, + dev_server_name TEXT + ); + ALTER TABLE workspaces DROP COLUMN remote_project_id; + ALTER TABLE workspaces ADD COLUMN dev_server_project_id INTEGER; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN local_paths_order BLOB; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN session_id TEXT DEFAULT NULL; + ), + sql!( + ALTER TABLE workspaces ADD COLUMN window_id INTEGER DEFAULT NULL; + ), + sql!( + ALTER TABLE panes ADD COLUMN pinned_count INTEGER DEFAULT 0; + ), + sql!( + CREATE TABLE ssh_projects ( + id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER, + path TEXT NOT NULL, + user TEXT + ); + ALTER TABLE workspaces ADD COLUMN ssh_project_id INTEGER REFERENCES ssh_projects(id) ON DELETE CASCADE; + ), + sql!( + ALTER TABLE ssh_projects RENAME COLUMN path TO paths; + ), + sql!( + CREATE TABLE toolchains ( + workspace_id INTEGER, + worktree_id INTEGER, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_id, language_name) + ); + ), + sql!( + ALTER TABLE toolchains ADD COLUMN raw_json TEXT DEFAULT "{}"; + ), + sql!( CREATE TABLE breakpoints ( workspace_id INTEGER NOT NULL, path TEXT NOT NULL, @@ -466,141 +473,165 @@ define_connection! { ON UPDATE CASCADE ); ), - sql!( - ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT; - CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array); - ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT; - ), - sql!( - ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL - ), - sql!( - ALTER TABLE breakpoints DROP COLUMN kind - ), - sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL), - sql!( - ALTER TABLE breakpoints ADD COLUMN condition TEXT; - ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT; - ), - sql!(CREATE TABLE toolchains2 ( - workspace_id INTEGER, - worktree_id INTEGER, - language_name TEXT NOT NULL, - name TEXT NOT NULL, - path TEXT NOT NULL, - raw_json TEXT NOT NULL, - relative_worktree_path TEXT NOT NULL, - PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT; - INSERT INTO toolchains2 - SELECT * FROM toolchains; - DROP TABLE toolchains; - ALTER TABLE toolchains2 RENAME TO toolchains; - ), - sql!( - CREATE TABLE ssh_connections ( - id INTEGER PRIMARY KEY, - host TEXT NOT NULL, - port INTEGER, - user TEXT - ); + sql!( + ALTER TABLE workspaces ADD COLUMN local_paths_array TEXT; + CREATE UNIQUE INDEX local_paths_array_uq ON workspaces(local_paths_array); + ALTER TABLE workspaces ADD COLUMN local_paths_order_array TEXT; + ), + sql!( + ALTER TABLE breakpoints ADD COLUMN state INTEGER DEFAULT(0) NOT NULL + ), + sql!( + ALTER TABLE breakpoints DROP COLUMN kind + ), + sql!(ALTER TABLE toolchains ADD COLUMN relative_worktree_path TEXT DEFAULT "" NOT NULL), + sql!( + ALTER TABLE breakpoints ADD COLUMN condition TEXT; + ALTER TABLE breakpoints ADD COLUMN hit_condition TEXT; + ), + sql!(CREATE TABLE toolchains2 ( + workspace_id INTEGER, + worktree_id INTEGER, + language_name TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + raw_json TEXT NOT NULL, + relative_worktree_path TEXT NOT NULL, + PRIMARY KEY (workspace_id, worktree_id, language_name, relative_worktree_path)) STRICT; + INSERT INTO toolchains2 + SELECT * FROM toolchains; + DROP TABLE toolchains; + ALTER TABLE toolchains2 RENAME TO toolchains; + ), + sql!( + CREATE TABLE ssh_connections ( + id INTEGER PRIMARY KEY, + host TEXT NOT NULL, + port INTEGER, + user TEXT + ); - INSERT INTO ssh_connections (host, port, user) - SELECT DISTINCT host, port, user - FROM ssh_projects; + INSERT INTO ssh_connections (host, port, user) + SELECT DISTINCT host, port, user + FROM ssh_projects; - CREATE TABLE workspaces_2( - workspace_id INTEGER PRIMARY KEY, - paths TEXT, - paths_order TEXT, - ssh_connection_id INTEGER REFERENCES ssh_connections(id), - timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, - window_state TEXT, - window_x REAL, - window_y REAL, - window_width REAL, - window_height REAL, - display BLOB, - left_dock_visible INTEGER, - left_dock_active_panel TEXT, - right_dock_visible INTEGER, - right_dock_active_panel TEXT, - bottom_dock_visible INTEGER, - bottom_dock_active_panel TEXT, - left_dock_zoom INTEGER, - right_dock_zoom INTEGER, - bottom_dock_zoom INTEGER, - fullscreen INTEGER, - centered_layout INTEGER, - session_id TEXT, - window_id INTEGER - ) STRICT; + CREATE TABLE workspaces_2( + workspace_id INTEGER PRIMARY KEY, + paths TEXT, + paths_order TEXT, + ssh_connection_id INTEGER REFERENCES ssh_connections(id), + timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL, + window_state TEXT, + window_x REAL, + window_y REAL, + window_width REAL, + window_height REAL, + display BLOB, + left_dock_visible INTEGER, + left_dock_active_panel TEXT, + right_dock_visible INTEGER, + right_dock_active_panel TEXT, + bottom_dock_visible INTEGER, + bottom_dock_active_panel TEXT, + left_dock_zoom INTEGER, + right_dock_zoom INTEGER, + bottom_dock_zoom INTEGER, + fullscreen INTEGER, + centered_layout INTEGER, + session_id TEXT, + window_id INTEGER + ) STRICT; - INSERT - INTO workspaces_2 - SELECT - workspaces.workspace_id, - CASE - WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths + INSERT + INTO workspaces_2 + SELECT + workspaces.workspace_id, + CASE + WHEN ssh_projects.id IS NOT NULL THEN ssh_projects.paths + ELSE + CASE + WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN + NULL + ELSE + replace(workspaces.local_paths_array, ',', "\n") + END + END as paths, + + CASE + WHEN ssh_projects.id IS NOT NULL THEN "" + ELSE workspaces.local_paths_order_array + END as paths_order, + + CASE + WHEN ssh_projects.id IS NOT NULL THEN ( + SELECT ssh_connections.id + FROM ssh_connections + WHERE + ssh_connections.host IS ssh_projects.host AND + ssh_connections.port IS ssh_projects.port AND + ssh_connections.user IS ssh_projects.user + ) + ELSE NULL + END as ssh_connection_id, + + workspaces.timestamp, + workspaces.window_state, + workspaces.window_x, + workspaces.window_y, + workspaces.window_width, + workspaces.window_height, + workspaces.display, + workspaces.left_dock_visible, + workspaces.left_dock_active_panel, + workspaces.right_dock_visible, + workspaces.right_dock_active_panel, + workspaces.bottom_dock_visible, + workspaces.bottom_dock_active_panel, + workspaces.left_dock_zoom, + workspaces.right_dock_zoom, + workspaces.bottom_dock_zoom, + workspaces.fullscreen, + workspaces.centered_layout, + workspaces.session_id, + workspaces.window_id + FROM + workspaces LEFT JOIN + ssh_projects ON + workspaces.ssh_project_id = ssh_projects.id; + + DROP TABLE ssh_projects; + DROP TABLE workspaces; + ALTER TABLE workspaces_2 RENAME TO workspaces; + + CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths); + ), + // Fix any data from when workspaces.paths were briefly encoded as JSON arrays + sql!( + UPDATE workspaces + SET paths = CASE + WHEN substr(paths, 1, 2) = '[' || '"' AND substr(paths, -2, 2) = '"' || ']' THEN + replace( + substr(paths, 3, length(paths) - 4), + '"' || ',' || '"', + CHAR(10) + ) ELSE - CASE - WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN - NULL - ELSE - json('[' || '"' || replace(workspaces.local_paths_array, ',', '"' || "," || '"') || '"' || ']') - END - END as paths, - - CASE - WHEN ssh_projects.id IS NOT NULL THEN "" - ELSE workspaces.local_paths_order_array - END as paths_order, - - CASE - WHEN ssh_projects.id IS NOT NULL THEN ( - SELECT ssh_connections.id - FROM ssh_connections - WHERE - ssh_connections.host IS ssh_projects.host AND - ssh_connections.port IS ssh_projects.port AND - ssh_connections.user IS ssh_projects.user - ) - ELSE NULL - END as ssh_connection_id, - - workspaces.timestamp, - workspaces.window_state, - workspaces.window_x, - workspaces.window_y, - workspaces.window_width, - workspaces.window_height, - workspaces.display, - workspaces.left_dock_visible, - workspaces.left_dock_active_panel, - workspaces.right_dock_visible, - workspaces.right_dock_active_panel, - workspaces.bottom_dock_visible, - workspaces.bottom_dock_active_panel, - workspaces.left_dock_zoom, - workspaces.right_dock_zoom, - workspaces.bottom_dock_zoom, - workspaces.fullscreen, - workspaces.centered_layout, - workspaces.session_id, - workspaces.window_id - FROM - workspaces LEFT JOIN - ssh_projects ON - workspaces.ssh_project_id = ssh_projects.id; - - DROP TABLE ssh_projects; - DROP TABLE workspaces; - ALTER TABLE workspaces_2 RENAME TO workspaces; - - CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(ssh_connection_id, paths); - ), + replace(paths, ',', CHAR(10)) + END + WHERE paths IS NOT NULL + ), ]; + + // Allow recovering from bad migration that was initially shipped to nightly + // when introducing the ssh_connections table. + fn should_allow_migration_change(_index: usize, old: &str, new: &str) -> bool { + old.starts_with("CREATE TABLE ssh_connections") + && new.starts_with("CREATE TABLE ssh_connections") + } } +db::static_connection!(DB, WorkspaceDb, []); + impl WorkspaceDb { /// Returns a serialized workspace for the given worktree_roots. If the passed array /// is empty, the most recent workspace is returned instead. If no workspace for the @@ -1803,6 +1834,7 @@ mod tests { ON DELETE CASCADE ) STRICT; )], + |_, _, _| false, ) .unwrap(); }) @@ -1851,6 +1883,7 @@ mod tests { REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT;)], + |_, _, _| false, ) }) .await diff --git a/crates/zed/src/zed/component_preview/persistence.rs b/crates/zed/src/zed/component_preview/persistence.rs index 780f7f7626..c37a4cc389 100644 --- a/crates/zed/src/zed/component_preview/persistence.rs +++ b/crates/zed/src/zed/component_preview/persistence.rs @@ -1,10 +1,17 @@ use anyhow::Result; -use db::{define_connection, query, sqlez::statement::Statement, sqlez_macros::sql}; +use db::{ + query, + sqlez::{domain::Domain, statement::Statement, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use workspace::{ItemId, WorkspaceDb, WorkspaceId}; -define_connection! { - pub static ref COMPONENT_PREVIEW_DB: ComponentPreviewDb = - &[sql!( +pub struct ComponentPreviewDb(ThreadSafeConnection); + +impl Domain for ComponentPreviewDb { + const NAME: &str = stringify!(ComponentPreviewDb); + + const MIGRATIONS: &[&str] = &[sql!( CREATE TABLE component_previews ( workspace_id INTEGER, item_id INTEGER UNIQUE, @@ -13,9 +20,11 @@ define_connection! { FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ) STRICT; - )]; + )]; } +db::static_connection!(COMPONENT_PREVIEW_DB, ComponentPreviewDb, [WorkspaceDb]); + impl ComponentPreviewDb { pub async fn save_active_page( &self, From 633ce23ae974211de452683e6d5b2e1a0bf21431 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 25 Aug 2025 18:55:24 -0600 Subject: [PATCH 34/54] acp: Send user-configured MCP tools (#36910) Release Notes: - N/A --- crates/agent2/src/tests/mod.rs | 1 + crates/agent_servers/src/acp.rs | 30 ++++++++++++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 093b8ba971..78e5c88280 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -72,6 +72,7 @@ async fn test_echo(cx: &mut TestAppContext) { } #[gpui::test] +#[cfg_attr(target_os = "windows", ignore)] // TODO: Fix this test on Windows async fn test_thinking(cx: &mut TestAppContext) { let ThreadTest { model, thread, .. } = setup(cx, TestModel::Fake).await; let fake_model = model.as_fake(); diff --git a/crates/agent_servers/src/acp.rs b/crates/agent_servers/src/acp.rs index 9080fc1ab0..b4e897374a 100644 --- a/crates/agent_servers/src/acp.rs +++ b/crates/agent_servers/src/acp.rs @@ -162,12 +162,34 @@ impl AgentConnection for AcpConnection { let conn = self.connection.clone(); let sessions = self.sessions.clone(); let cwd = cwd.to_path_buf(); + let context_server_store = project.read(cx).context_server_store().read(cx); + let mcp_servers = context_server_store + .configured_server_ids() + .iter() + .filter_map(|id| { + let configuration = context_server_store.configuration_for_server(id)?; + let command = configuration.command(); + Some(acp::McpServer { + name: id.0.to_string(), + command: command.path.clone(), + args: command.args.clone(), + env: if let Some(env) = command.env.as_ref() { + env.iter() + .map(|(name, value)| acp::EnvVariable { + name: name.clone(), + value: value.clone(), + }) + .collect() + } else { + vec![] + }, + }) + }) + .collect(); + cx.spawn(async move |cx| { let response = conn - .new_session(acp::NewSessionRequest { - mcp_servers: vec![], - cwd, - }) + .new_session(acp::NewSessionRequest { mcp_servers, cwd }) .await .map_err(|err| { if err.code == acp::ErrorCode::AUTH_REQUIRED.code { From bb5cfe118f588336d54b0499be998cd7744fb8a2 Mon Sep 17 00:00:00 2001 From: Romans Malinovskis Date: Tue, 26 Aug 2025 04:37:29 +0100 Subject: [PATCH 35/54] Add "shift-r" and "g ." support for helix mode (#35468) Related #4642 Compatible with #34136 Release Notes: - Helix: `Shift+R` works as Paste instead of taking you to ReplaceMode - Helix: `g .` goes to last modification place (similar to `. in vim) --- assets/keymaps/vim.json | 2 + crates/vim/src/helix.rs | 87 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 62e50b3c8c..67add61bd3 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -428,11 +428,13 @@ "g h": "vim::StartOfLine", "g s": "vim::FirstNonWhitespace", // "g s" default behavior is "space s" "g e": "vim::EndOfDocument", + "g .": "vim::HelixGotoLastModification", // go to last modification "g r": "editor::FindAllReferences", // zed specific "g t": "vim::WindowTop", "g c": "vim::WindowMiddle", "g b": "vim::WindowBottom", + "shift-r": "editor::Paste", "x": "editor::SelectLine", "shift-x": "editor::SelectLine", "%": "editor::SelectAll", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 2bc531268d..726022021d 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -23,6 +23,8 @@ actions!( HelixInsert, /// Appends at the end of the selection. HelixAppend, + /// Goes to the location of the last modification. + HelixGotoLastModification, ] ); @@ -31,6 +33,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_insert); Vim::action(editor, cx, Vim::helix_append); Vim::action(editor, cx, Vim::helix_yank); + Vim::action(editor, cx, Vim::helix_goto_last_modification); } impl Vim { @@ -430,6 +433,15 @@ impl Vim { }); self.switch_mode(Mode::HelixNormal, true, window, cx); } + + pub fn helix_goto_last_modification( + &mut self, + _: &HelixGotoLastModification, + window: &mut Window, + cx: &mut Context, + ) { + self.jump(".".into(), false, false, window, cx); + } } #[cfg(test)] @@ -441,6 +453,7 @@ mod test { #[gpui::test] async fn test_word_motions(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // « // ˇ // » @@ -502,6 +515,7 @@ mod test { #[gpui::test] async fn test_delete(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // test delete a selection cx.set_state( @@ -582,6 +596,7 @@ mod test { #[gpui::test] async fn test_f_and_t(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); cx.set_state( indoc! {" @@ -635,6 +650,7 @@ mod test { #[gpui::test] async fn test_newline_char(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); cx.set_state("aa«\nˇ»bb cc", Mode::HelixNormal); @@ -652,6 +668,7 @@ mod test { #[gpui::test] async fn test_insert_selected(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); cx.set_state( indoc! {" «The ˇ»quick brown @@ -674,6 +691,7 @@ mod test { #[gpui::test] async fn test_append(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // test from the end of the selection cx.set_state( indoc! {" @@ -716,6 +734,7 @@ mod test { #[gpui::test] async fn test_replace(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); // No selection (single character) cx.set_state("ˇaa", Mode::HelixNormal); @@ -763,4 +782,72 @@ mod test { cx.shared_clipboard().assert_eq("worl"); cx.assert_state("hello «worlˇ»d", Mode::HelixNormal); } + #[gpui::test] + async fn test_shift_r_paste(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // First copy some text to clipboard + cx.set_state("«hello worldˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("y"); + + // Test paste with shift-r on single cursor + cx.set_state("foo ˇbar", Mode::HelixNormal); + cx.simulate_keystrokes("shift-r"); + + cx.assert_state("foo hello worldˇbar", Mode::HelixNormal); + + // Test paste with shift-r on selection + cx.set_state("foo «barˇ» baz", Mode::HelixNormal); + cx.simulate_keystrokes("shift-r"); + + cx.assert_state("foo hello worldˇ baz", Mode::HelixNormal); + } + + #[gpui::test] + async fn test_insert_mode_stickiness(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Make a modification at a specific location + cx.set_state("ˇhello", Mode::HelixNormal); + assert_eq!(cx.mode(), Mode::HelixNormal); + cx.simulate_keystrokes("i"); + assert_eq!(cx.mode(), Mode::Insert); + cx.simulate_keystrokes("escape"); + assert_eq!(cx.mode(), Mode::HelixNormal); + } + + #[gpui::test] + async fn test_goto_last_modification(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Make a modification at a specific location + cx.set_state("line one\nline ˇtwo\nline three", Mode::HelixNormal); + cx.assert_state("line one\nline ˇtwo\nline three", Mode::HelixNormal); + cx.simulate_keystrokes("i"); + cx.simulate_keystrokes("escape"); + cx.simulate_keystrokes("i"); + cx.simulate_keystrokes("m o d i f i e d space"); + cx.simulate_keystrokes("escape"); + + // TODO: this fails, because state is no longer helix + cx.assert_state( + "line one\nline modified ˇtwo\nline three", + Mode::HelixNormal, + ); + + // Move cursor away from the modification + cx.simulate_keystrokes("up"); + + // Use "g ." to go back to last modification + cx.simulate_keystrokes("g ."); + + // Verify we're back at the modification location and still in HelixNormal mode + cx.assert_state( + "line one\nline modifiedˇ two\nline three", + Mode::HelixNormal, + ); + } } From bf5ed6d1c9795369310b5b9d6c752d9dc54991b5 Mon Sep 17 00:00:00 2001 From: Rui Ning <107875822+iryanin@users.noreply.github.com> Date: Tue, 26 Aug 2025 11:40:53 +0800 Subject: [PATCH 36/54] Remote: Change "sh -c" to "sh -lc" to make config in $HOME/.profile effective (#36760) Closes #ISSUE Release Notes: - The environment of original remote dev cannot be changed without sudo because of the behavior of "sh -c". This PR changes "sh -c" to "sh -lc" to let the shell source $HOME/.profile and support customized environment like customized $PATH variable. --- crates/remote/src/ssh_session.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/remote/src/ssh_session.rs b/crates/remote/src/ssh_session.rs index b9af528643..6794018470 100644 --- a/crates/remote/src/ssh_session.rs +++ b/crates/remote/src/ssh_session.rs @@ -445,7 +445,7 @@ impl SshSocket { } async fn platform(&self) -> Result { - let uname = self.run_command("sh", &["-c", "uname -sm"]).await?; + let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?; let Some((os, arch)) = uname.split_once(" ") else { anyhow::bail!("unknown uname: {uname:?}") }; @@ -476,7 +476,7 @@ impl SshSocket { } async fn shell(&self) -> String { - match self.run_command("sh", &["-c", "echo $SHELL"]).await { + match self.run_command("sh", &["-lc", "echo $SHELL"]).await { Ok(shell) => shell.trim().to_owned(), Err(e) => { log::error!("Failed to get shell: {e}"); @@ -1533,7 +1533,7 @@ impl RemoteConnection for SshRemoteConnection { let ssh_proxy_process = match self .socket - .ssh_command("sh", &["-c", &start_proxy_command]) + .ssh_command("sh", &["-lc", &start_proxy_command]) // IMPORTANT: we kill this process when we drop the task that uses it. .kill_on_drop(true) .spawn() @@ -1910,7 +1910,7 @@ impl SshRemoteConnection { .run_command( "sh", &[ - "-c", + "-lc", &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), ], ) @@ -1988,7 +1988,7 @@ impl SshRemoteConnection { .run_command( "sh", &[ - "-c", + "-lc", &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()), ], ) @@ -2036,7 +2036,7 @@ impl SshRemoteConnection { dst_path = &dst_path.to_string() ) }; - self.socket.run_command("sh", &["-c", &script]).await?; + self.socket.run_command("sh", &["-lc", &script]).await?; Ok(()) } From 64b14ef84859de5d03c5959faedc1216415a2b52 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 25 Aug 2025 22:21:05 -0700 Subject: [PATCH 37/54] Fix Sqlite newline syntax in workspace migration (#36916) Fixes one more case where I incorrectly tried to use a `\n` escape sequence for a newline in sqlite. Release Notes: - N/A --- crates/workspace/src/persistence.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 89e1147d8a..12e719cfd9 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -553,7 +553,7 @@ impl Domain for WorkspaceDb { WHEN workspaces.local_paths_array IS NULL OR workspaces.local_paths_array = "" THEN NULL ELSE - replace(workspaces.local_paths_array, ',', "\n") + replace(workspaces.local_paths_array, ',', CHAR(10)) END END as paths, From 428fc6d483b785227dfd56e4e493ee7ccc3c384d Mon Sep 17 00:00:00 2001 From: Dan Dascalescu Date: Tue, 26 Aug 2025 12:05:40 +0300 Subject: [PATCH 38/54] chore: Fix typo in `10_bug_report.yml` (#36922) Release Notes: - N/A --- .github/ISSUE_TEMPLATE/10_bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/10_bug_report.yml b/.github/ISSUE_TEMPLATE/10_bug_report.yml index e132eca1e5..1bf6c80e40 100644 --- a/.github/ISSUE_TEMPLATE/10_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/10_bug_report.yml @@ -14,7 +14,7 @@ body: ### Description From c14d84cfdb61a4d6fbaeabe14c3b5ca0909163af Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 11:20:33 +0200 Subject: [PATCH 39/54] acp: Add button to configure custom agent in the configuration view (#36923) Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 107 ++++++++++++++++++++- 1 file changed, 102 insertions(+), 5 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 52fb7eed4b..aa9b2ca94f 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -5,18 +5,21 @@ mod tool_picker; use std::{sync::Arc, time::Duration}; -use agent_servers::{AgentServerCommand, AllAgentServersSettings, Gemini}; +use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini}; use agent_settings::AgentSettings; +use anyhow::Result; use assistant_tool::{ToolSource, ToolWorkingSet}; use cloud_llm_client::Plan; use collections::HashMap; use context_server::ContextServerId; +use editor::{Editor, SelectionEffects, scroll::Autoscroll}; use extension::ExtensionManifest; use extension_host::ExtensionStore; use fs::Fs; use gpui::{ - Action, Animation, AnimationExt as _, AnyView, App, Corner, Entity, EventEmitter, FocusHandle, - Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, WeakEntity, percentage, + Action, Animation, AnimationExt as _, AnyView, App, AsyncWindowContext, Corner, Entity, + EventEmitter, FocusHandle, Focusable, Hsla, ScrollHandle, Subscription, Task, Transformation, + WeakEntity, percentage, }; use language::LanguageRegistry; use language_model::{ @@ -34,7 +37,7 @@ use ui::{ Scrollbar, ScrollbarState, Switch, SwitchColor, SwitchField, Tooltip, prelude::*, }; use util::ResultExt as _; -use workspace::Workspace; +use workspace::{Workspace, create_and_open_local_file}; use zed_actions::ExtensionCategoryFilter; pub(crate) use configure_context_server_modal::ConfigureContextServerModal; @@ -1058,7 +1061,36 @@ impl AgentConfiguration { .child( v_flex() .gap_0p5() - .child(Headline::new("External Agents")) + .child( + h_flex() + .w_full() + .gap_2() + .justify_between() + .child(Headline::new("External Agents")) + .child( + Button::new("add-agent", "Add Agent") + .icon_position(IconPosition::Start) + .icon(IconName::Plus) + .icon_size(IconSize::Small) + .icon_color(Color::Muted) + .label_size(LabelSize::Small) + .on_click( + move |_, window, cx| { + if let Some(workspace) = window.root().flatten() { + let workspace = workspace.downgrade(); + window + .spawn(cx, async |cx| { + open_new_agent_servers_entry_in_settings_editor( + workspace, + cx, + ).await + }) + .detach_and_log_err(cx); + } + } + ), + ) + ) .child( Label::new( "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", @@ -1324,3 +1356,68 @@ fn show_unable_to_uninstall_extension_with_context_server( workspace.toggle_status_toast(status_toast, cx); } + +async fn open_new_agent_servers_entry_in_settings_editor( + workspace: WeakEntity, + cx: &mut AsyncWindowContext, +) -> Result<()> { + let settings_editor = workspace + .update_in(cx, |_, window, cx| { + create_and_open_local_file(paths::settings_file(), window, cx, || { + settings::initial_user_settings_content().as_ref().into() + }) + })? + .await? + .downcast::() + .unwrap(); + + settings_editor + .downgrade() + .update_in(cx, |item, window, cx| { + let text = item.buffer().read(cx).snapshot(cx).text(); + + let settings = cx.global::(); + + let edits = settings.edits_for_update::(&text, |file| { + let unique_server_name = (0..u8::MAX) + .map(|i| { + if i == 0 { + "your_agent".into() + } else { + format!("your_agent_{}", i).into() + } + }) + .find(|name| !file.custom.contains_key(name)); + if let Some(server_name) = unique_server_name { + file.custom.insert( + server_name, + AgentServerSettings { + command: AgentServerCommand { + path: "path_to_executable".into(), + args: vec![], + env: Some(HashMap::default()), + }, + }, + ); + } + }); + + if !edits.is_empty() { + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); + + item.edit(edits, cx); + + item.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(ranges); + }, + ); + } + }) +} From b249593abee31e420fe447f6b551b5e2130b1bc8 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Tue, 26 Aug 2025 02:46:29 -0700 Subject: [PATCH 40/54] agent2: Always finalize diffs from the edit tool (#36918) Previously, we wouldn't finalize the diff if an error occurred during editing or the tool call was canceled. Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/agent2/src/thread.rs | 24 +++++ crates/agent2/src/tools/edit_file_tool.rs | 103 ++++++++++++++++++++- crates/language_model/src/fake_provider.rs | 31 ++++++- 3 files changed, 152 insertions(+), 6 deletions(-) diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 1b1c014b79..4acd72f275 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -2459,6 +2459,30 @@ impl ToolCallEventStreamReceiver { } } + pub async fn expect_update_fields(&mut self) -> acp::ToolCallUpdateFields { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateFields( + update, + )))) = event + { + update.fields + } else { + panic!("Expected update fields but got: {:?}", event); + } + } + + pub async fn expect_diff(&mut self) -> Entity { + let event = self.0.next().await; + if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateDiff( + update, + )))) = event + { + update.diff + } else { + panic!("Expected diff but got: {:?}", event); + } + } + pub async fn expect_terminal(&mut self) -> Entity { let event = self.0.next().await; if let Some(Ok(ThreadEvent::ToolCallUpdate(acp_thread::ToolCallUpdate::UpdateTerminal( diff --git a/crates/agent2/src/tools/edit_file_tool.rs b/crates/agent2/src/tools/edit_file_tool.rs index 5a68d0c70a..f86bfd25f7 100644 --- a/crates/agent2/src/tools/edit_file_tool.rs +++ b/crates/agent2/src/tools/edit_file_tool.rs @@ -273,6 +273,13 @@ impl AgentTool for EditFileTool { let diff = cx.new(|cx| Diff::new(buffer.clone(), cx))?; event_stream.update_diff(diff.clone()); + let _finalize_diff = util::defer({ + let diff = diff.downgrade(); + let mut cx = cx.clone(); + move || { + diff.update(&mut cx, |diff, cx| diff.finalize(cx)).ok(); + } + }); let old_snapshot = buffer.read_with(cx, |buffer, _cx| buffer.snapshot())?; let old_text = cx @@ -389,8 +396,6 @@ impl AgentTool for EditFileTool { }) .await; - diff.update(cx, |diff, cx| diff.finalize(cx)).ok(); - let input_path = input.path.display(); if unified_diff.is_empty() { anyhow::ensure!( @@ -1545,6 +1550,100 @@ mod tests { ); } + #[gpui::test] + async fn test_diff_finalization(cx: &mut TestAppContext) { + init_test(cx); + let fs = project::FakeFs::new(cx.executor()); + fs.insert_tree("/", json!({"main.rs": ""})).await; + + let project = Project::test(fs.clone(), [path!("/").as_ref()], cx).await; + let languages = project.read_with(cx, |project, _cx| project.languages().clone()); + let context_server_registry = + cx.new(|cx| ContextServerRegistry::new(project.read(cx).context_server_store(), cx)); + let model = Arc::new(FakeLanguageModel::default()); + let thread = cx.new(|cx| { + Thread::new( + project.clone(), + cx.new(|_cx| ProjectContext::default()), + context_server_registry.clone(), + Templates::new(), + Some(model.clone()), + cx, + ) + }); + + // Ensure the diff is finalized after the edit completes. + { + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + cx.run_until_parked(); + model.end_last_completion_stream(); + edit.await.unwrap(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + + // Ensure the diff is finalized if an error occurs while editing. + { + model.forbid_requests(); + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + edit.await.unwrap_err(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + model.allow_requests(); + } + + // Ensure the diff is finalized if the tool call gets dropped. + { + let tool = Arc::new(EditFileTool::new(thread.downgrade(), languages.clone())); + let (stream_tx, mut stream_rx) = ToolCallEventStream::test(); + let edit = cx.update(|cx| { + tool.run( + EditFileToolInput { + display_description: "Edit file".into(), + path: path!("/main.rs").into(), + mode: EditFileMode::Edit, + }, + stream_tx, + cx, + ) + }); + stream_rx.expect_update_fields().await; + let diff = stream_rx.expect_diff().await; + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Pending(_)))); + drop(edit); + cx.run_until_parked(); + diff.read_with(cx, |diff, _| assert!(matches!(diff, Diff::Finalized(_)))); + } + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); diff --git a/crates/language_model/src/fake_provider.rs b/crates/language_model/src/fake_provider.rs index ebfd37d16c..b06a475f93 100644 --- a/crates/language_model/src/fake_provider.rs +++ b/crates/language_model/src/fake_provider.rs @@ -4,12 +4,16 @@ use crate::{ LanguageModelProviderId, LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, LanguageModelToolChoice, }; +use anyhow::anyhow; use futures::{FutureExt, channel::mpsc, future::BoxFuture, stream::BoxStream}; use gpui::{AnyView, App, AsyncApp, Entity, Task, Window}; use http_client::Result; use parking_lot::Mutex; use smol::stream::StreamExt; -use std::sync::Arc; +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering::SeqCst}, +}; #[derive(Clone)] pub struct FakeLanguageModelProvider { @@ -106,6 +110,7 @@ pub struct FakeLanguageModel { >, )>, >, + forbid_requests: AtomicBool, } impl Default for FakeLanguageModel { @@ -114,11 +119,20 @@ impl Default for FakeLanguageModel { provider_id: LanguageModelProviderId::from("fake".to_string()), provider_name: LanguageModelProviderName::from("Fake".to_string()), current_completion_txs: Mutex::new(Vec::new()), + forbid_requests: AtomicBool::new(false), } } } impl FakeLanguageModel { + pub fn allow_requests(&self) { + self.forbid_requests.store(false, SeqCst); + } + + pub fn forbid_requests(&self) { + self.forbid_requests.store(true, SeqCst); + } + pub fn pending_completions(&self) -> Vec { self.current_completion_txs .lock() @@ -251,9 +265,18 @@ impl LanguageModel for FakeLanguageModel { LanguageModelCompletionError, >, > { - let (tx, rx) = mpsc::unbounded(); - self.current_completion_txs.lock().push((request, tx)); - async move { Ok(rx.boxed()) }.boxed() + if self.forbid_requests.load(SeqCst) { + async move { + Err(LanguageModelCompletionError::Other(anyhow!( + "requests are forbidden" + ))) + } + .boxed() + } else { + let (tx, rx) = mpsc::unbounded(); + self.current_completion_txs.lock().push((request, tx)); + async move { Ok(rx.boxed()) }.boxed() + } } fn as_fake(&self) -> &Self { From e96b68bc1599b92b6404f77326d79e198f4a8efb Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 12:55:45 +0200 Subject: [PATCH 41/54] acp: Polish UI (#36927) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/agent2/src/tests/mod.rs | 2 +- crates/agent2/src/thread.rs | 14 ++++++++++++-- crates/agent_ui/src/acp/thread_view.rs | 7 ++++--- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index 78e5c88280..a55eaacee3 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -472,7 +472,7 @@ async fn test_tool_authorization(cx: &mut TestAppContext) { tool_name: ToolRequiringPermission::name().into(), is_error: true, content: "Permission to run tool denied by user".into(), - output: None + output: Some("Permission to run tool denied by user".into()) }) ] ); diff --git a/crates/agent2/src/thread.rs b/crates/agent2/src/thread.rs index 4acd72f275..97ea1caf1d 100644 --- a/crates/agent2/src/thread.rs +++ b/crates/agent2/src/thread.rs @@ -732,7 +732,17 @@ impl Thread { stream.update_tool_call_fields( &tool_use.id, acp::ToolCallUpdateFields { - status: Some(acp::ToolCallStatus::Completed), + status: Some( + tool_result + .as_ref() + .map_or(acp::ToolCallStatus::Failed, |result| { + if result.is_error { + acp::ToolCallStatus::Failed + } else { + acp::ToolCallStatus::Completed + } + }), + ), raw_output: output, ..Default::default() }, @@ -1557,7 +1567,7 @@ impl Thread { tool_name: tool_use.name, is_error: true, content: LanguageModelToolResultContent::Text(Arc::from(error.to_string())), - output: None, + output: Some(error.to_string().into()), }, } })) diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 837ce6f90a..6d8f8fb82e 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -1684,7 +1684,7 @@ impl AcpThreadView { div() .relative() .mt_1p5() - .ml(px(7.)) + .ml(rems(0.4)) .pl_4() .border_l_1() .border_color(self.tool_card_border_color(cx)) @@ -1850,6 +1850,7 @@ impl AcpThreadView { .w_full() .h(window.line_height() - px(2.)) .text_size(self.tool_name_font_size()) + .gap_0p5() .child(tool_icon) .child(if tool_call.locations.len() == 1 { let name = tool_call.locations[0] @@ -1968,7 +1969,7 @@ impl AcpThreadView { v_flex() .mt_1p5() - .ml(px(7.)) + .ml(rems(0.4)) .px_3p5() .gap_2() .border_l_1() @@ -2025,7 +2026,7 @@ impl AcpThreadView { let button_id = SharedString::from(format!("item-{}", uri)); div() - .ml(px(7.)) + .ml(rems(0.4)) .pl_2p5() .border_l_1() .border_color(self.tool_card_border_color(cx)) From 10a1140d49fc0af2c1adf301433a3c9a34374417 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 13:18:50 +0200 Subject: [PATCH 42/54] acp: Improve matching logic when adding new entry to agent_servers (#36926) Release Notes: - N/A --------- Co-authored-by: Antonio Scandurra --- crates/agent_ui/src/agent_configuration.rs | 75 +++++++++++++++++----- 1 file changed, 58 insertions(+), 17 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index aa9b2ca94f..c279115880 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -3,7 +3,7 @@ mod configure_context_server_modal; mod manage_profiles_modal; mod tool_picker; -use std::{sync::Arc, time::Duration}; +use std::{ops::Range, sync::Arc, time::Duration}; use agent_servers::{AgentServerCommand, AgentServerSettings, AllAgentServersSettings, Gemini}; use agent_settings::AgentSettings; @@ -1378,8 +1378,9 @@ async fn open_new_agent_servers_entry_in_settings_editor( let settings = cx.global::(); + let mut unique_server_name = None; let edits = settings.edits_for_update::(&text, |file| { - let unique_server_name = (0..u8::MAX) + let server_name: Option = (0..u8::MAX) .map(|i| { if i == 0 { "your_agent".into() @@ -1388,7 +1389,8 @@ async fn open_new_agent_servers_entry_in_settings_editor( } }) .find(|name| !file.custom.contains_key(name)); - if let Some(server_name) = unique_server_name { + if let Some(server_name) = server_name { + unique_server_name = Some(server_name.clone()); file.custom.insert( server_name, AgentServerSettings { @@ -1402,22 +1404,61 @@ async fn open_new_agent_servers_entry_in_settings_editor( } }); - if !edits.is_empty() { - let ranges = edits - .iter() - .map(|(range, _)| range.clone()) - .collect::>(); + if edits.is_empty() { + return; + } - item.edit(edits, cx); + let ranges = edits + .iter() + .map(|(range, _)| range.clone()) + .collect::>(); - item.change_selections( - SelectionEffects::scroll(Autoscroll::newest()), - window, - cx, - |selections| { - selections.select_ranges(ranges); - }, - ); + item.edit(edits, cx); + if let Some((unique_server_name, buffer)) = + unique_server_name.zip(item.buffer().read(cx).as_singleton()) + { + let snapshot = buffer.read(cx).snapshot(); + if let Some(range) = + find_text_in_buffer(&unique_server_name, ranges[0].start, &snapshot) + { + item.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + cx, + |selections| { + selections.select_ranges(vec![range]); + }, + ); + } } }) } + +fn find_text_in_buffer( + text: &str, + start: usize, + snapshot: &language::BufferSnapshot, +) -> Option> { + let chars = text.chars().collect::>(); + + let mut offset = start; + let mut char_offset = 0; + for c in snapshot.chars_at(start) { + if char_offset >= chars.len() { + break; + } + offset += 1; + + if c == chars[char_offset] { + char_offset += 1; + } else { + char_offset = 0; + } + } + + if char_offset == chars.len() { + Some(offset.saturating_sub(chars.len())..offset) + } else { + None + } +} From 372b3c7af632caffbc4e73d5b84bc804d375904a Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 15:30:26 +0200 Subject: [PATCH 43/54] acp: Enable feature flag for everyone (#36928) Release Notes: - N/A --- crates/agent_ui/src/agent_panel.rs | 11 ----------- crates/feature_flags/src/feature_flags.rs | 6 +++++- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 269aec3365..267c76d73f 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -591,17 +591,6 @@ impl AgentPanel { None }; - // Wait for the Gemini/Native feature flag to be available. - let client = workspace.read_with(cx, |workspace, _| workspace.client().clone())?; - if !client.status().borrow().is_signed_out() { - cx.update(|_, cx| { - cx.wait_for_flag_or_timeout::( - Duration::from_secs(2), - ) - })? - .await; - } - let panel = workspace.update_in(cx, |workspace, window, cx| { let panel = cx.new(|cx| { Self::new( diff --git a/crates/feature_flags/src/feature_flags.rs b/crates/feature_flags/src/feature_flags.rs index 422979c429..f5f7fc42b3 100644 --- a/crates/feature_flags/src/feature_flags.rs +++ b/crates/feature_flags/src/feature_flags.rs @@ -98,6 +98,10 @@ impl FeatureFlag for GeminiAndNativeFeatureFlag { // integration too, and we'd like to turn Gemini/Native on in new builds // without enabling Claude Code in old builds. const NAME: &'static str = "gemini-and-native"; + + fn enabled_for_all() -> bool { + true + } } pub struct ClaudeCodeFeatureFlag; @@ -201,7 +205,7 @@ impl FeatureFlagAppExt for App { fn has_flag(&self) -> bool { self.try_global::() .map(|flags| flags.has_flag::()) - .unwrap_or(false) + .unwrap_or(T::enabled_for_all()) } fn is_staff(&self) -> bool { From aa0f7a2d09c06331dbb176a8b0e45235f1ad8516 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 26 Aug 2025 09:33:42 -0400 Subject: [PATCH 44/54] Fix conflicts in Linux default keymap (#36519) Closes https://github.com/zed-industries/zed/issues/29746 | Action | New Key | Old Key | Former Conflict | | - | - | - | - | | `edit_prediction::ToggleMenu` | `ctrl-alt-shift-i` | `ctrl-shift-i` | `editor::Format` | | `editor::ToggleEditPrediction` | `ctrl-alt-shift-e` | `ctrl-shift-e` | `project_panel::ToggleFocus` | These aren't great keys and I'm open to alternate suggestions, but the will work out of the box without conflict. Release Notes: - N/A --- assets/keymaps/default-linux.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index e84f4834af..3cca560c00 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -40,7 +40,7 @@ "shift-f11": "debugger::StepOut", "f11": "zed::ToggleFullScreen", "ctrl-alt-z": "edit_prediction::RateCompletions", - "ctrl-shift-i": "edit_prediction::ToggleMenu", + "ctrl-alt-shift-i": "edit_prediction::ToggleMenu", "ctrl-alt-l": "lsp_tool::ToggleMenu" } }, @@ -120,7 +120,7 @@ "alt-g m": "git::OpenModifiedFiles", "menu": "editor::OpenContextMenu", "shift-f10": "editor::OpenContextMenu", - "ctrl-shift-e": "editor::ToggleEditPrediction", + "ctrl-alt-shift-e": "editor::ToggleEditPrediction", "f9": "editor::ToggleBreakpoint", "shift-f9": "editor::EditLogBreakpoint" } From 76dbcde62836445d146c3918edce26dbaec25314 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 26 Aug 2025 09:35:45 -0400 Subject: [PATCH 45/54] Support disabling drag-and-drop in Project Panel (#36719) Release Notes: - Added setting for disabling drag and drop in project panel. `{ "project_panel": {"drag_and_drop": false } }` --- assets/settings/default.json | 2 + crates/project_panel/src/project_panel.rs | 74 ++++++++++--------- .../src/project_panel_settings.rs | 5 ++ docs/src/configuring-zed.md | 1 + docs/src/visual-customization.md | 1 + 5 files changed, 50 insertions(+), 33 deletions(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index f0b9e11e57..804198090f 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -653,6 +653,8 @@ // "never" "show": "always" }, + // Whether to enable drag-and-drop operations in the project panel. + "drag_and_drop": true, // Whether to hide the root entry when only one folder is open in the window. "hide_root": false }, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index c99f5f8172..5a30a3e9bc 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -4089,6 +4089,7 @@ impl ProjectPanel { .when(!is_sticky, |this| { this .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) + .when(settings.drag_and_drop, |this| this .on_drag_move::(cx.listener( move |this, event: &DragMoveEvent, _, cx| { let is_current_target = this.drag_target_entry.as_ref() @@ -4222,7 +4223,7 @@ impl ProjectPanel { } this.drag_onto(selections, entry_id, kind.is_file(), window, cx); }), - ) + )) }) .on_mouse_down( MouseButton::Left, @@ -4433,6 +4434,7 @@ impl ProjectPanel { div() .when(!is_sticky, |div| { div + .when(settings.drag_and_drop, |div| div .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { this.hover_scroll_task.take(); this.drag_target_entry = None; @@ -4464,7 +4466,7 @@ impl ProjectPanel { } }, - )) + ))) }) .child( Label::new(DELIMITER.clone()) @@ -4484,6 +4486,7 @@ impl ProjectPanel { .when(index != components_len - 1, |div|{ let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); div + .when(settings.drag_and_drop, |div| div .on_drag_move(cx.listener( move |this, event: &DragMoveEvent, _, _| { if event.bounds.contains(&event.event.position) { @@ -4521,7 +4524,7 @@ impl ProjectPanel { target.index == index ), |this| { this.bg(item_colors.drag_over) - }) + })) }) }) .on_click(cx.listener(move |this, _, _, cx| { @@ -5029,7 +5032,8 @@ impl ProjectPanel { sticky_parents.reverse(); - let git_status_enabled = ProjectPanelSettings::get_global(cx).git_status; + let panel_settings = ProjectPanelSettings::get_global(cx); + let git_status_enabled = panel_settings.git_status; let root_name = OsStr::new(worktree.root_name()); let git_summaries_by_id = if git_status_enabled { @@ -5113,11 +5117,11 @@ impl Render for ProjectPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let has_worktree = !self.visible_entries.is_empty(); let project = self.project.read(cx); - let indent_size = ProjectPanelSettings::get_global(cx).indent_size; - let show_indent_guides = - ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always; + let panel_settings = ProjectPanelSettings::get_global(cx); + let indent_size = panel_settings.indent_size; + let show_indent_guides = panel_settings.indent_guides.show == ShowIndentGuides::Always; let show_sticky_entries = { - if ProjectPanelSettings::get_global(cx).sticky_scroll { + if panel_settings.sticky_scroll { let is_scrollable = self.scroll_handle.is_scrollable(); let is_scrolled = self.scroll_handle.offset().y < px(0.); is_scrollable && is_scrolled @@ -5205,8 +5209,10 @@ impl Render for ProjectPanel { h_flex() .id("project-panel") .group("project-panel") - .on_drag_move(cx.listener(handle_drag_move::)) - .on_drag_move(cx.listener(handle_drag_move::)) + .when(panel_settings.drag_and_drop, |this| { + this.on_drag_move(cx.listener(handle_drag_move::)) + .on_drag_move(cx.listener(handle_drag_move::)) + }) .size_full() .relative() .on_modifiers_changed(cx.listener( @@ -5544,30 +5550,32 @@ impl Render for ProjectPanel { })), ) .when(is_local, |div| { - div.drag_over::(|style, _, _, cx| { - style.bg(cx.theme().colors().drop_target_background) + div.when(panel_settings.drag_and_drop, |div| { + div.drag_over::(|style, _, _, cx| { + style.bg(cx.theme().colors().drop_target_background) + }) + .on_drop(cx.listener( + move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + if let Some(task) = this + .workspace + .update(cx, |workspace, cx| { + workspace.open_workspace_for_paths( + true, + external_paths.paths().to_owned(), + window, + cx, + ) + }) + .log_err() + { + task.detach_and_log_err(cx); + } + cx.stop_propagation(); + }, + )) }) - .on_drop(cx.listener( - move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - if let Some(task) = this - .workspace - .update(cx, |workspace, cx| { - workspace.open_workspace_for_paths( - true, - external_paths.paths().to_owned(), - window, - cx, - ) - }) - .log_err() - { - task.detach_and_log_err(cx); - } - cx.stop_propagation(); - }, - )) }) } } diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 8a243589ed..fc399d66a7 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -47,6 +47,7 @@ pub struct ProjectPanelSettings { pub scrollbar: ScrollbarSettings, pub show_diagnostics: ShowDiagnostics, pub hide_root: bool, + pub drag_and_drop: bool, } #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] @@ -160,6 +161,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: true pub sticky_scroll: Option, + /// Whether to enable drag-and-drop operations in the project panel. + /// + /// Default: true + pub drag_and_drop: Option, } impl Settings for ProjectPanelSettings { diff --git a/docs/src/configuring-zed.md b/docs/src/configuring-zed.md index fb139db6e4..a8a4689689 100644 --- a/docs/src/configuring-zed.md +++ b/docs/src/configuring-zed.md @@ -3243,6 +3243,7 @@ Run the `theme selector: toggle` action in the command palette to see a current "indent_size": 20, "auto_reveal_entries": true, "auto_fold_dirs": true, + "drag_and_drop": true, "scrollbar": { "show": null }, diff --git a/docs/src/visual-customization.md b/docs/src/visual-customization.md index 24b2a9d769..4fc5a9ba88 100644 --- a/docs/src/visual-customization.md +++ b/docs/src/visual-customization.md @@ -431,6 +431,7 @@ Project panel can be shown/hidden with {#action project_panel::ToggleFocus} ({#k "auto_reveal_entries": true, // Show file in panel when activating its buffer "auto_fold_dirs": true, // Fold dirs with single subdir "sticky_scroll": true, // Stick parent directories at top of the project panel. + "drag_and_drop": true, // Whether drag and drop is enabled "scrollbar": { // Project panel scrollbar settings "show": null // Show/hide: (auto, system, always, never) }, From b7dad2cf7199e4e31ce149d707dc87683981bc5d Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 26 Aug 2025 09:41:57 -0400 Subject: [PATCH 46/54] Fix initial_tasks.json triggering diagnostic warning (#36523) `zed::OpenProjectTasks` without an existing tasks.json will recreate it from the template. This file will immediately show a warning. Screenshot 2025-08-19 at 17 16 07 Release Notes: - N/A --- assets/settings/initial_tasks.json | 4 ++-- docs/src/tasks.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/assets/settings/initial_tasks.json b/assets/settings/initial_tasks.json index a79c550671..5cead67b6d 100644 --- a/assets/settings/initial_tasks.json +++ b/assets/settings/initial_tasks.json @@ -43,8 +43,8 @@ // "args": ["--login"] // } // } - "shell": "system", + "shell": "system" // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. - "tags": [] + // "tags": [] } ] diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 9550563432..bff3eac860 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -45,9 +45,9 @@ Zed supports ways to spawn (and rerun) commands using its integrated terminal to // Whether to show the task line in the output of the spawned task, defaults to `true`. "show_summary": true, // Whether to show the command line in the output of the spawned task, defaults to `true`. - "show_output": true, + "show_output": true // Represents the tags for inline runnable indicators, or spawning multiple tasks at once. - "tags": [] + // "tags": [] } ] ``` From 2c64b05ea44c4b8be85d298c01c3c8984b433398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Mart=C3=ADn?= Date: Tue, 26 Aug 2025 15:43:58 +0200 Subject: [PATCH 47/54] emacs: Add editor::FindAllReferences keybinding (#36840) This commit maps `editor::FindAllReferences` to Alt+? in the Emacs keymap. Release Notes: - N/A --- assets/keymaps/linux/emacs.json | 1 + assets/keymaps/macos/emacs.json | 1 + 2 files changed, 2 insertions(+) diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 0ff3796f03..62910e297b 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -38,6 +38,7 @@ "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-x ctrl-;": "editor::ToggleComments", "alt-.": "editor::GoToDefinition", // xref-find-definitions + "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 0ff3796f03..62910e297b 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -38,6 +38,7 @@ "alt-;": ["editor::ToggleComments", { "advance_downwards": false }], "ctrl-x ctrl-;": "editor::ToggleComments", "alt-.": "editor::GoToDefinition", // xref-find-definitions + "alt-?": "editor::FindAllReferences", // xref-find-references "alt-,": "pane::GoBack", // xref-pop-marker-stack "ctrl-x h": "editor::SelectAll", // mark-whole-buffer "ctrl-d": "editor::Delete", // delete-char From 858ab9cc2358a4f7571c3a0a46cdfa9ac5124714 Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Tue, 26 Aug 2025 15:55:09 +0200 Subject: [PATCH 48/54] Revert "ai: Auto select user model when there's no default" (#36932) Reverts zed-industries/zed#36722 Release Notes: - N/A --- crates/agent/src/thread.rs | 17 +-- crates/agent2/src/agent.rs | 4 +- crates/agent2/src/tests/mod.rs | 4 +- .../agent_ui/src/language_model_selector.rs | 55 ++++++++- crates/git_ui/src/git_panel.rs | 2 +- crates/language_model/src/registry.rs | 114 ++++++++---------- crates/language_models/Cargo.toml | 1 - crates/language_models/src/language_models.rs | 103 +--------------- crates/language_models/src/provider/cloud.rs | 6 +- 9 files changed, 122 insertions(+), 184 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 899e360ab0..7b70fde56a 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -664,7 +664,7 @@ impl Thread { } pub fn get_or_init_configured_model(&mut self, cx: &App) -> Option { - if self.configured_model.is_none() || self.messages.is_empty() { + if self.configured_model.is_none() { self.configured_model = LanguageModelRegistry::read_global(cx).default_model(); } self.configured_model.clone() @@ -2097,7 +2097,7 @@ impl Thread { } pub fn summarize(&mut self, cx: &mut Context) { - let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model(cx) else { + let Some(model) = LanguageModelRegistry::read_global(cx).thread_summary_model() else { println!("No thread summary model"); return; }; @@ -2416,7 +2416,7 @@ impl Thread { } let Some(ConfiguredModel { model, provider }) = - LanguageModelRegistry::read_global(cx).thread_summary_model(cx) + LanguageModelRegistry::read_global(cx).thread_summary_model() else { return; }; @@ -5410,10 +5410,13 @@ fn main() {{ }), cx, ); - registry.set_thread_summary_model(Some(ConfiguredModel { - provider, - model: model.clone(), - })); + registry.set_thread_summary_model( + Some(ConfiguredModel { + provider, + model: model.clone(), + }), + cx, + ); }) }); diff --git a/crates/agent2/src/agent.rs b/crates/agent2/src/agent.rs index ecfaea4b49..6fa36d33d5 100644 --- a/crates/agent2/src/agent.rs +++ b/crates/agent2/src/agent.rs @@ -228,7 +228,7 @@ impl NativeAgent { ) -> Entity { let connection = Rc::new(NativeAgentConnection(cx.entity())); let registry = LanguageModelRegistry::read_global(cx); - let summarization_model = registry.thread_summary_model(cx).map(|c| c.model); + let summarization_model = registry.thread_summary_model().map(|c| c.model); thread_handle.update(cx, |thread, cx| { thread.set_summarization_model(summarization_model, cx); @@ -524,7 +524,7 @@ impl NativeAgent { let registry = LanguageModelRegistry::read_global(cx); let default_model = registry.default_model().map(|m| m.model); - let summarization_model = registry.thread_summary_model(cx).map(|m| m.model); + let summarization_model = registry.thread_summary_model().map(|m| m.model); for session in self.sessions.values_mut() { session.thread.update(cx, |thread, cx| { diff --git a/crates/agent2/src/tests/mod.rs b/crates/agent2/src/tests/mod.rs index a55eaacee3..fbeee46a48 100644 --- a/crates/agent2/src/tests/mod.rs +++ b/crates/agent2/src/tests/mod.rs @@ -1822,11 +1822,11 @@ async fn test_agent_connection(cx: &mut TestAppContext) { let clock = Arc::new(clock::FakeSystemClock::new()); let client = Client::new(clock, http_client, cx); let user_store = cx.new(|cx| UserStore::new(client.clone(), cx)); - Project::init_settings(cx); - agent_settings::init(cx); language_model::init(client.clone(), cx); language_models::init(user_store, client.clone(), cx); + Project::init_settings(cx); LanguageModelRegistry::test(cx); + agent_settings::init(cx); }); cx.executor().forbid_parking(); diff --git a/crates/agent_ui/src/language_model_selector.rs b/crates/agent_ui/src/language_model_selector.rs index aceca79dbf..3633e533da 100644 --- a/crates/agent_ui/src/language_model_selector.rs +++ b/crates/agent_ui/src/language_model_selector.rs @@ -6,7 +6,8 @@ use feature_flags::ZedProFeatureFlag; use fuzzy::{StringMatch, StringMatchCandidate, match_strings}; use gpui::{Action, AnyElement, App, BackgroundExecutor, DismissEvent, Subscription, Task}; use language_model::{ - ConfiguredModel, LanguageModel, LanguageModelProviderId, LanguageModelRegistry, + AuthenticateError, ConfiguredModel, LanguageModel, LanguageModelProviderId, + LanguageModelRegistry, }; use ordered_float::OrderedFloat; use picker::{Picker, PickerDelegate}; @@ -76,6 +77,7 @@ pub struct LanguageModelPickerDelegate { all_models: Arc, filtered_entries: Vec, selected_index: usize, + _authenticate_all_providers_task: Task<()>, _subscriptions: Vec, } @@ -96,6 +98,7 @@ impl LanguageModelPickerDelegate { selected_index: Self::get_active_model_index(&entries, get_active_model(cx)), filtered_entries: entries, get_active_model: Arc::new(get_active_model), + _authenticate_all_providers_task: Self::authenticate_all_providers(cx), _subscriptions: vec![cx.subscribe_in( &LanguageModelRegistry::global(cx), window, @@ -139,6 +142,56 @@ impl LanguageModelPickerDelegate { .unwrap_or(0) } + /// Authenticates all providers in the [`LanguageModelRegistry`]. + /// + /// We do this so that we can populate the language selector with all of the + /// models from the configured providers. + fn authenticate_all_providers(cx: &mut App) -> Task<()> { + let authenticate_all_providers = LanguageModelRegistry::global(cx) + .read(cx) + .providers() + .iter() + .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) + .collect::>(); + + cx.spawn(async move |_cx| { + for (provider_id, provider_name, authenticate_task) in authenticate_all_providers { + if let Err(err) = authenticate_task.await { + if matches!(err, AuthenticateError::CredentialsNotFound) { + // Since we're authenticating these providers in the + // background for the purposes of populating the + // language selector, we don't care about providers + // where the credentials are not found. + } else { + // Some providers have noisy failure states that we + // don't want to spam the logs with every time the + // language model selector is initialized. + // + // Ideally these should have more clear failure modes + // that we know are safe to ignore here, like what we do + // with `CredentialsNotFound` above. + match provider_id.0.as_ref() { + "lmstudio" | "ollama" => { + // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". + // + // These fail noisily, so we don't log them. + } + "copilot_chat" => { + // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. + } + _ => { + log::error!( + "Failed to authenticate provider: {}: {err}", + provider_name.0 + ); + } + } + } + } + } + }) + } + pub fn active_model(&self, cx: &App) -> Option { (self.get_active_model)(cx) } diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 958a609a09..4ecb4a8829 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -4466,7 +4466,7 @@ fn current_language_model(cx: &Context<'_, GitPanel>) -> Option, - /// This model is automatically configured by a user's environment after - /// authenticating all providers. It's only used when default_model is not available. - environment_fallback_model: Option, + default_fast_model: Option, inline_assistant_model: Option, commit_message_model: Option, thread_summary_model: Option, @@ -99,6 +98,9 @@ impl ConfiguredModel { pub enum Event { DefaultModelChanged, + InlineAssistantModelChanged, + CommitMessageModelChanged, + ThreadSummaryModelChanged, ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), RemovedProvider(LanguageModelProviderId), @@ -224,7 +226,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_inline_assistant_model(configured_model); + self.set_inline_assistant_model(configured_model, cx); } pub fn select_commit_message_model( @@ -233,7 +235,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_commit_message_model(configured_model); + self.set_commit_message_model(configured_model, cx); } pub fn select_thread_summary_model( @@ -242,7 +244,7 @@ impl LanguageModelRegistry { cx: &mut Context, ) { let configured_model = model.and_then(|model| self.select_model(model, cx)); - self.set_thread_summary_model(configured_model); + self.set_thread_summary_model(configured_model, cx); } /// Selects and sets the inline alternatives for language models based on @@ -276,60 +278,68 @@ impl LanguageModelRegistry { } pub fn set_default_model(&mut self, model: Option, cx: &mut Context) { - match (self.default_model(), model.as_ref()) { + match (self.default_model.as_ref(), model.as_ref()) { (Some(old), Some(new)) if old.is_same_as(new) => {} (None, None) => {} _ => cx.emit(Event::DefaultModelChanged), } + self.default_fast_model = maybe!({ + let provider = &model.as_ref()?.provider; + let fast_model = provider.default_fast_model(cx)?; + Some(ConfiguredModel { + provider: provider.clone(), + model: fast_model, + }) + }); self.default_model = model; } - pub fn set_environment_fallback_model( + pub fn set_inline_assistant_model( &mut self, model: Option, cx: &mut Context, ) { - if self.default_model.is_none() { - match (self.environment_fallback_model.as_ref(), model.as_ref()) { - (Some(old), Some(new)) if old.is_same_as(new) => {} - (None, None) => {} - _ => cx.emit(Event::DefaultModelChanged), - } + match (self.inline_assistant_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::InlineAssistantModelChanged), } - self.environment_fallback_model = model; - } - - pub fn set_inline_assistant_model(&mut self, model: Option) { self.inline_assistant_model = model; } - pub fn set_commit_message_model(&mut self, model: Option) { + pub fn set_commit_message_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + match (self.commit_message_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::CommitMessageModelChanged), + } self.commit_message_model = model; } - pub fn set_thread_summary_model(&mut self, model: Option) { + pub fn set_thread_summary_model( + &mut self, + model: Option, + cx: &mut Context, + ) { + match (self.thread_summary_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::ThreadSummaryModelChanged), + } self.thread_summary_model = model; } - #[track_caller] pub fn default_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; } - self.default_model - .clone() - .or_else(|| self.environment_fallback_model.clone()) - } - - pub fn default_fast_model(&self, cx: &App) -> Option { - let provider = self.default_model()?.provider; - let fast_model = provider.default_fast_model(cx)?; - Some(ConfiguredModel { - provider, - model: fast_model, - }) + self.default_model.clone() } pub fn inline_assistant_model(&self) -> Option { @@ -343,7 +353,7 @@ impl LanguageModelRegistry { .or_else(|| self.default_model.clone()) } - pub fn commit_message_model(&self, cx: &App) -> Option { + pub fn commit_message_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -351,11 +361,11 @@ impl LanguageModelRegistry { self.commit_message_model .clone() - .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_fast_model.clone()) .or_else(|| self.default_model.clone()) } - pub fn thread_summary_model(&self, cx: &App) -> Option { + pub fn thread_summary_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { return None; @@ -363,7 +373,7 @@ impl LanguageModelRegistry { self.thread_summary_model .clone() - .or_else(|| self.default_fast_model(cx)) + .or_else(|| self.default_fast_model.clone()) .or_else(|| self.default_model.clone()) } @@ -400,34 +410,4 @@ mod tests { let providers = registry.read(cx).providers(); assert!(providers.is_empty()); } - - #[gpui::test] - async fn test_configure_environment_fallback_model(cx: &mut gpui::TestAppContext) { - let registry = cx.new(|_| LanguageModelRegistry::default()); - - let provider = FakeLanguageModelProvider::default(); - registry.update(cx, |registry, cx| { - registry.register_provider(provider.clone(), cx); - }); - - cx.update(|cx| provider.authenticate(cx)).await.unwrap(); - - registry.update(cx, |registry, cx| { - let provider = registry.provider(&provider.id()).unwrap(); - - registry.set_environment_fallback_model( - Some(ConfiguredModel { - provider: provider.clone(), - model: provider.default_model(cx).unwrap(), - }), - cx, - ); - - let default_model = registry.default_model().unwrap(); - let fallback_model = registry.environment_fallback_model.clone().unwrap(); - - assert_eq!(default_model.model.id(), fallback_model.model.id()); - assert_eq!(default_model.provider.id(), fallback_model.provider.id()); - }); - } } diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index cd41478668..b5bfb870f6 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -44,7 +44,6 @@ ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true -project.workspace = true release_channel.workspace = true schemars.workspace = true serde.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index beed306e74..738b72b0c9 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -3,12 +3,8 @@ use std::sync::Arc; use ::settings::{Settings, SettingsStore}; use client::{Client, UserStore}; use collections::HashSet; -use futures::future; -use gpui::{App, AppContext as _, Context, Entity}; -use language_model::{ - AuthenticateError, ConfiguredModel, LanguageModelProviderId, LanguageModelRegistry, -}; -use project::DisableAiSettings; +use gpui::{App, Context, Entity}; +use language_model::{LanguageModelProviderId, LanguageModelRegistry}; use provider::deepseek::DeepSeekLanguageModelProvider; pub mod provider; @@ -17,7 +13,7 @@ pub mod ui; use crate::provider::anthropic::AnthropicLanguageModelProvider; use crate::provider::bedrock::BedrockLanguageModelProvider; -use crate::provider::cloud::{self, CloudLanguageModelProvider}; +use crate::provider::cloud::CloudLanguageModelProvider; use crate::provider::copilot_chat::CopilotChatLanguageModelProvider; use crate::provider::google::GoogleLanguageModelProvider; use crate::provider::lmstudio::LmStudioLanguageModelProvider; @@ -52,13 +48,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { cx, ); }); - - let mut already_authenticated = false; - if !DisableAiSettings::get_global(cx).disable_ai { - authenticate_all_providers(registry.clone(), cx); - already_authenticated = true; - } - cx.observe_global::(move |cx| { let openai_compatible_providers_new = AllLanguageModelSettings::get_global(cx) .openai_compatible @@ -76,12 +65,6 @@ pub fn init(user_store: Entity, client: Arc, cx: &mut App) { ); }); openai_compatible_providers = openai_compatible_providers_new; - already_authenticated = false; - } - - if !DisableAiSettings::get_global(cx).disable_ai && !already_authenticated { - authenticate_all_providers(registry.clone(), cx); - already_authenticated = true; } }) .detach(); @@ -168,83 +151,3 @@ fn register_language_model_providers( registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx); registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx); } - -/// Authenticates all providers in the [`LanguageModelRegistry`]. -/// -/// We do this so that we can populate the language selector with all of the -/// models from the configured providers. -/// -/// This function won't do anything if AI is disabled. -fn authenticate_all_providers(registry: Entity, cx: &mut App) { - let providers_to_authenticate = registry - .read(cx) - .providers() - .iter() - .map(|provider| (provider.id(), provider.name(), provider.authenticate(cx))) - .collect::>(); - - let mut tasks = Vec::with_capacity(providers_to_authenticate.len()); - - for (provider_id, provider_name, authenticate_task) in providers_to_authenticate { - tasks.push(cx.background_spawn(async move { - if let Err(err) = authenticate_task.await { - if matches!(err, AuthenticateError::CredentialsNotFound) { - // Since we're authenticating these providers in the - // background for the purposes of populating the - // language selector, we don't care about providers - // where the credentials are not found. - } else { - // Some providers have noisy failure states that we - // don't want to spam the logs with every time the - // language model selector is initialized. - // - // Ideally these should have more clear failure modes - // that we know are safe to ignore here, like what we do - // with `CredentialsNotFound` above. - match provider_id.0.as_ref() { - "lmstudio" | "ollama" => { - // LM Studio and Ollama both make fetch requests to the local APIs to determine if they are "authenticated". - // - // These fail noisily, so we don't log them. - } - "copilot_chat" => { - // Copilot Chat returns an error if Copilot is not enabled, so we don't log those errors. - } - _ => { - log::error!( - "Failed to authenticate provider: {}: {err}", - provider_name.0 - ); - } - } - } - } - })); - } - - let all_authenticated_future = future::join_all(tasks); - - cx.spawn(async move |cx| { - all_authenticated_future.await; - - registry - .update(cx, |registry, cx| { - let cloud_provider = registry.provider(&cloud::PROVIDER_ID); - let fallback_model = cloud_provider - .iter() - .chain(registry.providers().iter()) - .find(|provider| provider.is_authenticated(cx)) - .and_then(|provider| { - Some(ConfiguredModel { - provider: provider.clone(), - model: provider - .default_model(cx) - .or_else(|| provider.recommended_models(cx).first().cloned())?, - }) - }); - registry.set_environment_fallback_model(fallback_model, cx); - }) - .ok(); - }) - .detach(); -} diff --git a/crates/language_models/src/provider/cloud.rs b/crates/language_models/src/provider/cloud.rs index fb6e2fb1e4..b473d06357 100644 --- a/crates/language_models/src/provider/cloud.rs +++ b/crates/language_models/src/provider/cloud.rs @@ -44,8 +44,8 @@ use crate::provider::anthropic::{AnthropicEventMapper, count_anthropic_tokens, i use crate::provider::google::{GoogleEventMapper, into_google}; use crate::provider::open_ai::{OpenAiEventMapper, count_open_ai_tokens, into_open_ai}; -pub const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; -pub const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; +const PROVIDER_ID: LanguageModelProviderId = language_model::ZED_CLOUD_PROVIDER_ID; +const PROVIDER_NAME: LanguageModelProviderName = language_model::ZED_CLOUD_PROVIDER_NAME; #[derive(Default, Clone, Debug, PartialEq)] pub struct ZedDotDevSettings { @@ -146,7 +146,7 @@ impl State { default_fast_model: None, recommended_models: Vec::new(), _fetch_models_task: cx.spawn(async move |this, cx| { - maybe!(async { + maybe!(async move { let (client, llm_api_token) = this .read_with(cx, |this, _cx| (client.clone(), this.llm_api_token.clone()))?; From 65c6c709fdf606cbba9aea8aa8c3818702b110d7 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 26 Aug 2025 12:55:40 -0300 Subject: [PATCH 49/54] thread view: Refine tool call UI (#36937) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- crates/agent_ui/src/acp/entry_view_state.rs | 58 ++- crates/agent_ui/src/acp/thread_view.rs | 467 +++++++++++--------- crates/markdown/src/markdown.rs | 2 +- 3 files changed, 315 insertions(+), 212 deletions(-) diff --git a/crates/agent_ui/src/acp/entry_view_state.rs b/crates/agent_ui/src/acp/entry_view_state.rs index 0e4080d689..becf6953fd 100644 --- a/crates/agent_ui/src/acp/entry_view_state.rs +++ b/crates/agent_ui/src/acp/entry_view_state.rs @@ -6,7 +6,7 @@ use agent2::HistoryStore; use collections::HashMap; use editor::{Editor, EditorMode, MinimapVisibility}; use gpui::{ - AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, + AnyEntity, App, AppContext as _, Entity, EntityId, EventEmitter, Focusable, ScrollHandle, TextStyleRefinement, WeakEntity, Window, }; use language::language_settings::SoftWrap; @@ -154,10 +154,22 @@ impl EntryViewState { }); } } - AgentThreadEntry::AssistantMessage(_) => { - if index == self.entries.len() { - self.entries.push(Entry::empty()) - } + AgentThreadEntry::AssistantMessage(message) => { + let entry = if let Some(Entry::AssistantMessage(entry)) = + self.entries.get_mut(index) + { + entry + } else { + self.set_entry( + index, + Entry::AssistantMessage(AssistantMessageEntry::default()), + ); + let Some(Entry::AssistantMessage(entry)) = self.entries.get_mut(index) else { + unreachable!() + }; + entry + }; + entry.sync(message); } }; } @@ -177,7 +189,7 @@ impl EntryViewState { pub fn settings_changed(&mut self, cx: &mut App) { for entry in self.entries.iter() { match entry { - Entry::UserMessage { .. } => {} + Entry::UserMessage { .. } | Entry::AssistantMessage { .. } => {} Entry::Content(response_views) => { for view in response_views.values() { if let Ok(diff_editor) = view.clone().downcast::() { @@ -208,9 +220,29 @@ pub enum ViewEvent { MessageEditorEvent(Entity, MessageEditorEvent), } +#[derive(Default, Debug)] +pub struct AssistantMessageEntry { + scroll_handles_by_chunk_index: HashMap, +} + +impl AssistantMessageEntry { + pub fn scroll_handle_for_chunk(&self, ix: usize) -> Option { + self.scroll_handles_by_chunk_index.get(&ix).cloned() + } + + pub fn sync(&mut self, message: &acp_thread::AssistantMessage) { + if let Some(acp_thread::AssistantMessageChunk::Thought { .. }) = message.chunks.last() { + let ix = message.chunks.len() - 1; + let handle = self.scroll_handles_by_chunk_index.entry(ix).or_default(); + handle.scroll_to_bottom(); + } + } +} + #[derive(Debug)] pub enum Entry { UserMessage(Entity), + AssistantMessage(AssistantMessageEntry), Content(HashMap), } @@ -218,7 +250,7 @@ impl Entry { pub fn message_editor(&self) -> Option<&Entity> { match self { Self::UserMessage(editor) => Some(editor), - Entry::Content(_) => None, + Self::AssistantMessage(_) | Self::Content(_) => None, } } @@ -239,6 +271,16 @@ impl Entry { .map(|entity| entity.downcast::().unwrap()) } + pub fn scroll_handle_for_assistant_message_chunk( + &self, + chunk_ix: usize, + ) -> Option { + match self { + Self::AssistantMessage(message) => message.scroll_handle_for_chunk(chunk_ix), + Self::UserMessage(_) | Self::Content(_) => None, + } + } + fn content_map(&self) -> Option<&HashMap> { match self { Self::Content(map) => Some(map), @@ -254,7 +296,7 @@ impl Entry { pub fn has_content(&self) -> bool { match self { Self::Content(map) => !map.is_empty(), - Self::UserMessage(_) => false, + Self::UserMessage(_) | Self::AssistantMessage(_) => false, } } } diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index 6d8f8fb82e..f3b1e6ce3b 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -20,11 +20,11 @@ use file_icons::FileIcons; use fs::Fs; use gpui::{ Action, Animation, AnimationExt, AnyView, App, BorderStyle, ClickEvent, ClipboardItem, - EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, ListOffset, - ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, Subscription, - Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, Window, - WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, point, - prelude::*, pulsating_between, + CursorStyle, EdgesRefinement, ElementId, Empty, Entity, FocusHandle, Focusable, Hsla, Length, + ListOffset, ListState, MouseButton, PlatformDisplay, SharedString, Stateful, StyleRefinement, + Subscription, Task, TextStyle, TextStyleRefinement, Transformation, UnderlineStyle, WeakEntity, + Window, WindowHandle, div, ease_in_out, linear_color_stop, linear_gradient, list, percentage, + point, prelude::*, pulsating_between, }; use language::Buffer; @@ -66,7 +66,6 @@ use crate::{ KeepAll, OpenAgentDiff, OpenHistory, RejectAll, ToggleBurnMode, ToggleProfileSelector, }; -const RESPONSE_PADDING_X: Pixels = px(19.); pub const MIN_EDITOR_LINES: usize = 4; pub const MAX_EDITOR_LINES: usize = 8; @@ -1334,6 +1333,10 @@ impl AcpThreadView { window: &mut Window, cx: &Context, ) -> AnyElement { + let is_generating = self + .thread() + .is_some_and(|thread| thread.read(cx).status() != ThreadStatus::Idle); + let primary = match &entry { AgentThreadEntry::UserMessage(message) => { let Some(editor) = self @@ -1493,6 +1496,20 @@ impl AcpThreadView { .into_any() } AgentThreadEntry::AssistantMessage(AssistantMessage { chunks }) => { + let is_last = entry_ix + 1 == total_entries; + let pending_thinking_chunk_ix = if is_generating && is_last { + chunks + .iter() + .enumerate() + .next_back() + .filter(|(_, segment)| { + matches!(segment, AssistantMessageChunk::Thought { .. }) + }) + .map(|(index, _)| index) + } else { + None + }; + let style = default_markdown_style(false, false, window, cx); let message_body = v_flex() .w_full() @@ -1511,6 +1528,7 @@ impl AcpThreadView { entry_ix, chunk_ix, md.clone(), + Some(chunk_ix) == pending_thinking_chunk_ix, window, cx, ) @@ -1524,7 +1542,7 @@ impl AcpThreadView { v_flex() .px_5() .py_1() - .when(entry_ix + 1 == total_entries, |this| this.pb_4()) + .when(is_last, |this| this.pb_4()) .w_full() .text_ui(cx) .child(message_body) @@ -1533,7 +1551,7 @@ impl AcpThreadView { AgentThreadEntry::ToolCall(tool_call) => { let has_terminals = tool_call.terminals().next().is_some(); - div().w_full().py_1().px_5().map(|this| { + div().w_full().map(|this| { if has_terminals { this.children(tool_call.terminals().map(|terminal| { self.render_terminal_tool_call( @@ -1609,64 +1627,90 @@ impl AcpThreadView { entry_ix: usize, chunk_ix: usize, chunk: Entity, + pending: bool, window: &Window, cx: &Context, ) -> AnyElement { let header_id = SharedString::from(format!("thinking-block-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-card-header"); + let key = (entry_ix, chunk_ix); + let is_open = self.expanded_thinking_blocks.contains(&key); + let editor_bg = cx.theme().colors().editor_background; + let gradient_overlay = div() + .rounded_b_lg() + .h_full() + .absolute() + .w_full() + .bottom_0() + .left_0() + .bg(linear_gradient( + 180., + linear_color_stop(editor_bg, 1.), + linear_color_stop(editor_bg.opacity(0.2), 0.), + )); + + let scroll_handle = self + .entry_view_state + .read(cx) + .entry(entry_ix) + .and_then(|entry| entry.scroll_handle_for_assistant_message_chunk(chunk_ix)); v_flex() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) .child( h_flex() .id(header_id) .group(&card_header_id) .relative() .w_full() - .gap_1p5() + .py_0p5() + .px_1p5() + .rounded_t_md() + .bg(self.tool_card_header_bg(cx)) + .justify_between() + .border_b_1() + .border_color(self.tool_card_border_color(cx)) .child( h_flex() - .size_4() - .justify_center() + .h(window.line_height()) + .gap_1p5() .child( - div() - .group_hover(&card_header_id, |s| s.invisible().w_0()) - .child( - Icon::new(IconName::ToolThink) - .size(IconSize::Small) - .color(Color::Muted), - ), + Icon::new(IconName::ToolThink) + .size(IconSize::Small) + .color(Color::Muted), ) .child( - h_flex() - .absolute() - .inset_0() - .invisible() - .justify_center() - .group_hover(&card_header_id, |s| s.visible()) - .child( - Disclosure::new(("expand", entry_ix), is_open) - .opened_icon(IconName::ChevronUp) - .closed_icon(IconName::ChevronRight) - .on_click(cx.listener({ - move |this, _event, _window, cx| { - if is_open { - this.expanded_thinking_blocks.remove(&key); - } else { - this.expanded_thinking_blocks.insert(key); - } - cx.notify(); - } - })), - ), + div() + .text_size(self.tool_name_font_size()) + .text_color(cx.theme().colors().text_muted) + .map(|this| { + if pending { + this.child("Thinking") + } else { + this.child("Thought Process") + } + }), ), ) .child( - div() - .text_size(self.tool_name_font_size()) - .text_color(cx.theme().colors().text_muted) - .child("Thinking"), + Disclosure::new(("expand", entry_ix), is_open) + .opened_icon(IconName::ChevronUp) + .closed_icon(IconName::ChevronDown) + .visible_on_hover(&card_header_id) + .on_click(cx.listener({ + move |this, _event, _window, cx| { + if is_open { + this.expanded_thinking_blocks.remove(&key); + } else { + this.expanded_thinking_blocks.insert(key); + } + cx.notify(); + } + })), ) .on_click(cx.listener({ move |this, _event, _window, cx| { @@ -1679,22 +1723,28 @@ impl AcpThreadView { } })), ) - .when(is_open, |this| { - this.child( - div() - .relative() - .mt_1p5() - .ml(rems(0.4)) - .pl_4() - .border_l_1() - .border_color(self.tool_card_border_color(cx)) - .text_ui_sm(cx) - .child(self.render_markdown( - chunk, - default_markdown_style(false, false, window, cx), - )), - ) - }) + .child( + div() + .relative() + .bg(editor_bg) + .rounded_b_lg() + .child( + div() + .id(("thinking-content", chunk_ix)) + .when_some(scroll_handle, |this, scroll_handle| { + this.track_scroll(&scroll_handle) + }) + .p_2() + .when(!is_open, |this| this.max_h_20()) + .text_ui_sm(cx) + .overflow_hidden() + .child(self.render_markdown( + chunk, + default_markdown_style(false, false, window, cx), + )), + ) + .when(!is_open && pending, |this| this.child(gradient_overlay)), + ) .into_any_element() } @@ -1705,7 +1755,6 @@ impl AcpThreadView { window: &Window, cx: &Context, ) -> Div { - let header_id = SharedString::from(format!("outer-tool-call-header-{}", entry_ix)); let card_header_id = SharedString::from("inner-tool-call-header"); let tool_icon = @@ -1734,11 +1783,7 @@ impl AcpThreadView { _ => false, }; - let failed_tool_call = matches!( - tool_call.status, - ToolCallStatus::Rejected | ToolCallStatus::Canceled | ToolCallStatus::Failed - ); - + let has_location = tool_call.locations.len() == 1; let needs_confirmation = matches!( tool_call.status, ToolCallStatus::WaitingForConfirmation { .. } @@ -1751,23 +1796,31 @@ impl AcpThreadView { let is_open = needs_confirmation || self.expanded_tool_calls.contains(&tool_call.id); - let gradient_overlay = |color: Hsla| { + let gradient_overlay = { div() .absolute() .top_0() .right_0() .w_12() .h_full() - .bg(linear_gradient( - 90., - linear_color_stop(color, 1.), - linear_color_stop(color.opacity(0.2), 0.), - )) - }; - let gradient_color = if use_card_layout { - self.tool_card_header_bg(cx) - } else { - cx.theme().colors().panel_background + .map(|this| { + if use_card_layout { + this.bg(linear_gradient( + 90., + linear_color_stop(self.tool_card_header_bg(cx), 1.), + linear_color_stop(self.tool_card_header_bg(cx).opacity(0.2), 0.), + )) + } else { + this.bg(linear_gradient( + 90., + linear_color_stop(cx.theme().colors().panel_background, 1.), + linear_color_stop( + cx.theme().colors().panel_background.opacity(0.2), + 0., + ), + )) + } + }) }; let tool_output_display = if is_open { @@ -1818,41 +1871,58 @@ impl AcpThreadView { }; v_flex() - .when(use_card_layout, |this| { - this.rounded_md() - .border_1() - .border_color(self.tool_card_border_color(cx)) - .bg(cx.theme().colors().editor_background) - .overflow_hidden() + .map(|this| { + if use_card_layout { + this.my_2() + .rounded_md() + .border_1() + .border_color(self.tool_card_border_color(cx)) + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + } else { + this.my_1() + } }) + .map(|this| { + if has_location && !use_card_layout { + this.ml_4() + } else { + this.ml_5() + } + }) + .mr_5() .child( h_flex() - .id(header_id) .group(&card_header_id) .relative() .w_full() - .max_w_full() .gap_1() + .justify_between() .when(use_card_layout, |this| { - this.pl_1p5() - .pr_1() - .py_0p5() + this.p_0p5() .rounded_t_md() - .when(is_open && !failed_tool_call, |this| { + .bg(self.tool_card_header_bg(cx)) + .when(is_open && !failed_or_canceled, |this| { this.border_b_1() .border_color(self.tool_card_border_color(cx)) }) - .bg(self.tool_card_header_bg(cx)) }) .child( h_flex() .relative() .w_full() - .h(window.line_height() - px(2.)) + .h(window.line_height()) .text_size(self.tool_name_font_size()) - .gap_0p5() + .gap_1p5() + .when(has_location || use_card_layout, |this| this.px_1()) + .when(has_location, |this| { + this.cursor(CursorStyle::PointingHand) + .rounded_sm() + .hover(|s| s.bg(cx.theme().colors().element_hover.opacity(0.5))) + }) + .overflow_hidden() .child(tool_icon) - .child(if tool_call.locations.len() == 1 { + .child(if has_location { let name = tool_call.locations[0] .path .file_name() @@ -1863,13 +1933,6 @@ impl AcpThreadView { h_flex() .id(("open-tool-call-location", entry_ix)) .w_full() - .max_w_full() - .px_1p5() - .rounded_sm() - .overflow_x_scroll() - .hover(|label| { - label.bg(cx.theme().colors().element_hover.opacity(0.5)) - }) .map(|this| { if use_card_layout { this.text_color(cx.theme().colors().text) @@ -1879,31 +1942,28 @@ impl AcpThreadView { }) .child(name) .tooltip(Tooltip::text("Jump to File")) - .cursor(gpui::CursorStyle::PointingHand) .on_click(cx.listener(move |this, _, window, cx| { this.open_tool_call_location(entry_ix, 0, window, cx); })) .into_any_element() } else { h_flex() - .relative() .w_full() - .max_w_full() - .ml_1p5() - .overflow_hidden() - .child(h_flex().pr_8().child(self.render_markdown( + .child(self.render_markdown( tool_call.label.clone(), default_markdown_style(false, true, window, cx), - ))) - .child(gradient_overlay(gradient_color)) + )) .into_any() - }), + }) + .when(!has_location, |this| this.child(gradient_overlay)), ) - .child( - h_flex() - .gap_px() - .when(is_collapsible, |this| { - this.child( + .when(is_collapsible || failed_or_canceled, |this| { + this.child( + h_flex() + .px_1() + .gap_px() + .when(is_collapsible, |this| { + this.child( Disclosure::new(("expand", entry_ix), is_open) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) @@ -1920,15 +1980,16 @@ impl AcpThreadView { } })), ) - }) - .when(failed_or_canceled, |this| { - this.child( - Icon::new(IconName::Close) - .color(Color::Error) - .size(IconSize::Small), - ) - }), - ), + }) + .when(failed_or_canceled, |this| { + this.child( + Icon::new(IconName::Close) + .color(Color::Error) + .size(IconSize::Small), + ) + }), + ) + }), ) .children(tool_output_display) } @@ -2214,6 +2275,12 @@ impl AcpThreadView { started_at.elapsed() }; + let header_id = + SharedString::from(format!("terminal-tool-header-{}", terminal.entity_id())); + let header_group = SharedString::from(format!( + "terminal-tool-header-group-{}", + terminal.entity_id() + )); let header_bg = cx .theme() .colors() @@ -2229,10 +2296,7 @@ impl AcpThreadView { let is_expanded = self.expanded_tool_calls.contains(&tool_call.id); let header = h_flex() - .id(SharedString::from(format!( - "terminal-tool-header-{}", - terminal.entity_id() - ))) + .id(header_id) .flex_none() .gap_1() .justify_between() @@ -2296,23 +2360,6 @@ impl AcpThreadView { ), ) }) - .when(tool_failed || command_failed, |header| { - header.child( - div() - .id(("terminal-tool-error-code-indicator", terminal.entity_id())) - .child( - Icon::new(IconName::Close) - .size(IconSize::Small) - .color(Color::Error), - ) - .when_some(output.and_then(|o| o.exit_status), |this, status| { - this.tooltip(Tooltip::text(format!( - "Exited with code {}", - status.code().unwrap_or(-1), - ))) - }), - ) - }) .when(truncated_output, |header| { let tooltip = if let Some(output) = output { if output_line_count + 10 > terminal::MAX_SCROLL_HISTORY_LINES { @@ -2365,6 +2412,7 @@ impl AcpThreadView { ) .opened_icon(IconName::ChevronUp) .closed_icon(IconName::ChevronDown) + .visible_on_hover(&header_group) .on_click(cx.listener({ let id = tool_call.id.clone(); move |this, _event, _window, _cx| { @@ -2373,8 +2421,26 @@ impl AcpThreadView { } else { this.expanded_tool_calls.insert(id.clone()); } - }})), - ); + } + })), + ) + .when(tool_failed || command_failed, |header| { + header.child( + div() + .id(("terminal-tool-error-code-indicator", terminal.entity_id())) + .child( + Icon::new(IconName::Close) + .size(IconSize::Small) + .color(Color::Error), + ) + .when_some(output.and_then(|o| o.exit_status), |this, status| { + this.tooltip(Tooltip::text(format!( + "Exited with code {}", + status.code().unwrap_or(-1), + ))) + }), + ) + }); let terminal_view = self .entry_view_state @@ -2384,7 +2450,8 @@ impl AcpThreadView { let show_output = is_expanded && terminal_view.is_some(); v_flex() - .mb_2() + .my_2() + .mx_5() .border_1() .when(tool_failed || command_failed, |card| card.border_dashed()) .border_color(border_color) @@ -2392,9 +2459,10 @@ impl AcpThreadView { .overflow_hidden() .child( v_flex() + .group(&header_group) .py_1p5() - .pl_2() .pr_1p5() + .pl_2() .gap_0p5() .bg(header_bg) .text_xs() @@ -4153,13 +4221,14 @@ impl AcpThreadView { ) -> impl IntoElement { let is_generating = matches!(thread.read(cx).status(), ThreadStatus::Generating); if is_generating { - return h_flex().id("thread-controls-container").ml_1().child( + return h_flex().id("thread-controls-container").child( div() .py_2() - .px(rems_from_px(22.)) + .px_5() .child(SpinnerLabel::new().size(LabelSize::Small)), ); } + let open_as_markdown = IconButton::new("open-as-markdown", IconName::FileMarkdown) .shape(ui::IconButtonShape::Square) .icon_size(IconSize::Small) @@ -4185,12 +4254,10 @@ impl AcpThreadView { .id("thread-controls-container") .group("thread-controls-container") .w_full() - .mr_1() - .pt_1() - .pb_2() - .px(RESPONSE_PADDING_X) + .py_2() + .px_5() .gap_px() - .opacity(0.4) + .opacity(0.6) .hover(|style| style.opacity(1.)) .flex_wrap() .justify_end(); @@ -4201,56 +4268,50 @@ impl AcpThreadView { .is_some_and(|thread| thread.read(cx).connection().telemetry().is_some()) { let feedback = self.thread_feedback.feedback; - container = container.child( - div().visible_on_hover("thread-controls-container").child( - Label::new( - match feedback { + + container = container + .child( + div().visible_on_hover("thread-controls-container").child( + Label::new(match feedback { Some(ThreadFeedback::Positive) => "Thanks for your feedback!", - Some(ThreadFeedback::Negative) => "We appreciate your feedback and will use it to improve.", - None => "Rating the thread sends all of your current conversation to the Zed team.", - } - ) - .color(Color::Muted) - .size(LabelSize::XSmall) - .truncate(), - ), - ).child( - h_flex() - .child( - IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Positive) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(Tooltip::text("Helpful Response")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - ThreadFeedback::Positive, - window, - cx, - ); - })), - ) - .child( - IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) - .shape(ui::IconButtonShape::Square) - .icon_size(IconSize::Small) - .icon_color(match feedback { - Some(ThreadFeedback::Negative) => Color::Accent, - _ => Color::Ignored, - }) - .tooltip(Tooltip::text("Not Helpful")) - .on_click(cx.listener(move |this, _, window, cx| { - this.handle_feedback_click( - ThreadFeedback::Negative, - window, - cx, - ); - })), - ) - ) + Some(ThreadFeedback::Negative) => { + "We appreciate your feedback and will use it to improve." + } + None => { + "Rating the thread sends all of your current conversation to the Zed team." + } + }) + .color(Color::Muted) + .size(LabelSize::XSmall) + .truncate(), + ), + ) + .child( + IconButton::new("feedback-thumbs-up", IconName::ThumbsUp) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Positive) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Helpful Response")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Positive, window, cx); + })), + ) + .child( + IconButton::new("feedback-thumbs-down", IconName::ThumbsDown) + .shape(ui::IconButtonShape::Square) + .icon_size(IconSize::Small) + .icon_color(match feedback { + Some(ThreadFeedback::Negative) => Color::Accent, + _ => Color::Ignored, + }) + .tooltip(Tooltip::text("Not Helpful")) + .on_click(cx.listener(move |this, _, window, cx| { + this.handle_feedback_click(ThreadFeedback::Negative, window, cx); + })), + ); } container.child(open_as_markdown).child(scroll_to_top) diff --git a/crates/markdown/src/markdown.rs b/crates/markdown/src/markdown.rs index f16da45d79..1f607a033a 100644 --- a/crates/markdown/src/markdown.rs +++ b/crates/markdown/src/markdown.rs @@ -1323,7 +1323,7 @@ fn render_copy_code_block_button( .icon_size(IconSize::Small) .style(ButtonStyle::Filled) .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Copy Code")) + .tooltip(Tooltip::text("Copy")) .on_click({ let markdown = markdown; move |_event, _window, cx| { From 0e575b280997b346e58497725fa0609ac4f6f47c Mon Sep 17 00:00:00 2001 From: Adam Mulvany Date: Wed, 27 Aug 2025 02:38:53 +1000 Subject: [PATCH 50/54] helix: Fix `buffer search: deploy` reset to normal mode (#36917) ## Fix: Preserve Helix mode when using search ### Problem When using `buffer search: deploy` in Helix mode, pressing Enter to dismiss the search incorrectly returned to Vim NORMAL mode instead of Helix NORMAL mode. ### Root Cause The `search_deploy` function was resetting the entire `SearchState` to default values when buffer search: deploy was activated. Since the default `Mode` is `Normal`, this caused `prior_mode` to be set to Vim's Normal mode regardless of the actual mode before search. ### Solution Modified `search_deploy` to preserve the current mode when resetting search state: - Store the current mode before resetting - Reset search state to default - Restore the saved mode to `prior_mode` This ensures the editor returns to the correct mode (Helix NORMAL or Vim NORMAL) after dismissing buffer search. ### Settings I was able to reproduce and then test the fix was successful with the following config and have also tested with vim: default_mode commented out to ensure that's not influencing the mode selection flow: ``` "helix_mode": true, "vim_mode": true, "vim": { "default_mode": "helix_normal" }, ``` This is on Kubuntu 24.04. The following test combinations pass locally: - `cargo test -p search` - `cargo test -p vim` - `cargo test -p editor` - `cargo test -p workspace` - `cargo test -p gpui -- vim` - `cargo test -p gpui -- helix` Release Notes: - Fixed Helix mode switching to Vim normal mode after using `buffer search: deploy` to search Closes #36872 --- crates/vim/src/normal/search.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 4fbeec7236..dba003ec5f 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -203,7 +203,10 @@ impl Vim { // hook into the existing to clear out any vim search state on cmd+f or edit -> find. fn search_deploy(&mut self, _: &buffer_search::Deploy, _: &mut Window, cx: &mut Context) { + // Preserve the current mode when resetting search state + let current_mode = self.mode; self.search = Default::default(); + self.search.prior_mode = current_mode; cx.propagate(); } From b1b60bb7fe64f6b1ee6c1758f2792a68822bcc8a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 26 Aug 2025 10:54:39 -0700 Subject: [PATCH 51/54] Work around duplicate ssh projects in workspace migration (#36946) Fixes another case where the sqlite migration could fail, reported by @SomeoneToIgnore. Release Notes: - N/A --- crates/workspace/src/persistence.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/workspace/src/persistence.rs b/crates/workspace/src/persistence.rs index 12e719cfd9..c4ba93bcec 100644 --- a/crates/workspace/src/persistence.rs +++ b/crates/workspace/src/persistence.rs @@ -599,6 +599,13 @@ impl Domain for WorkspaceDb { ssh_projects ON workspaces.ssh_project_id = ssh_projects.id; + DELETE FROM workspaces_2 + WHERE workspace_id NOT IN ( + SELECT MAX(workspace_id) + FROM workspaces_2 + GROUP BY ssh_connection_id, paths + ); + DROP TABLE ssh_projects; DROP TABLE workspaces; ALTER TABLE workspaces_2 RENAME TO workspaces; From fff0ecead17c601721c35fcacfbb4804ad22a956 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Wed, 27 Aug 2025 03:24:50 +0800 Subject: [PATCH 52/54] windows: Fix keystroke & keymap (#36572) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #36300 This PR follows Windows conventions by introducing `KeybindingKeystroke`, so shortcuts now show up as `ctrl-shift-4` instead of `ctrl-$`. It also fixes issues with keyboard layouts: when `use_key_equivalents` is set to true, keys are remapped based on their virtual key codes. For example, `ctrl-\` on a standard English layout will be mapped to `ctrl-ё` on a Russian layout. Release Notes: - N/A --------- Co-authored-by: Kate --- assets/keymaps/default-windows.json | 1260 ++++++++++++++ crates/docs_preprocessor/src/main.rs | 5 + crates/editor/src/editor.rs | 26 +- crates/editor/src/element.rs | 10 +- crates/gpui/src/app.rs | 17 +- crates/gpui/src/keymap.rs | 7 +- crates/gpui/src/keymap/binding.rs | 49 +- crates/gpui/src/platform.rs | 6 +- crates/gpui/src/platform/keyboard.rs | 34 + crates/gpui/src/platform/keystroke.rs | 252 ++- crates/gpui/src/platform/linux/platform.rs | 8 +- crates/gpui/src/platform/mac/keyboard.rs | 1453 ++++++++++++++++- crates/gpui/src/platform/mac/platform.rs | 28 +- crates/gpui/src/platform/test/platform.rs | 11 +- crates/gpui/src/platform/windows/keyboard.rs | 244 ++- crates/gpui/src/platform/windows/platform.rs | 4 + crates/language_tools/src/key_context_view.rs | 4 +- crates/settings/src/key_equivalents.rs | 1424 ---------------- crates/settings/src/keymap_file.rs | 66 +- crates/settings/src/settings.rs | 7 +- crates/settings_ui/src/keybindings.rs | 101 +- .../src/ui_components/keystroke_input.rs | 74 +- crates/ui/src/components/keybinding.rs | 131 +- crates/zed/src/zed.rs | 10 +- .../zed/src/zed/quick_action_bar/preview.rs | 5 +- 25 files changed, 3515 insertions(+), 1721 deletions(-) create mode 100644 assets/keymaps/default-windows.json delete mode 100644 crates/settings/src/key_equivalents.rs diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json new file mode 100644 index 0000000000..c7a6c3149c --- /dev/null +++ b/assets/keymaps/default-windows.json @@ -0,0 +1,1260 @@ +[ + // Standard Windows bindings + { + "use_key_equivalents": true, + "bindings": { + "home": "menu::SelectFirst", + "shift-pageup": "menu::SelectFirst", + "pageup": "menu::SelectFirst", + "end": "menu::SelectLast", + "shift-pagedown": "menu::SelectLast", + "pagedown": "menu::SelectLast", + "ctrl-n": "menu::SelectNext", + "tab": "menu::SelectNext", + "down": "menu::SelectNext", + "ctrl-p": "menu::SelectPrevious", + "shift-tab": "menu::SelectPrevious", + "up": "menu::SelectPrevious", + "enter": "menu::Confirm", + "ctrl-enter": "menu::SecondaryConfirm", + "ctrl-escape": "menu::Cancel", + "ctrl-c": "menu::Cancel", + "escape": "menu::Cancel", + "shift-alt-enter": "menu::Restart", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }], + "ctrl-alt-enter": ["picker::ConfirmInput", { "secondary": true }], + "ctrl-shift-w": "workspace::CloseWindow", + "shift-escape": "workspace::ToggleZoom", + "open": "workspace::Open", + "ctrl-o": "workspace::Open", + "ctrl-=": ["zed::IncreaseBufferFontSize", { "persist": false }], + "ctrl-shift-=": ["zed::IncreaseBufferFontSize", { "persist": false }], + "ctrl--": ["zed::DecreaseBufferFontSize", { "persist": false }], + "ctrl-0": ["zed::ResetBufferFontSize", { "persist": false }], + "ctrl-,": "zed::OpenSettings", + "ctrl-q": "zed::Quit", + "f4": "debugger::Start", + "shift-f5": "debugger::Stop", + "ctrl-shift-f5": "debugger::RerunSession", + "f6": "debugger::Pause", + "f7": "debugger::StepOver", + "ctrl-f11": "debugger::StepInto", + "shift-f11": "debugger::StepOut", + "f11": "zed::ToggleFullScreen", + "ctrl-shift-i": "edit_prediction::ToggleMenu", + "shift-alt-l": "lsp_tool::ToggleMenu" + } + }, + { + "context": "Picker || menu", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } + }, + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "editor::Cancel", + "shift-backspace": "editor::Backspace", + "backspace": "editor::Backspace", + "delete": "editor::Delete", + "tab": "editor::Tab", + "shift-tab": "editor::Backtab", + "ctrl-k": "editor::CutToEndOfLine", + "ctrl-k ctrl-q": "editor::Rewrap", + "ctrl-k q": "editor::Rewrap", + "ctrl-backspace": "editor::DeleteToPreviousWordStart", + "ctrl-delete": "editor::DeleteToNextWordEnd", + "cut": "editor::Cut", + "shift-delete": "editor::Cut", + "ctrl-x": "editor::Cut", + "copy": "editor::Copy", + "ctrl-insert": "editor::Copy", + "ctrl-c": "editor::Copy", + "paste": "editor::Paste", + "shift-insert": "editor::Paste", + "ctrl-v": "editor::Paste", + "undo": "editor::Undo", + "ctrl-z": "editor::Undo", + "redo": "editor::Redo", + "ctrl-y": "editor::Redo", + "ctrl-shift-z": "editor::Redo", + "up": "editor::MoveUp", + "ctrl-up": "editor::LineUp", + "ctrl-down": "editor::LineDown", + "pageup": "editor::MovePageUp", + "alt-pageup": "editor::PageUp", + "shift-pageup": "editor::SelectPageUp", + "home": ["editor::MoveToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }], + "down": "editor::MoveDown", + "pagedown": "editor::MovePageDown", + "alt-pagedown": "editor::PageDown", + "shift-pagedown": "editor::SelectPageDown", + "end": ["editor::MoveToEndOfLine", { "stop_at_soft_wraps": true }], + "left": "editor::MoveLeft", + "right": "editor::MoveRight", + "ctrl-left": "editor::MoveToPreviousWordStart", + "ctrl-right": "editor::MoveToNextWordEnd", + "ctrl-home": "editor::MoveToBeginning", + "ctrl-end": "editor::MoveToEnd", + "shift-up": "editor::SelectUp", + "shift-down": "editor::SelectDown", + "shift-left": "editor::SelectLeft", + "shift-right": "editor::SelectRight", + "ctrl-shift-left": "editor::SelectToPreviousWordStart", + "ctrl-shift-right": "editor::SelectToNextWordEnd", + "ctrl-shift-home": "editor::SelectToBeginning", + "ctrl-shift-end": "editor::SelectToEnd", + "ctrl-a": "editor::SelectAll", + "ctrl-l": "editor::SelectLine", + "shift-alt-f": "editor::Format", + "shift-alt-o": "editor::OrganizeImports", + "shift-home": ["editor::SelectToBeginningOfLine", { "stop_at_soft_wraps": true, "stop_at_indent": true }], + "shift-end": ["editor::SelectToEndOfLine", { "stop_at_soft_wraps": true }], + "ctrl-alt-space": "editor::ShowCharacterPalette", + "ctrl-;": "editor::ToggleLineNumbers", + "ctrl-'": "editor::ToggleSelectedDiffHunks", + "ctrl-\"": "editor::ExpandAllDiffHunks", + "ctrl-i": "editor::ShowSignatureHelp", + "alt-g b": "git::Blame", + "alt-g m": "git::OpenModifiedFiles", + "menu": "editor::OpenContextMenu", + "shift-f10": "editor::OpenContextMenu", + "ctrl-shift-e": "editor::ToggleEditPrediction", + "f9": "editor::ToggleBreakpoint", + "shift-f9": "editor::EditLogBreakpoint" + } + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "shift-enter": "editor::Newline", + "enter": "editor::Newline", + "ctrl-enter": "editor::NewlineAbove", + "ctrl-shift-enter": "editor::NewlineBelow", + "ctrl-k ctrl-z": "editor::ToggleSoftWrap", + "ctrl-k z": "editor::ToggleSoftWrap", + "find": "buffer_search::Deploy", + "ctrl-f": "buffer_search::Deploy", + "ctrl-h": "buffer_search::DeployReplace", + "ctrl-shift-.": "assistant::QuoteSelection", + "ctrl-shift-,": "assistant::InsertIntoEditor", + "shift-alt-e": "editor::SelectEnclosingSymbol", + "ctrl-shift-backspace": "editor::GoToPreviousChange", + "ctrl-shift-alt-backspace": "editor::GoToNextChange", + "alt-enter": "editor::OpenSelectionsInMultibuffer" + } + }, + { + "context": "Editor && mode == full && edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-]": "editor::NextEditPrediction", + "alt-[": "editor::PreviousEditPrediction" + } + }, + { + "context": "Editor && !edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-\\": "editor::ShowEditPrediction" + } + }, + { + "context": "Editor && mode == auto_height", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "editor::Newline", + "shift-enter": "editor::Newline", + "ctrl-shift-enter": "editor::NewlineBelow" + } + }, + { + "context": "Markdown", + "use_key_equivalents": true, + "bindings": { + "copy": "markdown::Copy", + "ctrl-c": "markdown::Copy" + } + }, + { + "context": "Editor && jupyter && !ContextEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-enter": "repl::Run", + "ctrl-alt-enter": "repl::RunInPlace" + } + }, + { + "context": "Editor && !agent_diff", + "use_key_equivalents": true, + "bindings": { + "ctrl-k ctrl-r": "git::Restore", + "alt-y": "git::StageAndNext", + "shift-alt-y": "git::UnstageAndNext" + } + }, + { + "context": "Editor && editor_agent_diff", + "use_key_equivalents": true, + "bindings": { + "ctrl-y": "agent::Keep", + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll", + "ctrl-shift-r": "agent::OpenAgentDiff" + } + }, + { + "context": "AgentDiff", + "use_key_equivalents": true, + "bindings": { + "ctrl-y": "agent::Keep", + "ctrl-n": "agent::Reject", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "ContextEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "assistant::Assist", + "ctrl-s": "workspace::Save", + "save": "workspace::Save", + "ctrl-shift-,": "assistant::InsertIntoEditor", + "shift-enter": "assistant::Split", + "ctrl-r": "assistant::CycleMessageRole", + "enter": "assistant::ConfirmCommand", + "alt-enter": "editor::Newline", + "ctrl-k c": "assistant::CopyCode", + "ctrl-g": "search::SelectNextMatch", + "ctrl-shift-g": "search::SelectPreviousMatch", + "ctrl-k l": "agent::OpenRulesLibrary" + } + }, + { + "context": "AgentPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewThread", + "shift-alt-n": "agent::NewTextThread", + "ctrl-shift-h": "agent::OpenHistory", + "shift-alt-c": "agent::OpenSettings", + "shift-alt-p": "agent::OpenRulesLibrary", + "ctrl-i": "agent::ToggleProfileSelector", + "shift-alt-/": "agent::ToggleModelSelector", + "ctrl-shift-a": "agent::ToggleContextPicker", + "ctrl-shift-j": "agent::ToggleNavigationMenu", + "ctrl-shift-i": "agent::ToggleOptionsMenu", + // "ctrl-shift-alt-n": "agent::ToggleNewThreadMenu", + "shift-alt-escape": "agent::ExpandMessageEditor", + "ctrl-shift-.": "assistant::QuoteSelection", + "shift-alt-e": "agent::RemoveAllContext", + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-enter": "agent::ContinueThread", + "super-ctrl-b": "agent::ToggleBurnMode", + "alt-enter": "agent::ContinueWithBurnMode" + } + }, + { + "context": "AgentPanel > NavigationMenu", + "use_key_equivalents": true, + "bindings": { + "shift-backspace": "agent::DeleteRecentlyOpenThread" + } + }, + { + "context": "AgentPanel > Markdown", + "use_key_equivalents": true, + "bindings": { + "copy": "markdown::CopyAsMarkdown", + "ctrl-c": "markdown::CopyAsMarkdown" + } + }, + { + "context": "AgentPanel && prompt_editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewTextThread", + "ctrl-alt-t": "agent::NewThread" + } + }, + { + "context": "AgentPanel && external_agent_thread", + "use_key_equivalents": true, + "bindings": { + "ctrl-n": "agent::NewExternalAgentThread", + "ctrl-alt-t": "agent::NewThread" + } + }, + { + "context": "MessageEditor && !Picker > Editor && !use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "ctrl-enter": "agent::ChatWithFollow", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "MessageEditor && !Picker > Editor && use_modifier_to_send", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "agent::Chat", + "enter": "editor::Newline", + "ctrl-i": "agent::ToggleProfileSelector", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "EditMessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "menu::Confirm", + "alt-enter": "editor::Newline" + } + }, + { + "context": "AgentFeedbackMessageEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "menu::Confirm", + "alt-enter": "editor::Newline" + } + }, + { + "context": "ContextStrip", + "use_key_equivalents": true, + "bindings": { + "up": "agent::FocusUp", + "right": "agent::FocusRight", + "left": "agent::FocusLeft", + "down": "agent::FocusDown", + "backspace": "agent::RemoveFocusedContext", + "enter": "agent::AcceptSuggestedContext" + } + }, + { + "context": "AcpThread > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "agent::Chat", + "ctrl-shift-r": "agent::OpenAgentDiff", + "ctrl-shift-y": "agent::KeepAll", + "ctrl-shift-n": "agent::RejectAll" + } + }, + { + "context": "ThreadHistory", + "use_key_equivalents": true, + "bindings": { + "backspace": "agent::RemoveSelectedThread" + } + }, + { + "context": "PromptLibrary", + "use_key_equivalents": true, + "bindings": { + "new": "rules_library::NewRule", + "ctrl-n": "rules_library::NewRule", + "ctrl-shift-s": "rules_library::ToggleDefaultRule" + } + }, + { + "context": "BufferSearchBar", + "use_key_equivalents": true, + "bindings": { + "escape": "buffer_search::Dismiss", + "tab": "buffer_search::FocusEditor", + "enter": "search::SelectNextMatch", + "shift-enter": "search::SelectPreviousMatch", + "alt-enter": "search::SelectAllMatches", + "find": "search::FocusSearch", + "ctrl-f": "search::FocusSearch", + "ctrl-h": "search::ToggleReplace", + "ctrl-l": "search::ToggleSelection" + } + }, + { + "context": "BufferSearchBar && in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "search::ReplaceNext", + "ctrl-enter": "search::ReplaceAll" + } + }, + { + "context": "BufferSearchBar && !in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "search::PreviousHistoryQuery", + "down": "search::NextHistoryQuery" + } + }, + { + "context": "ProjectSearchBar", + "use_key_equivalents": true, + "bindings": { + "escape": "project_search::ToggleFocus", + "shift-find": "search::FocusSearch", + "ctrl-shift-f": "search::FocusSearch", + "ctrl-shift-h": "search::ToggleReplace", + "alt-r": "search::ToggleRegex" // vscode + } + }, + { + "context": "ProjectSearchBar > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "search::PreviousHistoryQuery", + "down": "search::NextHistoryQuery" + } + }, + { + "context": "ProjectSearchBar && in_replace > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "search::ReplaceNext", + "ctrl-alt-enter": "search::ReplaceAll" + } + }, + { + "context": "ProjectSearchView", + "use_key_equivalents": true, + "bindings": { + "escape": "project_search::ToggleFocus", + "ctrl-shift-h": "search::ToggleReplace", + "alt-r": "search::ToggleRegex" // vscode + } + }, + { + "context": "Pane", + "use_key_equivalents": true, + "bindings": { + "alt-1": ["pane::ActivateItem", 0], + "alt-2": ["pane::ActivateItem", 1], + "alt-3": ["pane::ActivateItem", 2], + "alt-4": ["pane::ActivateItem", 3], + "alt-5": ["pane::ActivateItem", 4], + "alt-6": ["pane::ActivateItem", 5], + "alt-7": ["pane::ActivateItem", 6], + "alt-8": ["pane::ActivateItem", 7], + "alt-9": ["pane::ActivateItem", 8], + "alt-0": "pane::ActivateLastItem", + "ctrl-pageup": "pane::ActivatePreviousItem", + "ctrl-pagedown": "pane::ActivateNextItem", + "ctrl-shift-pageup": "pane::SwapItemLeft", + "ctrl-shift-pagedown": "pane::SwapItemRight", + "ctrl-f4": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-w": ["pane::CloseActiveItem", { "close_pinned": false }], + "ctrl-shift-alt-t": ["pane::CloseOtherItems", { "close_pinned": false }], + "ctrl-shift-alt-w": "workspace::CloseInactiveTabsAndPanes", + "ctrl-k e": ["pane::CloseItemsToTheLeft", { "close_pinned": false }], + "ctrl-k t": ["pane::CloseItemsToTheRight", { "close_pinned": false }], + "ctrl-k u": ["pane::CloseCleanItems", { "close_pinned": false }], + "ctrl-k w": ["pane::CloseAllItems", { "close_pinned": false }], + "ctrl-k ctrl-w": "workspace::CloseAllItemsAndPanes", + "back": "pane::GoBack", + "alt--": "pane::GoBack", + "alt-=": "pane::GoForward", + "forward": "pane::GoForward", + "f3": "search::SelectNextMatch", + "shift-f3": "search::SelectPreviousMatch", + "shift-find": "project_search::ToggleFocus", + "ctrl-shift-f": "project_search::ToggleFocus", + "shift-alt-h": "search::ToggleReplace", + "alt-l": "search::ToggleSelection", + "alt-enter": "search::SelectAllMatches", + "alt-c": "search::ToggleCaseSensitive", + "alt-w": "search::ToggleWholeWord", + "alt-find": "project_search::ToggleFilters", + "alt-f": "project_search::ToggleFilters", + "alt-r": "search::ToggleRegex", + // "ctrl-shift-alt-x": "search::ToggleRegex", + "ctrl-k shift-enter": "pane::TogglePinTab" + } + }, + // Bindings from VS Code + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-[": "editor::Outdent", + "ctrl-]": "editor::Indent", + "ctrl-shift-alt-up": "editor::AddSelectionAbove", // Insert Cursor Above + "ctrl-shift-alt-down": "editor::AddSelectionBelow", // Insert Cursor Below + "ctrl-shift-k": "editor::DeleteLine", + "alt-up": "editor::MoveLineUp", + "alt-down": "editor::MoveLineDown", + "shift-alt-up": "editor::DuplicateLineUp", + "shift-alt-down": "editor::DuplicateLineDown", + "shift-alt-right": "editor::SelectLargerSyntaxNode", // Expand Selection + "shift-alt-left": "editor::SelectSmallerSyntaxNode", // Shrink Selection + "ctrl-shift-l": "editor::SelectAllMatches", // Select all occurrences of current selection + "ctrl-f2": "editor::SelectAllMatches", // Select all occurrences of current word + "ctrl-d": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch / find_under_expand + "ctrl-shift-down": ["editor::SelectNext", { "replace_newest": false }], // editor.action.addSelectionToNextFindMatch + "ctrl-shift-up": ["editor::SelectPrevious", { "replace_newest": false }], // editor.action.addSelectionToPreviousFindMatch + "ctrl-k ctrl-d": ["editor::SelectNext", { "replace_newest": true }], // editor.action.moveSelectionToNextFindMatch / find_under_expand_skip + "ctrl-k ctrl-shift-d": ["editor::SelectPrevious", { "replace_newest": true }], // editor.action.moveSelectionToPreviousFindMatch + "ctrl-k ctrl-i": "editor::Hover", + "ctrl-k ctrl-b": "editor::BlameHover", + "ctrl-/": ["editor::ToggleComments", { "advance_downwards": false }], + "f8": ["editor::GoToDiagnostic", { "severity": { "min": "hint", "max": "error" } }], + "shift-f8": ["editor::GoToPreviousDiagnostic", { "severity": { "min": "hint", "max": "error" } }], + "f2": "editor::Rename", + "f12": "editor::GoToDefinition", + "alt-f12": "editor::GoToDefinitionSplit", + "ctrl-shift-f10": "editor::GoToDefinitionSplit", + "ctrl-f12": "editor::GoToImplementation", + "shift-f12": "editor::GoToTypeDefinition", + "ctrl-alt-f12": "editor::GoToTypeDefinitionSplit", + "shift-alt-f12": "editor::FindAllReferences", + "ctrl-m": "editor::MoveToEnclosingBracket", // from jetbrains + "ctrl-shift-\\": "editor::MoveToEnclosingBracket", + "ctrl-shift-[": "editor::Fold", + "ctrl-shift-]": "editor::UnfoldLines", + "ctrl-k ctrl-l": "editor::ToggleFold", + "ctrl-k ctrl-[": "editor::FoldRecursive", + "ctrl-k ctrl-]": "editor::UnfoldRecursive", + "ctrl-k ctrl-1": ["editor::FoldAtLevel", 1], + "ctrl-k ctrl-2": ["editor::FoldAtLevel", 2], + "ctrl-k ctrl-3": ["editor::FoldAtLevel", 3], + "ctrl-k ctrl-4": ["editor::FoldAtLevel", 4], + "ctrl-k ctrl-5": ["editor::FoldAtLevel", 5], + "ctrl-k ctrl-6": ["editor::FoldAtLevel", 6], + "ctrl-k ctrl-7": ["editor::FoldAtLevel", 7], + "ctrl-k ctrl-8": ["editor::FoldAtLevel", 8], + "ctrl-k ctrl-9": ["editor::FoldAtLevel", 9], + "ctrl-k ctrl-0": "editor::FoldAll", + "ctrl-k ctrl-j": "editor::UnfoldAll", + "ctrl-space": "editor::ShowCompletions", + "ctrl-shift-space": "editor::ShowWordCompletions", + "ctrl-.": "editor::ToggleCodeActions", + "ctrl-k r": "editor::RevealInFileManager", + "ctrl-k p": "editor::CopyPath", + "ctrl-\\": "pane::SplitRight", + "ctrl-shift-alt-c": "editor::DisplayCursorNames", + "alt-.": "editor::GoToHunk", + "alt-,": "editor::GoToPreviousHunk" + } + }, + { + "context": "Editor && extension == md", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "markdown::OpenPreviewToTheSide", + "ctrl-shift-v": "markdown::OpenPreview" + } + }, + { + "context": "Editor && extension == svg", + "use_key_equivalents": true, + "bindings": { + "ctrl-k v": "svg::OpenPreviewToTheSide", + "ctrl-shift-v": "svg::OpenPreview" + } + }, + { + "context": "Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-o": "outline::Toggle", + "ctrl-g": "go_to_line::Toggle" + } + }, + { + "context": "Workspace", + "use_key_equivalents": true, + "bindings": { + "alt-open": ["projects::OpenRecent", { "create_new_window": false }], + // Change the default action on `menu::Confirm` by setting the parameter + // "ctrl-alt-o": ["projects::OpenRecent", { "create_new_window": true }], + "ctrl-r": ["projects::OpenRecent", { "create_new_window": false }], + "shift-alt-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + // Change to open path modal for existing remote connection by setting the parameter + // "ctrl-shift-alt-o": "["projects::OpenRemote", { "from_existing_connection": true }]", + "ctrl-shift-alt-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }], + "shift-alt-b": "branches::OpenRecent", + "shift-alt-enter": "toast::RunAction", + "ctrl-shift-`": "workspace::NewTerminal", + "save": "workspace::Save", + "ctrl-s": "workspace::Save", + "ctrl-k ctrl-shift-s": "workspace::SaveWithoutFormat", + "shift-save": "workspace::SaveAs", + "ctrl-shift-s": "workspace::SaveAs", + "new": "workspace::NewFile", + "ctrl-n": "workspace::NewFile", + "shift-new": "workspace::NewWindow", + "ctrl-shift-n": "workspace::NewWindow", + "ctrl-`": "terminal_panel::ToggleFocus", + "f10": ["app_menu::OpenApplicationMenu", "Zed"], + "alt-1": ["workspace::ActivatePane", 0], + "alt-2": ["workspace::ActivatePane", 1], + "alt-3": ["workspace::ActivatePane", 2], + "alt-4": ["workspace::ActivatePane", 3], + "alt-5": ["workspace::ActivatePane", 4], + "alt-6": ["workspace::ActivatePane", 5], + "alt-7": ["workspace::ActivatePane", 6], + "alt-8": ["workspace::ActivatePane", 7], + "alt-9": ["workspace::ActivatePane", 8], + "ctrl-alt-b": "workspace::ToggleRightDock", + "ctrl-b": "workspace::ToggleLeftDock", + "ctrl-j": "workspace::ToggleBottomDock", + "ctrl-shift-y": "workspace::CloseAllDocks", + "alt-r": "workspace::ResetActiveDockSize", + // For 0px parameter, uses UI font size value. + "shift-alt--": ["workspace::DecreaseActiveDockSize", { "px": 0 }], + "shift-alt-=": ["workspace::IncreaseActiveDockSize", { "px": 0 }], + "shift-alt-0": "workspace::ResetOpenDocksSize", + "ctrl-shift-alt--": ["workspace::DecreaseOpenDocksSize", { "px": 0 }], + "ctrl-shift-alt-=": ["workspace::IncreaseOpenDocksSize", { "px": 0 }], + "shift-find": "pane::DeploySearch", + "ctrl-shift-f": "pane::DeploySearch", + "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], + "ctrl-shift-t": "pane::ReopenClosedItem", + "ctrl-k ctrl-s": "zed::OpenKeymapEditor", + "ctrl-k ctrl-t": "theme_selector::Toggle", + "ctrl-alt-super-p": "settings_profile_selector::Toggle", + "ctrl-t": "project_symbols::Toggle", + "ctrl-p": "file_finder::Toggle", + "ctrl-tab": "tab_switcher::Toggle", + "ctrl-shift-tab": ["tab_switcher::Toggle", { "select_last": true }], + "ctrl-e": "file_finder::Toggle", + "f1": "command_palette::Toggle", + "ctrl-shift-p": "command_palette::Toggle", + "ctrl-shift-m": "diagnostics::Deploy", + "ctrl-shift-e": "project_panel::ToggleFocus", + "ctrl-shift-b": "outline_panel::ToggleFocus", + "ctrl-shift-g": "git_panel::ToggleFocus", + "ctrl-shift-d": "debug_panel::ToggleFocus", + "ctrl-shift-/": "agent::ToggleFocus", + "alt-save": "workspace::SaveAll", + "ctrl-k s": "workspace::SaveAll", + "ctrl-k m": "language_selector::Toggle", + "escape": "workspace::Unfollow", + "ctrl-k ctrl-left": "workspace::ActivatePaneLeft", + "ctrl-k ctrl-right": "workspace::ActivatePaneRight", + "ctrl-k ctrl-up": "workspace::ActivatePaneUp", + "ctrl-k ctrl-down": "workspace::ActivatePaneDown", + "ctrl-k shift-left": "workspace::SwapPaneLeft", + "ctrl-k shift-right": "workspace::SwapPaneRight", + "ctrl-k shift-up": "workspace::SwapPaneUp", + "ctrl-k shift-down": "workspace::SwapPaneDown", + "ctrl-shift-x": "zed::Extensions", + "ctrl-shift-r": "task::Rerun", + "alt-t": "task::Rerun", + "shift-alt-t": "task::Spawn", + "shift-alt-r": ["task::Spawn", { "reveal_target": "center" }], + // also possible to spawn tasks by name: + // "foo-bar": ["task::Spawn", { "task_name": "MyTask", "reveal_target": "dock" }] + // or by tag: + // "foo-bar": ["task::Spawn", { "task_tag": "MyTag" }], + "f5": "debugger::Rerun", + "ctrl-f4": "workspace::CloseActiveDock", + "ctrl-w": "workspace::CloseActiveDock" + } + }, + { + "context": "Workspace && debugger_running", + "use_key_equivalents": true, + "bindings": { + "f5": "zed::NoAction" + } + }, + { + "context": "Workspace && debugger_stopped", + "use_key_equivalents": true, + "bindings": { + "f5": "debugger::Continue" + } + }, + { + "context": "ApplicationMenu", + "use_key_equivalents": true, + "bindings": { + "f10": "menu::Cancel", + "left": "app_menu::ActivateMenuLeft", + "right": "app_menu::ActivateMenuRight" + } + }, + // Bindings from Sublime Text + { + "context": "Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-u": "editor::UndoSelection", + "ctrl-shift-u": "editor::RedoSelection", + "ctrl-shift-j": "editor::JoinLines", + "ctrl-alt-backspace": "editor::DeleteToPreviousSubwordStart", + "shift-alt-h": "editor::DeleteToPreviousSubwordStart", + "ctrl-alt-delete": "editor::DeleteToNextSubwordEnd", + "shift-alt-d": "editor::DeleteToNextSubwordEnd", + "ctrl-alt-left": "editor::MoveToPreviousSubwordStart", + "ctrl-alt-right": "editor::MoveToNextSubwordEnd", + "ctrl-shift-alt-left": "editor::SelectToPreviousSubwordStart", + "ctrl-shift-alt-right": "editor::SelectToNextSubwordEnd" + } + }, + // Bindings from Atom + { + "context": "Pane", + "use_key_equivalents": true, + "bindings": { + "ctrl-k up": "pane::SplitUp", + "ctrl-k down": "pane::SplitDown", + "ctrl-k left": "pane::SplitLeft", + "ctrl-k right": "pane::SplitRight" + } + }, + // Bindings that should be unified with bindings for more general actions + { + "context": "Editor && renaming", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmRename" + } + }, + { + "context": "Editor && showing_completions", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmCompletion", + "shift-enter": "editor::ConfirmCompletionReplace", + "tab": "editor::ComposeCompletion" + } + }, + // Bindings for accepting edit predictions + // + // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is + // because alt-tab may not be available, as it is often used for window switching. + { + "context": "Editor && edit_prediction", + "use_key_equivalents": true, + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + "alt-l": "editor::AcceptEditPrediction", + "tab": "editor::AcceptEditPrediction", + "alt-right": "editor::AcceptPartialEditPrediction" + } + }, + { + "context": "Editor && edit_prediction_conflict", + "use_key_equivalents": true, + "bindings": { + "alt-tab": "editor::AcceptEditPrediction", + "alt-l": "editor::AcceptEditPrediction", + "alt-right": "editor::AcceptPartialEditPrediction" + } + }, + { + "context": "Editor && showing_code_actions", + "use_key_equivalents": true, + "bindings": { + "enter": "editor::ConfirmCodeAction" + } + }, + { + "context": "Editor && (showing_code_actions || showing_completions)", + "use_key_equivalents": true, + "bindings": { + "ctrl-p": "editor::ContextMenuPrevious", + "up": "editor::ContextMenuPrevious", + "ctrl-n": "editor::ContextMenuNext", + "down": "editor::ContextMenuNext", + "pageup": "editor::ContextMenuFirst", + "pagedown": "editor::ContextMenuLast" + } + }, + { + "context": "Editor && showing_signature_help && !showing_completions", + "use_key_equivalents": true, + "bindings": { + "up": "editor::SignatureHelpPrevious", + "down": "editor::SignatureHelpNext" + } + }, + // Custom bindings + { + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-alt-f": "workspace::FollowNextCollaborator", + // Only available in debug builds: opens an element inspector for development. + "shift-alt-i": "dev::ToggleInspector" + } + }, + { + "context": "!Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-c": "collab_panel::ToggleFocus" + } + }, + { + "context": "!ContextEditor > Editor && mode == full", + "use_key_equivalents": true, + "bindings": { + "alt-enter": "editor::OpenExcerpts", + "shift-enter": "editor::ExpandExcerpts", + "ctrl-alt-enter": "editor::OpenExcerptsSplit", + "ctrl-shift-e": "pane::RevealInProjectPanel", + "ctrl-f8": "editor::GoToHunk", + "ctrl-shift-f8": "editor::GoToPreviousHunk", + "ctrl-enter": "assistant::InlineAssist", + "ctrl-shift-;": "editor::ToggleInlayHints" + } + }, + { + "context": "PromptEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-[": "agent::CyclePreviousInlineAssist", + "ctrl-]": "agent::CycleNextInlineAssist", + "shift-alt-e": "agent::RemoveAllContext" + } + }, + { + "context": "Prompt", + "use_key_equivalents": true, + "bindings": { + "left": "menu::SelectPrevious", + "right": "menu::SelectNext", + "h": "menu::SelectPrevious", + "l": "menu::SelectNext" + } + }, + { + "context": "ProjectSearchBar && !in_replace", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "project_search::SearchInNew" + } + }, + { + "context": "OutlinePanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "left": "outline_panel::CollapseSelectedEntry", + "right": "outline_panel::ExpandSelectedEntry", + "alt-copy": "outline_panel::CopyPath", + "shift-alt-c": "outline_panel::CopyPath", + "shift-alt-copy": "workspace::CopyRelativePath", + "ctrl-shift-alt-c": "workspace::CopyRelativePath", + "ctrl-alt-r": "outline_panel::RevealInFileManager", + "space": "outline_panel::OpenSelectedEntry", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "alt-enter": "editor::OpenExcerpts", + "ctrl-alt-enter": "editor::OpenExcerptsSplit" + } + }, + { + "context": "ProjectPanel", + "use_key_equivalents": true, + "bindings": { + "left": "project_panel::CollapseSelectedEntry", + "right": "project_panel::ExpandSelectedEntry", + "new": "project_panel::NewFile", + "ctrl-n": "project_panel::NewFile", + "alt-new": "project_panel::NewDirectory", + "alt-n": "project_panel::NewDirectory", + "cut": "project_panel::Cut", + "ctrl-x": "project_panel::Cut", + "copy": "project_panel::Copy", + "ctrl-insert": "project_panel::Copy", + "ctrl-c": "project_panel::Copy", + "paste": "project_panel::Paste", + "shift-insert": "project_panel::Paste", + "ctrl-v": "project_panel::Paste", + "alt-copy": "project_panel::CopyPath", + "shift-alt-c": "project_panel::CopyPath", + "shift-alt-copy": "workspace::CopyRelativePath", + "ctrl-k ctrl-shift-c": "workspace::CopyRelativePath", + "enter": "project_panel::Rename", + "f2": "project_panel::Rename", + "backspace": ["project_panel::Trash", { "skip_prompt": false }], + "delete": ["project_panel::Trash", { "skip_prompt": false }], + "shift-delete": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-backspace": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-delete": ["project_panel::Delete", { "skip_prompt": false }], + "ctrl-alt-r": "project_panel::RevealInFileManager", + "ctrl-shift-enter": "project_panel::OpenWithSystem", + "alt-d": "project_panel::CompareMarkedFiles", + "shift-find": "project_panel::NewSearchInDirectory", + "ctrl-k ctrl-shift-f": "project_panel::NewSearchInDirectory", + "shift-down": "menu::SelectNext", + "shift-up": "menu::SelectPrevious", + "escape": "menu::Cancel" + } + }, + { + "context": "ProjectPanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "space": "project_panel::Open" + } + }, + { + "context": "GitPanel && ChangesList", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "enter": "menu::Confirm", + "alt-y": "git::StageFile", + "shift-alt-y": "git::UnstageFile", + "space": "git::ToggleStaged", + "shift-space": "git::StageRange", + "tab": "git_panel::FocusEditor", + "shift-tab": "git_panel::FocusEditor", + "escape": "git_panel::ToggleFocus", + "alt-enter": "menu::SecondaryConfirm", + "delete": ["git::RestoreFile", { "skip_prompt": false }], + "backspace": ["git::RestoreFile", { "skip_prompt": false }], + "shift-delete": ["git::RestoreFile", { "skip_prompt": false }], + "ctrl-backspace": ["git::RestoreFile", { "skip_prompt": false }], + "ctrl-delete": ["git::RestoreFile", { "skip_prompt": false }] + } + }, + { + "context": "GitPanel && CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git::Cancel" + } + }, + { + "context": "GitCommit > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "editor::Newline", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "alt-l": "git::GenerateCommitMessage" + } + }, + { + "context": "GitPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-g ctrl-g": "git::Fetch", + "ctrl-g up": "git::Push", + "ctrl-g down": "git::Pull", + "ctrl-g shift-up": "git::ForcePush", + "ctrl-g d": "git::Diff", + "ctrl-g backspace": "git::RestoreTrackedFiles", + "ctrl-g shift-backspace": "git::TrashUntrackedFiles", + "ctrl-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend" + } + }, + { + "context": "GitDiff > Editor", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "ctrl-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll" + } + }, + { + "context": "AskPass > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm" + } + }, + { + "context": "CommitEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "git_panel::FocusChanges", + "tab": "git_panel::FocusChanges", + "shift-tab": "git_panel::FocusChanges", + "enter": "editor::Newline", + "ctrl-enter": "git::Commit", + "ctrl-shift-enter": "git::Amend", + "alt-up": "git_panel::FocusChanges", + "alt-l": "git::GenerateCommitMessage" + } + }, + { + "context": "DebugPanel", + "use_key_equivalents": true, + "bindings": { + "ctrl-t": "debugger::ToggleThreadPicker", + "ctrl-i": "debugger::ToggleSessionPicker", + "shift-alt-escape": "debugger::ToggleExpandItem" + } + }, + { + "context": "VariableList", + "use_key_equivalents": true, + "bindings": { + "left": "variable_list::CollapseSelectedEntry", + "right": "variable_list::ExpandSelectedEntry", + "enter": "variable_list::EditVariable", + "ctrl-c": "variable_list::CopyVariableValue", + "ctrl-alt-c": "variable_list::CopyVariableName", + "delete": "variable_list::RemoveWatch", + "backspace": "variable_list::RemoveWatch", + "alt-enter": "variable_list::AddWatch" + } + }, + { + "context": "BreakpointList", + "use_key_equivalents": true, + "bindings": { + "space": "debugger::ToggleEnableBreakpoint", + "backspace": "debugger::UnsetBreakpoint", + "left": "debugger::PreviousBreakpointProperty", + "right": "debugger::NextBreakpointProperty" + } + }, + { + "context": "CollabPanel && not_editing", + "use_key_equivalents": true, + "bindings": { + "ctrl-backspace": "collab_panel::Remove", + "space": "menu::Confirm" + } + }, + { + "context": "CollabPanel", + "use_key_equivalents": true, + "bindings": { + "alt-up": "collab_panel::MoveChannelUp", + "alt-down": "collab_panel::MoveChannelDown" + } + }, + { + "context": "(CollabPanel && editing) > Editor", + "use_key_equivalents": true, + "bindings": { + "space": "collab_panel::InsertSpace" + } + }, + { + "context": "ChannelModal", + "use_key_equivalents": true, + "bindings": { + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "up": "menu::SelectPrevious", + "down": "menu::SelectNext", + "tab": "picker::ConfirmCompletion", + "alt-enter": ["picker::ConfirmInput", { "secondary": false }] + } + }, + { + "context": "ChannelModal > Picker > Editor", + "use_key_equivalents": true, + "bindings": { + "tab": "channel_modal::ToggleMode" + } + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor)", + "use_key_equivalents": true, + "bindings": { + "ctrl-p": "file_finder::Toggle", + "ctrl-shift-a": "file_finder::ToggleSplitMenu", + "ctrl-shift-i": "file_finder::ToggleFilterMenu" + } + }, + { + "context": "FileFinder || (FileFinder > Picker > Editor) || (FileFinder > Picker > menu)", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-p": "file_finder::SelectPrevious", + "ctrl-j": "pane::SplitDown", + "ctrl-k": "pane::SplitUp", + "ctrl-h": "pane::SplitLeft", + "ctrl-l": "pane::SplitRight" + } + }, + { + "context": "TabSwitcher", + "use_key_equivalents": true, + "bindings": { + "ctrl-shift-tab": "menu::SelectPrevious", + "ctrl-up": "menu::SelectPrevious", + "ctrl-down": "menu::SelectNext", + "ctrl-backspace": "tab_switcher::CloseSelectedItem" + } + }, + { + "context": "Terminal", + "use_key_equivalents": true, + "bindings": { + "ctrl-alt-space": "terminal::ShowCharacterPalette", + "copy": "terminal::Copy", + "ctrl-insert": "terminal::Copy", + "ctrl-shift-c": "terminal::Copy", + "paste": "terminal::Paste", + "shift-insert": "terminal::Paste", + "ctrl-shift-v": "terminal::Paste", + "ctrl-enter": "assistant::InlineAssist", + "alt-b": ["terminal::SendText", "\u001bb"], + "alt-f": ["terminal::SendText", "\u001bf"], + "alt-.": ["terminal::SendText", "\u001b."], + "ctrl-delete": ["terminal::SendText", "\u001bd"], + // Overrides for conflicting keybindings + "ctrl-b": ["terminal::SendKeystroke", "ctrl-b"], + "ctrl-c": ["terminal::SendKeystroke", "ctrl-c"], + "ctrl-e": ["terminal::SendKeystroke", "ctrl-e"], + "ctrl-o": ["terminal::SendKeystroke", "ctrl-o"], + "ctrl-w": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-backspace": ["terminal::SendKeystroke", "ctrl-w"], + "ctrl-shift-a": "editor::SelectAll", + "find": "buffer_search::Deploy", + "ctrl-shift-f": "buffer_search::Deploy", + "ctrl-shift-l": "terminal::Clear", + "ctrl-shift-w": "pane::CloseActiveItem", + "up": ["terminal::SendKeystroke", "up"], + "pageup": ["terminal::SendKeystroke", "pageup"], + "down": ["terminal::SendKeystroke", "down"], + "pagedown": ["terminal::SendKeystroke", "pagedown"], + "escape": ["terminal::SendKeystroke", "escape"], + "enter": ["terminal::SendKeystroke", "enter"], + "shift-pageup": "terminal::ScrollPageUp", + "shift-pagedown": "terminal::ScrollPageDown", + "shift-up": "terminal::ScrollLineUp", + "shift-down": "terminal::ScrollLineDown", + "shift-home": "terminal::ScrollToTop", + "shift-end": "terminal::ScrollToBottom", + "ctrl-shift-space": "terminal::ToggleViMode", + "ctrl-shift-r": "terminal::RerunTask", + "ctrl-alt-r": "terminal::RerunTask", + "alt-t": "terminal::RerunTask" + } + }, + { + "context": "ZedPredictModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "ConfigureContextServerModal > Editor", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel", + "enter": "editor::Newline", + "ctrl-enter": "menu::Confirm" + } + }, + { + "context": "OnboardingAiConfigurationModal", + "use_key_equivalents": true, + "bindings": { + "escape": "menu::Cancel" + } + }, + { + "context": "Diagnostics", + "use_key_equivalents": true, + "bindings": { + "ctrl-r": "diagnostics::ToggleDiagnosticsRefresh" + } + }, + { + "context": "DebugConsole > Editor", + "use_key_equivalents": true, + "bindings": { + "enter": "menu::Confirm", + "alt-enter": "console::WatchExpression" + } + }, + { + "context": "RunModal", + "use_key_equivalents": true, + "bindings": { + "ctrl-tab": "pane::ActivateNextItem", + "ctrl-shift-tab": "pane::ActivatePreviousItem" + } + }, + { + "context": "MarkdownPreview", + "use_key_equivalents": true, + "bindings": { + "pageup": "markdown::MovePageUp", + "pagedown": "markdown::MovePageDown" + } + }, + { + "context": "KeymapEditor", + "use_key_equivalents": true, + "bindings": { + "ctrl-f": "search::FocusSearch", + "alt-find": "keymap_editor::ToggleKeystrokeSearch", + "alt-f": "keymap_editor::ToggleKeystrokeSearch", + "alt-c": "keymap_editor::ToggleConflictFilter", + "enter": "keymap_editor::EditBinding", + "alt-enter": "keymap_editor::CreateBinding", + "ctrl-c": "keymap_editor::CopyAction", + "ctrl-shift-c": "keymap_editor::CopyContext", + "ctrl-t": "keymap_editor::ShowMatchingKeybinds" + } + }, + { + "context": "KeystrokeInput", + "use_key_equivalents": true, + "bindings": { + "enter": "keystroke_input::StartRecording", + "escape escape escape": "keystroke_input::StopRecording", + "delete": "keystroke_input::ClearKeystrokes" + } + }, + { + "context": "KeybindEditorModal", + "use_key_equivalents": true, + "bindings": { + "ctrl-enter": "menu::Confirm", + "escape": "menu::Cancel" + } + }, + { + "context": "KeybindEditorModal > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } + }, + { + "context": "Onboarding", + "use_key_equivalents": true, + "bindings": { + "ctrl-1": "onboarding::ActivateBasicsPage", + "ctrl-2": "onboarding::ActivateEditingPage", + "ctrl-3": "onboarding::ActivateAISetupPage", + "ctrl-escape": "onboarding::Finish", + "alt-tab": "onboarding::SignIn", + "shift-alt-a": "onboarding::OpenAccount" + } + } +] diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index c900eb692a..c8c3dc54b7 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -19,6 +19,10 @@ static KEYMAP_LINUX: LazyLock = LazyLock::new(|| { load_keymap("keymaps/default-linux.json").expect("Failed to load Linux keymap") }); +static KEYMAP_WINDOWS: LazyLock = LazyLock::new(|| { + load_keymap("keymaps/default-windows.json").expect("Failed to load Windows keymap") +}); + static ALL_ACTIONS: LazyLock> = LazyLock::new(dump_all_gpui_actions); const FRONT_MATTER_COMMENT: &str = ""; @@ -216,6 +220,7 @@ fn find_binding(os: &str, action: &str) -> Option { let keymap = match os { "macos" => &KEYMAP_MACOS, "linux" | "freebsd" => &KEYMAP_LINUX, + "windows" => &KEYMAP_WINDOWS, _ => unreachable!("Not a valid OS: {}", os), }; diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 29e009fdf8..80680ae9c0 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2588,7 +2588,7 @@ impl Editor { || binding .keystrokes() .first() - .is_some_and(|keystroke| keystroke.modifiers.modified()) + .is_some_and(|keystroke| keystroke.display_modifiers.modified()) })) } @@ -7686,16 +7686,16 @@ impl Editor { .keystroke() { modifiers_held = modifiers_held - || (&accept_keystroke.modifiers == modifiers - && accept_keystroke.modifiers.modified()); + || (&accept_keystroke.display_modifiers == modifiers + && accept_keystroke.display_modifiers.modified()); }; if let Some(accept_partial_keystroke) = self .accept_edit_prediction_keybind(true, window, cx) .keystroke() { modifiers_held = modifiers_held - || (&accept_partial_keystroke.modifiers == modifiers - && accept_partial_keystroke.modifiers.modified()); + || (&accept_partial_keystroke.display_modifiers == modifiers + && accept_partial_keystroke.display_modifiers.modified()); } if modifiers_held { @@ -9044,7 +9044,7 @@ impl Editor { let is_platform_style_mac = PlatformStyle::platform() == PlatformStyle::Mac; - let modifiers_color = if accept_keystroke.modifiers == window.modifiers() { + let modifiers_color = if accept_keystroke.display_modifiers == window.modifiers() { Color::Accent } else { Color::Muted @@ -9056,19 +9056,19 @@ impl Editor { .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .text_size(TextSize::XSmall.rems(cx)) .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + &accept_keystroke.display_modifiers, PlatformStyle::platform(), Some(modifiers_color), Some(IconSize::XSmall.rems().into()), true, ))) .when(is_platform_style_mac, |parent| { - parent.child(accept_keystroke.key.clone()) + parent.child(accept_keystroke.display_key.clone()) }) .when(!is_platform_style_mac, |parent| { parent.child( Key::new( - util::capitalize(&accept_keystroke.key), + util::capitalize(&accept_keystroke.display_key), Some(Color::Default), ) .size(Some(IconSize::XSmall.rems().into())), @@ -9171,7 +9171,7 @@ impl Editor { max_width: Pixels, cursor_point: Point, style: &EditorStyle, - accept_keystroke: Option<&gpui::Keystroke>, + accept_keystroke: Option<&gpui::KeybindingKeystroke>, _window: &Window, cx: &mut Context, ) -> Option { @@ -9249,7 +9249,7 @@ impl Editor { accept_keystroke.as_ref(), |el, accept_keystroke| { el.child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + &accept_keystroke.display_modifiers, PlatformStyle::platform(), Some(Color::Default), Some(IconSize::XSmall.rems().into()), @@ -9319,7 +9319,7 @@ impl Editor { .child(completion), ) .when_some(accept_keystroke, |el, accept_keystroke| { - if !accept_keystroke.modifiers.modified() { + if !accept_keystroke.display_modifiers.modified() { return el; } @@ -9338,7 +9338,7 @@ impl Editor { .font(theme::ThemeSettings::get_global(cx).buffer_font.clone()) .when(is_platform_style_mac, |parent| parent.gap_1()) .child(h_flex().children(ui::render_modifiers( - &accept_keystroke.modifiers, + &accept_keystroke.display_modifiers, PlatformStyle::platform(), Some(if !has_completion { Color::Muted diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4f3580da07..91034829f7 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -43,10 +43,10 @@ use gpui::{ Bounds, ClickEvent, ClipboardItem, ContentMask, Context, Corner, Corners, CursorStyle, DispatchPhase, Edges, Element, ElementInputHandler, Entity, Focusable as _, FontId, GlobalElementId, Hitbox, HitboxBehavior, Hsla, InteractiveElement, IntoElement, IsZero, - Keystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, MouseDownEvent, - MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, ScrollHandle, - ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, Style, Styled, - TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, + KeybindingKeystroke, Length, ModifiersChangedEvent, MouseButton, MouseClickEvent, + MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, ParentElement, Pixels, ScrollDelta, + ScrollHandle, ScrollWheelEvent, ShapedLine, SharedString, Size, StatefulInteractiveElement, + Style, Styled, TextRun, TextStyleRefinement, WeakEntity, Window, anchored, deferred, div, fill, linear_color_stop, linear_gradient, outline, point, px, quad, relative, size, solid_background, transparent_black, }; @@ -7150,7 +7150,7 @@ fn header_jump_data( pub struct AcceptEditPredictionBinding(pub(crate) Option); impl AcceptEditPredictionBinding { - pub fn keystroke(&self) -> Option<&Keystroke> { + pub fn keystroke(&self) -> Option<&KeybindingKeystroke> { if let Some(binding) = self.0.as_ref() { match &binding.keystrokes() { [keystroke, ..] => Some(keystroke), diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index bbd59fa7bc..b59d7e717a 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -37,10 +37,10 @@ use crate::{ AssetSource, BackgroundExecutor, Bounds, ClipboardItem, CursorStyle, DispatchPhase, DisplayId, EventEmitter, FocusHandle, FocusMap, ForegroundExecutor, Global, KeyBinding, KeyContext, Keymap, Keystroke, LayoutId, Menu, MenuItem, OwnedMenu, PathPromptOptions, Pixels, Platform, - PlatformDisplay, PlatformKeyboardLayout, Point, PromptBuilder, PromptButton, PromptHandle, - PromptLevel, Render, RenderImage, RenderablePromptHandle, Reservation, ScreenCaptureSource, - SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, Window, WindowAppearance, - WindowHandle, WindowId, WindowInvalidator, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, Point, PromptBuilder, + PromptButton, PromptHandle, PromptLevel, Render, RenderImage, RenderablePromptHandle, + Reservation, ScreenCaptureSource, SubscriberSet, Subscription, SvgRenderer, Task, TextSystem, + Window, WindowAppearance, WindowHandle, WindowId, WindowInvalidator, colors::{Colors, GlobalColors}, current_platform, hash, init_app_menus, }; @@ -263,6 +263,7 @@ pub struct App { pub(crate) focus_handles: Arc, pub(crate) keymap: Rc>, pub(crate) keyboard_layout: Box, + pub(crate) keyboard_mapper: Rc, pub(crate) global_action_listeners: FxHashMap>>, pending_effects: VecDeque, @@ -312,6 +313,7 @@ impl App { let text_system = Arc::new(TextSystem::new(platform.text_system())); let entities = EntityMap::new(); let keyboard_layout = platform.keyboard_layout(); + let keyboard_mapper = platform.keyboard_mapper(); let app = Rc::new_cyclic(|this| AppCell { app: RefCell::new(App { @@ -337,6 +339,7 @@ impl App { focus_handles: Arc::new(RwLock::new(SlotMap::with_key())), keymap: Rc::new(RefCell::new(Keymap::default())), keyboard_layout, + keyboard_mapper, global_action_listeners: FxHashMap::default(), pending_effects: VecDeque::new(), pending_notifications: FxHashSet::default(), @@ -376,6 +379,7 @@ impl App { if let Some(app) = app.upgrade() { let cx = &mut app.borrow_mut(); cx.keyboard_layout = cx.platform.keyboard_layout(); + cx.keyboard_mapper = cx.platform.keyboard_mapper(); cx.keyboard_layout_observers .clone() .retain(&(), move |callback| (callback)(cx)); @@ -424,6 +428,11 @@ impl App { self.keyboard_layout.as_ref() } + /// Get the current keyboard mapper. + pub fn keyboard_mapper(&self) -> &Rc { + &self.keyboard_mapper + } + /// Invokes a handler when the current keyboard layout changes pub fn on_keyboard_layout_change(&self, mut callback: F) -> Subscription where diff --git a/crates/gpui/src/keymap.rs b/crates/gpui/src/keymap.rs index 757205fcc3..b3db09d821 100644 --- a/crates/gpui/src/keymap.rs +++ b/crates/gpui/src/keymap.rs @@ -4,7 +4,7 @@ mod context; pub use binding::*; pub use context::*; -use crate::{Action, Keystroke, is_no_action}; +use crate::{Action, AsKeystroke, Keystroke, is_no_action}; use collections::{HashMap, HashSet}; use smallvec::SmallVec; use std::any::TypeId; @@ -141,7 +141,7 @@ impl Keymap { /// only. pub fn bindings_for_input( &self, - input: &[Keystroke], + input: &[impl AsKeystroke], context_stack: &[KeyContext], ) -> (SmallVec<[KeyBinding; 1]>, bool) { let mut matched_bindings = SmallVec::<[(usize, BindingIndex, &KeyBinding); 1]>::new(); @@ -192,7 +192,6 @@ impl Keymap { (bindings, !pending.is_empty()) } - /// Check if the given binding is enabled, given a certain key context. /// Returns the deepest depth at which the binding matches, or None if it doesn't match. fn binding_enabled(&self, binding: &KeyBinding, contexts: &[KeyContext]) -> Option { @@ -639,7 +638,7 @@ mod tests { fn assert_bindings(keymap: &Keymap, action: &dyn Action, expected: &[&str]) { let actual = keymap .bindings_for_action(action) - .map(|binding| binding.keystrokes[0].unparse()) + .map(|binding| binding.keystrokes[0].inner.unparse()) .collect::>(); assert_eq!(actual, expected, "{:?}", action); } diff --git a/crates/gpui/src/keymap/binding.rs b/crates/gpui/src/keymap/binding.rs index 729498d153..a7cf9d5c54 100644 --- a/crates/gpui/src/keymap/binding.rs +++ b/crates/gpui/src/keymap/binding.rs @@ -1,14 +1,15 @@ use std::rc::Rc; -use collections::HashMap; - -use crate::{Action, InvalidKeystrokeError, KeyBindingContextPredicate, Keystroke, SharedString}; +use crate::{ + Action, AsKeystroke, DummyKeyboardMapper, InvalidKeystrokeError, KeyBindingContextPredicate, + KeybindingKeystroke, Keystroke, PlatformKeyboardMapper, SharedString, +}; use smallvec::SmallVec; /// A keybinding and its associated metadata, from the keymap. pub struct KeyBinding { pub(crate) action: Box, - pub(crate) keystrokes: SmallVec<[Keystroke; 2]>, + pub(crate) keystrokes: SmallVec<[KeybindingKeystroke; 2]>, pub(crate) context_predicate: Option>, pub(crate) meta: Option, /// The json input string used when building the keybinding, if any @@ -32,7 +33,15 @@ impl KeyBinding { pub fn new(keystrokes: &str, action: A, context: Option<&str>) -> Self { let context_predicate = context.map(|context| KeyBindingContextPredicate::parse(context).unwrap().into()); - Self::load(keystrokes, Box::new(action), context_predicate, None, None).unwrap() + Self::load( + keystrokes, + Box::new(action), + context_predicate, + false, + None, + &DummyKeyboardMapper, + ) + .unwrap() } /// Load a keybinding from the given raw data. @@ -40,24 +49,22 @@ impl KeyBinding { keystrokes: &str, action: Box, context_predicate: Option>, - key_equivalents: Option<&HashMap>, + use_key_equivalents: bool, action_input: Option, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> std::result::Result { - let mut keystrokes: SmallVec<[Keystroke; 2]> = keystrokes + let keystrokes: SmallVec<[KeybindingKeystroke; 2]> = keystrokes .split_whitespace() - .map(Keystroke::parse) + .map(|source| { + let keystroke = Keystroke::parse(source)?; + Ok(KeybindingKeystroke::new( + keystroke, + use_key_equivalents, + keyboard_mapper, + )) + }) .collect::>()?; - if let Some(equivalents) = key_equivalents { - for keystroke in keystrokes.iter_mut() { - if keystroke.key.chars().count() == 1 - && let Some(key) = equivalents.get(&keystroke.key.chars().next().unwrap()) - { - keystroke.key = key.to_string(); - } - } - } - Ok(Self { keystrokes, action, @@ -79,13 +86,13 @@ impl KeyBinding { } /// Check if the given keystrokes match this binding. - pub fn match_keystrokes(&self, typed: &[Keystroke]) -> Option { + pub fn match_keystrokes(&self, typed: &[impl AsKeystroke]) -> Option { if self.keystrokes.len() < typed.len() { return None; } for (target, typed) in self.keystrokes.iter().zip(typed.iter()) { - if !typed.should_match(target) { + if !typed.as_keystroke().should_match(target) { return None; } } @@ -94,7 +101,7 @@ impl KeyBinding { } /// Get the keystrokes associated with this binding - pub fn keystrokes(&self) -> &[Keystroke] { + pub fn keystrokes(&self) -> &[KeybindingKeystroke] { self.keystrokes.as_slice() } diff --git a/crates/gpui/src/platform.rs b/crates/gpui/src/platform.rs index 4d2feeaf1d..f64710bc56 100644 --- a/crates/gpui/src/platform.rs +++ b/crates/gpui/src/platform.rs @@ -231,7 +231,6 @@ pub(crate) trait Platform: 'static { fn on_quit(&self, callback: Box); fn on_reopen(&self, callback: Box); - fn on_keyboard_layout_change(&self, callback: Box); fn set_menus(&self, menus: Vec, keymap: &Keymap); fn get_menus(&self) -> Option> { @@ -251,7 +250,6 @@ pub(crate) trait Platform: 'static { fn on_app_menu_action(&self, callback: Box); fn on_will_open_app_menu(&self, callback: Box); fn on_validate_app_menu_command(&self, callback: Box bool>); - fn keyboard_layout(&self) -> Box; fn compositor_name(&self) -> &'static str { "" @@ -272,6 +270,10 @@ pub(crate) trait Platform: 'static { fn write_credentials(&self, url: &str, username: &str, password: &[u8]) -> Task>; fn read_credentials(&self, url: &str) -> Task)>>>; fn delete_credentials(&self, url: &str) -> Task>; + + fn keyboard_layout(&self) -> Box; + fn keyboard_mapper(&self) -> Rc; + fn on_keyboard_layout_change(&self, callback: Box); } /// A handle to a platform's display, e.g. a monitor or laptop screen. diff --git a/crates/gpui/src/platform/keyboard.rs b/crates/gpui/src/platform/keyboard.rs index e28d781520..10b8620258 100644 --- a/crates/gpui/src/platform/keyboard.rs +++ b/crates/gpui/src/platform/keyboard.rs @@ -1,3 +1,7 @@ +use collections::HashMap; + +use crate::{KeybindingKeystroke, Keystroke}; + /// A trait for platform-specific keyboard layouts pub trait PlatformKeyboardLayout { /// Get the keyboard layout ID, which should be unique to the layout @@ -5,3 +9,33 @@ pub trait PlatformKeyboardLayout { /// Get the keyboard layout display name fn name(&self) -> &str; } + +/// A trait for platform-specific keyboard mappings +pub trait PlatformKeyboardMapper { + /// Map a key equivalent to its platform-specific representation + fn map_key_equivalent( + &self, + keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke; + /// Get the key equivalents for the current keyboard layout, + /// only used on macOS + fn get_key_equivalents(&self) -> Option<&HashMap>; +} + +/// A dummy implementation of the platform keyboard mapper +pub struct DummyKeyboardMapper; + +impl PlatformKeyboardMapper for DummyKeyboardMapper { + fn map_key_equivalent( + &self, + keystroke: Keystroke, + _use_key_equivalents: bool, + ) -> KeybindingKeystroke { + KeybindingKeystroke::from_keystroke(keystroke) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } +} diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 24601eefd6..6ce17c3a01 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -5,6 +5,14 @@ use std::{ fmt::{Display, Write}, }; +use crate::PlatformKeyboardMapper; + +/// This is a helper trait so that we can simplify the implementation of some functions +pub trait AsKeystroke { + /// Returns the GPUI representation of the keystroke. + fn as_keystroke(&self) -> &Keystroke; +} + /// A keystroke and associated metadata generated by the platform #[derive(Clone, Debug, Eq, PartialEq, Default, Deserialize, Hash)] pub struct Keystroke { @@ -24,6 +32,17 @@ pub struct Keystroke { pub key_char: Option, } +/// Represents a keystroke that can be used in keybindings and displayed to the user. +#[derive(Debug, Clone, Eq, PartialEq, Hash)] +pub struct KeybindingKeystroke { + /// The GPUI representation of the keystroke. + pub inner: Keystroke, + /// The modifiers to display. + pub display_modifiers: Modifiers, + /// The key to display. + pub display_key: String, +} + /// Error type for `Keystroke::parse`. This is used instead of `anyhow::Error` so that Zed can use /// markdown to display it. #[derive(Debug)] @@ -58,7 +77,7 @@ impl Keystroke { /// /// This method assumes that `self` was typed and `target' is in the keymap, and checks /// both possibilities for self against the target. - pub fn should_match(&self, target: &Keystroke) -> bool { + pub fn should_match(&self, target: &KeybindingKeystroke) -> bool { #[cfg(not(target_os = "windows"))] if let Some(key_char) = self .key_char @@ -71,7 +90,7 @@ impl Keystroke { ..Default::default() }; - if &target.key == key_char && target.modifiers == ime_modifiers { + if &target.inner.key == key_char && target.inner.modifiers == ime_modifiers { return true; } } @@ -83,12 +102,12 @@ impl Keystroke { .filter(|key_char| key_char != &&self.key) { // On Windows, if key_char is set, then the typed keystroke produced the key_char - if &target.key == key_char && target.modifiers == Modifiers::none() { + if &target.inner.key == key_char && target.inner.modifiers == Modifiers::none() { return true; } } - target.modifiers == self.modifiers && target.key == self.key + target.inner.modifiers == self.modifiers && target.inner.key == self.key } /// key syntax is: @@ -200,31 +219,7 @@ impl Keystroke { /// Produces a representation of this key that Parse can understand. pub fn unparse(&self) -> String { - let mut str = String::new(); - if self.modifiers.function { - str.push_str("fn-"); - } - if self.modifiers.control { - str.push_str("ctrl-"); - } - if self.modifiers.alt { - str.push_str("alt-"); - } - if self.modifiers.platform { - #[cfg(target_os = "macos")] - str.push_str("cmd-"); - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - str.push_str("super-"); - - #[cfg(target_os = "windows")] - str.push_str("win-"); - } - if self.modifiers.shift { - str.push_str("shift-"); - } - str.push_str(&self.key); - str + unparse(&self.modifiers, &self.key) } /// Returns true if this keystroke left @@ -266,6 +261,32 @@ impl Keystroke { } } +impl KeybindingKeystroke { + /// Create a new keybinding keystroke from the given keystroke + pub fn new( + inner: Keystroke, + use_key_equivalents: bool, + keyboard_mapper: &dyn PlatformKeyboardMapper, + ) -> Self { + keyboard_mapper.map_key_equivalent(inner, use_key_equivalents) + } + + pub(crate) fn from_keystroke(keystroke: Keystroke) -> Self { + let key = keystroke.key.clone(); + let modifiers = keystroke.modifiers; + KeybindingKeystroke { + inner: keystroke, + display_modifiers: modifiers, + display_key: key, + } + } + + /// Produces a representation of this key that Parse can understand. + pub fn unparse(&self) -> String { + unparse(&self.display_modifiers, &self.display_key) + } +} + fn is_printable_key(key: &str) -> bool { !matches!( key, @@ -322,65 +343,15 @@ fn is_printable_key(key: &str) -> bool { impl std::fmt::Display for Keystroke { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - if self.modifiers.control { - #[cfg(target_os = "macos")] - f.write_char('^')?; + display_modifiers(&self.modifiers, f)?; + display_key(&self.key, f) + } +} - #[cfg(not(target_os = "macos"))] - write!(f, "ctrl-")?; - } - if self.modifiers.alt { - #[cfg(target_os = "macos")] - f.write_char('⌥')?; - - #[cfg(not(target_os = "macos"))] - write!(f, "alt-")?; - } - if self.modifiers.platform { - #[cfg(target_os = "macos")] - f.write_char('⌘')?; - - #[cfg(any(target_os = "linux", target_os = "freebsd"))] - f.write_char('❖')?; - - #[cfg(target_os = "windows")] - f.write_char('⊞')?; - } - if self.modifiers.shift { - #[cfg(target_os = "macos")] - f.write_char('⇧')?; - - #[cfg(not(target_os = "macos"))] - write!(f, "shift-")?; - } - let key = match self.key.as_str() { - #[cfg(target_os = "macos")] - "backspace" => '⌫', - #[cfg(target_os = "macos")] - "up" => '↑', - #[cfg(target_os = "macos")] - "down" => '↓', - #[cfg(target_os = "macos")] - "left" => '←', - #[cfg(target_os = "macos")] - "right" => '→', - #[cfg(target_os = "macos")] - "tab" => '⇥', - #[cfg(target_os = "macos")] - "escape" => '⎋', - #[cfg(target_os = "macos")] - "shift" => '⇧', - #[cfg(target_os = "macos")] - "control" => '⌃', - #[cfg(target_os = "macos")] - "alt" => '⌥', - #[cfg(target_os = "macos")] - "platform" => '⌘', - - key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(), - key => return f.write_str(key), - }; - f.write_char(key) +impl std::fmt::Display for KeybindingKeystroke { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + display_modifiers(&self.display_modifiers, f)?; + display_key(&self.display_key, f) } } @@ -600,3 +571,110 @@ pub struct Capslock { #[serde(default)] pub on: bool, } + +impl AsKeystroke for Keystroke { + fn as_keystroke(&self) -> &Keystroke { + self + } +} + +impl AsKeystroke for KeybindingKeystroke { + fn as_keystroke(&self) -> &Keystroke { + &self.inner + } +} + +fn display_modifiers(modifiers: &Modifiers, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if modifiers.control { + #[cfg(target_os = "macos")] + f.write_char('^')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "ctrl-")?; + } + if modifiers.alt { + #[cfg(target_os = "macos")] + f.write_char('⌥')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "alt-")?; + } + if modifiers.platform { + #[cfg(target_os = "macos")] + f.write_char('⌘')?; + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + f.write_char('❖')?; + + #[cfg(target_os = "windows")] + f.write_char('⊞')?; + } + if modifiers.shift { + #[cfg(target_os = "macos")] + f.write_char('⇧')?; + + #[cfg(not(target_os = "macos"))] + write!(f, "shift-")?; + } + Ok(()) +} + +fn display_key(key: &str, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let key = match key { + #[cfg(target_os = "macos")] + "backspace" => '⌫', + #[cfg(target_os = "macos")] + "up" => '↑', + #[cfg(target_os = "macos")] + "down" => '↓', + #[cfg(target_os = "macos")] + "left" => '←', + #[cfg(target_os = "macos")] + "right" => '→', + #[cfg(target_os = "macos")] + "tab" => '⇥', + #[cfg(target_os = "macos")] + "escape" => '⎋', + #[cfg(target_os = "macos")] + "shift" => '⇧', + #[cfg(target_os = "macos")] + "control" => '⌃', + #[cfg(target_os = "macos")] + "alt" => '⌥', + #[cfg(target_os = "macos")] + "platform" => '⌘', + + key if key.len() == 1 => key.chars().next().unwrap().to_ascii_uppercase(), + key => return f.write_str(key), + }; + f.write_char(key) +} + +#[inline] +fn unparse(modifiers: &Modifiers, key: &str) -> String { + let mut result = String::new(); + if modifiers.function { + result.push_str("fn-"); + } + if modifiers.control { + result.push_str("ctrl-"); + } + if modifiers.alt { + result.push_str("alt-"); + } + if modifiers.platform { + #[cfg(target_os = "macos")] + result.push_str("cmd-"); + + #[cfg(any(target_os = "linux", target_os = "freebsd"))] + result.push_str("super-"); + + #[cfg(target_os = "windows")] + result.push_str("win-"); + } + if modifiers.shift { + result.push_str("shift-"); + } + result.push_str(&key); + result +} diff --git a/crates/gpui/src/platform/linux/platform.rs b/crates/gpui/src/platform/linux/platform.rs index 3fb1ef4572..8bd89fc399 100644 --- a/crates/gpui/src/platform/linux/platform.rs +++ b/crates/gpui/src/platform/linux/platform.rs @@ -25,8 +25,8 @@ use xkbcommon::xkb::{self, Keycode, Keysym, State}; use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DisplayId, ForegroundExecutor, Keymap, LinuxDispatcher, Menu, MenuItem, OwnedMenu, PathPromptOptions, - Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, - Point, Result, Task, WindowAppearance, WindowParams, px, + Pixels, Platform, PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, + PlatformTextSystem, PlatformWindow, Point, Result, Task, WindowAppearance, WindowParams, px, }; #[cfg(any(feature = "wayland", feature = "x11"))] @@ -144,6 +144,10 @@ impl Platform for P { self.keyboard_layout() } + fn keyboard_mapper(&self) -> Rc { + Rc::new(crate::DummyKeyboardMapper) + } + fn on_keyboard_layout_change(&self, callback: Box) { self.with_common(|common| common.callbacks.keyboard_layout_change = Some(callback)); } diff --git a/crates/gpui/src/platform/mac/keyboard.rs b/crates/gpui/src/platform/mac/keyboard.rs index a9f6af3edb..1409731246 100644 --- a/crates/gpui/src/platform/mac/keyboard.rs +++ b/crates/gpui/src/platform/mac/keyboard.rs @@ -1,8 +1,9 @@ +use collections::HashMap; use std::ffi::{CStr, c_void}; use objc::{msg_send, runtime::Object, sel, sel_impl}; -use crate::PlatformKeyboardLayout; +use crate::{KeybindingKeystroke, Keystroke, PlatformKeyboardLayout, PlatformKeyboardMapper}; use super::{ TISCopyCurrentKeyboardLayoutInputSource, TISGetInputSourceProperty, kTISPropertyInputSourceID, @@ -14,6 +15,10 @@ pub(crate) struct MacKeyboardLayout { name: String, } +pub(crate) struct MacKeyboardMapper { + key_equivalents: Option>, +} + impl PlatformKeyboardLayout for MacKeyboardLayout { fn id(&self) -> &str { &self.id @@ -24,6 +29,27 @@ impl PlatformKeyboardLayout for MacKeyboardLayout { } } +impl PlatformKeyboardMapper for MacKeyboardMapper { + fn map_key_equivalent( + &self, + mut keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + if use_key_equivalents && let Some(key_equivalents) = &self.key_equivalents { + if keystroke.key.chars().count() == 1 + && let Some(key) = key_equivalents.get(&keystroke.key.chars().next().unwrap()) + { + keystroke.key = key.to_string(); + } + } + KeybindingKeystroke::from_keystroke(keystroke) + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + self.key_equivalents.as_ref() + } +} + impl MacKeyboardLayout { pub(crate) fn new() -> Self { unsafe { @@ -47,3 +73,1428 @@ impl MacKeyboardLayout { } } } + +impl MacKeyboardMapper { + pub(crate) fn new(layout_id: &str) -> Self { + let key_equivalents = get_key_equivalents(layout_id); + + Self { key_equivalents } + } +} + +// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range +// without using option. This means that some of our built in keyboard shortcuts do not work +// for those users. +// +// The way macOS solves this problem is to move shortcuts around so that they are all reachable, +// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct +// +// For example, cmd-> is the "switch window" shortcut because the > key is right above tab. +// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves +// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position +// as cmd-> on a QWERTY layout. +// +// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö +// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard +// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the +// specific key moves) +// +// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every +// possible key combination, and inspecting the UI to see what it rendered. So that's what we did... +// +// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the +// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with: +// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add' +// From there I used multi-cursor to produce this match statement. +fn get_key_equivalents(layout_id: &str) -> Option> { + let mappings: &[(char, char)] = match layout_id { + "com.apple.keylayout.ABC-AZERTY" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.ABC-QWERTZ" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Albanian" => &[ + ('"', '\''), + (':', 'Ç'), + (';', 'ç'), + ('<', ';'), + ('>', ':'), + ('@', '"'), + ('\'', '@'), + ('\\', 'ë'), + ('`', '<'), + ('|', 'Ë'), + ('~', '>'), + ], + "com.apple.keylayout.Austrian" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Azeri" => &[ + ('"', 'Ə'), + (',', 'ç'), + ('.', 'ş'), + ('/', '.'), + (':', 'I'), + (';', 'ı'), + ('<', 'Ç'), + ('>', 'Ş'), + ('?', ','), + ('W', 'Ü'), + ('[', 'ö'), + ('\'', 'ə'), + (']', 'ğ'), + ('w', 'ü'), + ('{', 'Ö'), + ('|', '/'), + ('}', 'Ğ'), + ], + "com.apple.keylayout.Belgian" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.Brazilian-ABNT2" => &[ + ('"', '`'), + ('/', 'ç'), + ('?', 'Ç'), + ('\'', '´'), + ('\\', '~'), + ('^', '¨'), + ('`', '\''), + ('|', '^'), + ('~', '"'), + ], + "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.British" => &[('#', '£')], + "com.apple.keylayout.Canadian-CSA" => &[ + ('"', 'È'), + ('/', 'é'), + ('<', '\''), + ('>', '"'), + ('?', 'É'), + ('[', '^'), + ('\'', 'è'), + ('\\', 'à'), + (']', 'ç'), + ('`', 'ù'), + ('{', '¨'), + ('|', 'À'), + ('}', 'Ç'), + ('~', 'Ù'), + ], + "com.apple.keylayout.Croatian" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Croatian-PC" => &[ + ('"', 'Ć'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Czech" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ě'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ř'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ů'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', ')'), + ('^', '6'), + ('`', '¨'), + ('{', 'Ú'), + ('}', '('), + ('~', '`'), + ], + "com.apple.keylayout.Czech-QWERTY" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ě'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ř'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ů'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', ')'), + ('^', '6'), + ('`', '¨'), + ('{', 'Ú'), + ('}', '('), + ('~', '`'), + ], + "com.apple.keylayout.Danish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'æ'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ø'), + ('^', '&'), + ('`', '<'), + ('{', 'Æ'), + ('|', '*'), + ('}', 'Ø'), + ('~', '>'), + ], + "com.apple.keylayout.Faroese" => &[ + ('"', 'Ø'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Æ'), + (';', 'æ'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'å'), + ('\'', 'ø'), + ('\\', '\''), + (']', 'ð'), + ('^', '&'), + ('`', '<'), + ('{', 'Å'), + ('|', '*'), + ('}', 'Ð'), + ('~', '>'), + ], + "com.apple.keylayout.Finnish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.FinnishExtended" => &[ + ('"', 'ˆ'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.FinnishSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '@'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.French" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.French-PC" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('-', ')'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '-'), + ('7', 'è'), + ('8', '_'), + ('9', 'ç'), + (':', '§'), + (';', '!'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '*'), + (']', '$'), + ('^', '6'), + ('_', '°'), + ('`', '<'), + ('{', '¨'), + ('|', 'μ'), + ('}', '£'), + ('~', '>'), + ], + "com.apple.keylayout.French-numerical" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('.', ';'), + ('/', ':'), + ('0', 'à'), + ('1', '&'), + ('2', 'é'), + ('3', '"'), + ('4', '\''), + ('5', '('), + ('6', '§'), + ('7', 'è'), + ('8', '!'), + ('9', 'ç'), + (':', '°'), + (';', ')'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', '^'), + ('\'', 'ù'), + ('\\', '`'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '¨'), + ('|', '£'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.German" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.German-DIN-2137" => &[ + ('"', '`'), + ('#', '§'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', 'ß'), + (':', 'Ü'), + (';', 'ü'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '´'), + ('\\', '#'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '\''), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')], + "com.apple.keylayout.Hungarian" => &[ + ('!', '\''), + ('"', 'Á'), + ('#', '+'), + ('$', '!'), + ('&', '='), + ('(', ')'), + (')', 'Ö'), + ('*', '('), + ('+', 'Ó'), + ('/', 'ü'), + ('0', 'ö'), + (':', 'É'), + (';', 'é'), + ('<', 'Ü'), + ('=', 'ó'), + ('>', ':'), + ('@', '"'), + ('[', 'ő'), + ('\'', 'á'), + ('\\', 'ű'), + (']', 'ú'), + ('^', '/'), + ('`', 'í'), + ('{', 'Ő'), + ('|', 'Ű'), + ('}', 'Ú'), + ('~', 'Í'), + ], + "com.apple.keylayout.Hungarian-QWERTY" => &[ + ('!', '\''), + ('"', 'Á'), + ('#', '+'), + ('$', '!'), + ('&', '='), + ('(', ')'), + (')', 'Ö'), + ('*', '('), + ('+', 'Ó'), + ('/', 'ü'), + ('0', 'ö'), + (':', 'É'), + (';', 'é'), + ('<', 'Ü'), + ('=', 'ó'), + ('>', ':'), + ('@', '"'), + ('[', 'ő'), + ('\'', 'á'), + ('\\', 'ű'), + (']', 'ú'), + ('^', '/'), + ('`', 'í'), + ('{', 'Ő'), + ('|', 'Ű'), + ('}', 'Ú'), + ('~', 'Í'), + ], + "com.apple.keylayout.Icelandic" => &[ + ('"', 'Ö'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Ð'), + (';', 'ð'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'æ'), + ('\'', 'ö'), + ('\\', 'þ'), + (']', '´'), + ('^', '&'), + ('`', '<'), + ('{', 'Æ'), + ('|', 'Þ'), + ('}', '´'), + ('~', '>'), + ], + "com.apple.keylayout.Irish" => &[('#', '£')], + "com.apple.keylayout.IrishExtended" => &[('#', '£')], + "com.apple.keylayout.Italian" => &[ + ('!', '1'), + ('"', '%'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + (',', ';'), + ('.', ':'), + ('/', ','), + ('0', 'é'), + ('1', '&'), + ('2', '"'), + ('3', '\''), + ('4', '('), + ('5', 'ç'), + ('6', 'è'), + ('7', ')'), + ('8', '£'), + ('9', 'à'), + (':', '!'), + (';', 'ò'), + ('<', '.'), + ('>', '/'), + ('@', '2'), + ('[', 'ì'), + ('\'', 'ù'), + ('\\', '§'), + (']', '$'), + ('^', '6'), + ('`', '<'), + ('{', '^'), + ('|', '°'), + ('}', '*'), + ('~', '>'), + ], + "com.apple.keylayout.Italian-Pro" => &[ + ('"', '^'), + ('#', '£'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'é'), + (';', 'è'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ò'), + ('\'', 'ì'), + ('\\', 'ù'), + (']', 'à'), + ('^', '&'), + ('`', '<'), + ('{', 'ç'), + ('|', '§'), + ('}', '°'), + ('~', '>'), + ], + "com.apple.keylayout.LatinAmerican" => &[ + ('"', '¨'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'Ñ'), + (';', 'ñ'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', '{'), + ('\'', '´'), + ('\\', '¿'), + (']', '}'), + ('^', '&'), + ('`', '<'), + ('{', '['), + ('|', '¡'), + ('}', ']'), + ('~', '>'), + ], + "com.apple.keylayout.Lithuanian" => &[ + ('!', 'Ą'), + ('#', 'Ę'), + ('$', 'Ė'), + ('%', 'Į'), + ('&', 'Ų'), + ('*', 'Ū'), + ('+', 'Ž'), + ('1', 'ą'), + ('2', 'č'), + ('3', 'ę'), + ('4', 'ė'), + ('5', 'į'), + ('6', 'š'), + ('7', 'ų'), + ('8', 'ū'), + ('=', 'ž'), + ('@', 'Č'), + ('^', 'Š'), + ], + "com.apple.keylayout.Maltese" => &[ + ('#', '£'), + ('[', 'ġ'), + (']', 'ħ'), + ('`', 'ż'), + ('{', 'Ġ'), + ('}', 'Ħ'), + ('~', 'Ż'), + ], + "com.apple.keylayout.NorthernSami" => &[ + ('"', 'Ŋ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('Q', 'Á'), + ('W', 'Š'), + ('X', 'Č'), + ('[', 'ø'), + ('\'', 'ŋ'), + ('\\', 'đ'), + (']', 'æ'), + ('^', '&'), + ('`', 'ž'), + ('q', 'á'), + ('w', 'š'), + ('x', 'č'), + ('{', 'Ø'), + ('|', 'Đ'), + ('}', 'Æ'), + ('~', 'Ž'), + ], + "com.apple.keylayout.Norwegian" => &[ + ('"', '^'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\'', '¨'), + ('\\', '@'), + (']', 'æ'), + ('^', '&'), + ('`', '<'), + ('{', 'Ø'), + ('|', '*'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.NorwegianExtended" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\\', '@'), + (']', 'æ'), + ('`', '<'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.NorwegianSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ø'), + ('\'', '¨'), + ('\\', '@'), + (']', 'æ'), + ('^', '&'), + ('`', '<'), + ('{', 'Ø'), + ('|', '*'), + ('}', 'Æ'), + ('~', '>'), + ], + "com.apple.keylayout.Polish" => &[ + ('!', '§'), + ('"', 'ę'), + ('#', '!'), + ('$', '?'), + ('%', '+'), + ('&', ':'), + ('(', '/'), + (')', '"'), + ('*', '_'), + ('+', ']'), + (',', '.'), + ('.', ','), + ('/', 'ż'), + (':', 'Ł'), + (';', 'ł'), + ('<', 'ś'), + ('=', '['), + ('>', 'ń'), + ('?', 'Ż'), + ('@', '%'), + ('[', 'ó'), + ('\'', 'ą'), + ('\\', ';'), + (']', '('), + ('^', '='), + ('_', 'ć'), + ('`', '<'), + ('{', 'ź'), + ('|', '$'), + ('}', ')'), + ('~', '>'), + ], + "com.apple.keylayout.Portuguese" => &[ + ('"', '`'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '\''), + (':', 'ª'), + (';', 'º'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'ç'), + ('\'', '´'), + (']', '~'), + ('^', '&'), + ('`', '<'), + ('{', 'Ç'), + ('}', '^'), + ('~', '>'), + ], + "com.apple.keylayout.Sami-PC" => &[ + ('"', 'Ŋ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('Q', 'Á'), + ('W', 'Š'), + ('X', 'Č'), + ('[', 'ø'), + ('\'', 'ŋ'), + ('\\', 'đ'), + (']', 'æ'), + ('^', '&'), + ('`', 'ž'), + ('q', 'á'), + ('w', 'š'), + ('x', 'č'), + ('{', 'Ø'), + ('|', 'Đ'), + ('}', 'Æ'), + ('~', 'Ž'), + ], + "com.apple.keylayout.Serbian-Latin" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Slovak" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ľ'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ť'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ô'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', 'ä'), + ('^', '6'), + ('`', 'ň'), + ('{', 'Ú'), + ('}', 'Ä'), + ('~', 'Ň'), + ], + "com.apple.keylayout.Slovak-QWERTY" => &[ + ('!', '1'), + ('"', '!'), + ('#', '3'), + ('$', '4'), + ('%', '5'), + ('&', '7'), + ('(', '9'), + (')', '0'), + ('*', '8'), + ('+', '%'), + ('/', '\''), + ('0', 'é'), + ('1', '+'), + ('2', 'ľ'), + ('3', 'š'), + ('4', 'č'), + ('5', 'ť'), + ('6', 'ž'), + ('7', 'ý'), + ('8', 'á'), + ('9', 'í'), + (':', '"'), + (';', 'ô'), + ('<', '?'), + ('>', ':'), + ('?', 'ˇ'), + ('@', '2'), + ('[', 'ú'), + ('\'', '§'), + (']', 'ä'), + ('^', '6'), + ('`', 'ň'), + ('{', 'Ú'), + ('}', 'Ä'), + ('~', 'Ň'), + ], + "com.apple.keylayout.Slovenian" => &[ + ('"', 'Ć'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (':', 'Č'), + (';', 'č'), + ('<', ';'), + ('=', '*'), + ('>', ':'), + ('@', '"'), + ('[', 'š'), + ('\'', 'ć'), + ('\\', 'ž'), + (']', 'đ'), + ('^', '&'), + ('`', '<'), + ('{', 'Š'), + ('|', 'Ž'), + ('}', 'Đ'), + ('~', '>'), + ], + "com.apple.keylayout.Spanish" => &[ + ('!', '¡'), + ('"', '¨'), + ('.', 'ç'), + ('/', '.'), + (':', 'º'), + (';', '´'), + ('<', '¿'), + ('>', 'Ç'), + ('@', '!'), + ('[', 'ñ'), + ('\'', '`'), + ('\\', '\''), + (']', ';'), + ('^', '/'), + ('`', '<'), + ('{', 'Ñ'), + ('|', '"'), + ('}', ':'), + ('~', '>'), + ], + "com.apple.keylayout.Spanish-ISO" => &[ + ('"', '¨'), + ('#', '·'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('.', 'ç'), + ('/', '.'), + (':', 'º'), + (';', '´'), + ('<', '¿'), + ('>', 'Ç'), + ('@', '"'), + ('[', 'ñ'), + ('\'', '`'), + ('\\', '\''), + (']', ';'), + ('^', '&'), + ('`', '<'), + ('{', 'Ñ'), + ('|', '"'), + ('}', '`'), + ('~', '>'), + ], + "com.apple.keylayout.Swedish" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.Swedish-Pro" => &[ + ('"', '^'), + ('$', '€'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '\''), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwedishSami-PC" => &[ + ('"', 'ˆ'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('/', '´'), + (':', 'Å'), + (';', 'å'), + ('<', ';'), + ('=', '`'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '¨'), + ('\\', '@'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'Ö'), + ('|', '*'), + ('}', 'Ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwissFrench" => &[ + ('!', '+'), + ('"', '`'), + ('#', '*'), + ('$', 'ç'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', '!'), + ('/', '\''), + (':', 'ü'), + (';', 'è'), + ('<', ';'), + ('=', '¨'), + ('>', ':'), + ('@', '"'), + ('[', 'é'), + ('\'', '^'), + ('\\', '$'), + (']', 'à'), + ('^', '&'), + ('`', '<'), + ('{', 'ö'), + ('|', '£'), + ('}', 'ä'), + ('~', '>'), + ], + "com.apple.keylayout.SwissGerman" => &[ + ('!', '+'), + ('"', '`'), + ('#', '*'), + ('$', 'ç'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', '!'), + ('/', '\''), + (':', 'è'), + (';', 'ü'), + ('<', ';'), + ('=', '¨'), + ('>', ':'), + ('@', '"'), + ('[', 'ö'), + ('\'', '^'), + ('\\', '$'), + (']', 'ä'), + ('^', '&'), + ('`', '<'), + ('{', 'é'), + ('|', '£'), + ('}', 'à'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish" => &[ + ('"', '-'), + ('#', '"'), + ('$', '\''), + ('%', '('), + ('&', ')'), + ('(', '%'), + (')', ':'), + ('*', '_'), + (',', 'ö'), + ('-', 'ş'), + ('.', 'ç'), + ('/', '.'), + (':', '$'), + ('<', 'Ö'), + ('>', 'Ç'), + ('@', '*'), + ('[', 'ğ'), + ('\'', ','), + ('\\', 'ü'), + (']', 'ı'), + ('^', '/'), + ('_', 'Ş'), + ('`', '<'), + ('{', 'Ğ'), + ('|', 'Ü'), + ('}', 'I'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish-QWERTY-PC" => &[ + ('"', 'I'), + ('#', '^'), + ('$', '+'), + ('&', '/'), + ('(', ')'), + (')', '='), + ('*', '('), + ('+', ':'), + (',', 'ö'), + ('.', 'ç'), + ('/', '*'), + (':', 'Ş'), + (';', 'ş'), + ('<', 'Ö'), + ('=', '.'), + ('>', 'Ç'), + ('@', '\''), + ('[', 'ğ'), + ('\'', 'ı'), + ('\\', ','), + (']', 'ü'), + ('^', '&'), + ('`', '<'), + ('{', 'Ğ'), + ('|', ';'), + ('}', 'Ü'), + ('~', '>'), + ], + "com.apple.keylayout.Turkish-Standard" => &[ + ('"', 'Ş'), + ('#', '^'), + ('&', '\''), + ('(', ')'), + (')', '='), + ('*', '('), + (',', '.'), + ('.', ','), + (':', 'Ç'), + (';', 'ç'), + ('<', ':'), + ('=', '*'), + ('>', ';'), + ('@', '"'), + ('[', 'ğ'), + ('\'', 'ş'), + ('\\', 'ü'), + (']', 'ı'), + ('^', '&'), + ('`', 'ö'), + ('{', 'Ğ'), + ('|', 'Ü'), + ('}', 'I'), + ('~', 'Ö'), + ], + "com.apple.keylayout.Turkmen" => &[ + ('C', 'Ç'), + ('Q', 'Ä'), + ('V', 'Ý'), + ('X', 'Ü'), + ('[', 'ň'), + ('\\', 'ş'), + (']', 'ö'), + ('^', '№'), + ('`', 'ž'), + ('c', 'ç'), + ('q', 'ä'), + ('v', 'ý'), + ('x', 'ü'), + ('{', 'Ň'), + ('|', 'Ş'), + ('}', 'Ö'), + ('~', 'Ž'), + ], + "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')], + "com.apple.keylayout.Welsh" => &[('#', '£')], + + _ => return None, + }; + + Some(HashMap::from_iter(mappings.iter().cloned())) +} diff --git a/crates/gpui/src/platform/mac/platform.rs b/crates/gpui/src/platform/mac/platform.rs index 832550dc46..30453def00 100644 --- a/crates/gpui/src/platform/mac/platform.rs +++ b/crates/gpui/src/platform/mac/platform.rs @@ -1,5 +1,5 @@ use super::{ - BoolExt, MacKeyboardLayout, + BoolExt, MacKeyboardLayout, MacKeyboardMapper, attributed_string::{NSAttributedString, NSMutableAttributedString}, events::key_to_native, renderer, @@ -8,8 +8,9 @@ use crate::{ Action, AnyWindowHandle, BackgroundExecutor, ClipboardEntry, ClipboardItem, ClipboardString, CursorStyle, ForegroundExecutor, Image, ImageFormat, KeyContext, Keymap, MacDispatcher, MacDisplay, MacWindow, Menu, MenuItem, OsMenu, OwnedMenu, PathPromptOptions, Platform, - PlatformDisplay, PlatformKeyboardLayout, PlatformTextSystem, PlatformWindow, Result, - SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, hash, + PlatformDisplay, PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, + PlatformWindow, Result, SemanticVersion, SystemMenuType, Task, WindowAppearance, WindowParams, + hash, }; use anyhow::{Context as _, anyhow}; use block::ConcreteBlock; @@ -171,6 +172,7 @@ pub(crate) struct MacPlatformState { finish_launching: Option>, dock_menu: Option, menus: Option>, + keyboard_mapper: Rc, } impl Default for MacPlatform { @@ -189,6 +191,9 @@ impl MacPlatform { #[cfg(not(feature = "font-kit"))] let text_system = Arc::new(crate::NoopTextSystem::new()); + let keyboard_layout = MacKeyboardLayout::new(); + let keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); + Self(Mutex::new(MacPlatformState { headless, text_system, @@ -209,6 +214,7 @@ impl MacPlatform { dock_menu: None, on_keyboard_layout_change: None, menus: None, + keyboard_mapper, })) } @@ -348,19 +354,19 @@ impl MacPlatform { let mut mask = NSEventModifierFlags::empty(); for (modifier, flag) in &[ ( - keystroke.modifiers.platform, + keystroke.display_modifiers.platform, NSEventModifierFlags::NSCommandKeyMask, ), ( - keystroke.modifiers.control, + keystroke.display_modifiers.control, NSEventModifierFlags::NSControlKeyMask, ), ( - keystroke.modifiers.alt, + keystroke.display_modifiers.alt, NSEventModifierFlags::NSAlternateKeyMask, ), ( - keystroke.modifiers.shift, + keystroke.display_modifiers.shift, NSEventModifierFlags::NSShiftKeyMask, ), ] { @@ -373,7 +379,7 @@ impl MacPlatform { .initWithTitle_action_keyEquivalent_( ns_string(name), selector, - ns_string(key_to_native(&keystroke.key).as_ref()), + ns_string(key_to_native(&keystroke.display_key).as_ref()), ) .autorelease(); if Self::os_version() >= SemanticVersion::new(12, 0, 0) { @@ -882,6 +888,10 @@ impl Platform for MacPlatform { Box::new(MacKeyboardLayout::new()) } + fn keyboard_mapper(&self) -> Rc { + self.0.lock().keyboard_mapper.clone() + } + fn app_path(&self) -> Result { unsafe { let bundle: id = NSBundle::mainBundle(); @@ -1393,6 +1403,8 @@ extern "C" fn will_terminate(this: &mut Object, _: Sel, _: id) { extern "C" fn on_keyboard_layout_change(this: &mut Object, _: Sel, _: id) { let platform = unsafe { get_mac_platform(this) }; let mut lock = platform.0.lock(); + let keyboard_layout = MacKeyboardLayout::new(); + lock.keyboard_mapper = Rc::new(MacKeyboardMapper::new(keyboard_layout.id())); if let Some(mut callback) = lock.on_keyboard_layout_change.take() { drop(lock); callback(); diff --git a/crates/gpui/src/platform/test/platform.rs b/crates/gpui/src/platform/test/platform.rs index 00afcd81b5..15b909199f 100644 --- a/crates/gpui/src/platform/test/platform.rs +++ b/crates/gpui/src/platform/test/platform.rs @@ -1,8 +1,9 @@ use crate::{ AnyWindowHandle, BackgroundExecutor, ClipboardItem, CursorStyle, DevicePixels, - ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, PlatformKeyboardLayout, - PlatformTextSystem, PromptButton, ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, - SourceMetadata, Task, TestDisplay, TestWindow, WindowAppearance, WindowParams, size, + DummyKeyboardMapper, ForegroundExecutor, Keymap, NoopTextSystem, Platform, PlatformDisplay, + PlatformKeyboardLayout, PlatformKeyboardMapper, PlatformTextSystem, PromptButton, + ScreenCaptureFrame, ScreenCaptureSource, ScreenCaptureStream, SourceMetadata, Task, + TestDisplay, TestWindow, WindowAppearance, WindowParams, size, }; use anyhow::Result; use collections::VecDeque; @@ -237,6 +238,10 @@ impl Platform for TestPlatform { Box::new(TestKeyboardLayout) } + fn keyboard_mapper(&self) -> Rc { + Rc::new(DummyKeyboardMapper) + } + fn on_keyboard_layout_change(&self, _: Box) {} fn run(&self, _on_finish_launching: Box) { diff --git a/crates/gpui/src/platform/windows/keyboard.rs b/crates/gpui/src/platform/windows/keyboard.rs index 371feb70c2..0eb97fbb0c 100644 --- a/crates/gpui/src/platform/windows/keyboard.rs +++ b/crates/gpui/src/platform/windows/keyboard.rs @@ -1,22 +1,31 @@ use anyhow::Result; +use collections::HashMap; use windows::Win32::UI::{ Input::KeyboardAndMouse::{ - GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MapVirtualKeyW, ToUnicode, VIRTUAL_KEY, VK_0, - VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, VK_CONTROL, VK_MENU, - VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, VK_OEM_8, VK_OEM_102, - VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT, + GetKeyboardLayoutNameW, MAPVK_VK_TO_CHAR, MAPVK_VK_TO_VSC, MapVirtualKeyW, ToUnicode, + VIRTUAL_KEY, VK_0, VK_1, VK_2, VK_3, VK_4, VK_5, VK_6, VK_7, VK_8, VK_9, VK_ABNT_C1, + VK_CONTROL, VK_MENU, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5, VK_OEM_6, VK_OEM_7, + VK_OEM_8, VK_OEM_102, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_SHIFT, }, WindowsAndMessaging::KL_NAMELENGTH, }; use windows_core::HSTRING; -use crate::{Modifiers, PlatformKeyboardLayout}; +use crate::{ + KeybindingKeystroke, Keystroke, Modifiers, PlatformKeyboardLayout, PlatformKeyboardMapper, +}; pub(crate) struct WindowsKeyboardLayout { id: String, name: String, } +pub(crate) struct WindowsKeyboardMapper { + key_to_vkey: HashMap, + vkey_to_key: HashMap, + vkey_to_shifted: HashMap, +} + impl PlatformKeyboardLayout for WindowsKeyboardLayout { fn id(&self) -> &str { &self.id @@ -27,6 +36,65 @@ impl PlatformKeyboardLayout for WindowsKeyboardLayout { } } +impl PlatformKeyboardMapper for WindowsKeyboardMapper { + fn map_key_equivalent( + &self, + mut keystroke: Keystroke, + use_key_equivalents: bool, + ) -> KeybindingKeystroke { + let Some((vkey, shifted_key)) = self.get_vkey_from_key(&keystroke.key, use_key_equivalents) + else { + return KeybindingKeystroke::from_keystroke(keystroke); + }; + if shifted_key && keystroke.modifiers.shift { + log::warn!( + "Keystroke '{}' has both shift and a shifted key, this is likely a bug", + keystroke.key + ); + } + + let shift = shifted_key || keystroke.modifiers.shift; + keystroke.modifiers.shift = false; + + let Some(key) = self.vkey_to_key.get(&vkey).cloned() else { + log::error!( + "Failed to map key equivalent '{:?}' to a valid key", + keystroke + ); + return KeybindingKeystroke::from_keystroke(keystroke); + }; + + keystroke.key = if shift { + let Some(shifted_key) = self.vkey_to_shifted.get(&vkey).cloned() else { + log::error!( + "Failed to map keystroke {:?} with virtual key '{:?}' to a shifted key", + keystroke, + vkey + ); + return KeybindingKeystroke::from_keystroke(keystroke); + }; + shifted_key + } else { + key.clone() + }; + + let modifiers = Modifiers { + shift, + ..keystroke.modifiers + }; + + KeybindingKeystroke { + inner: keystroke, + display_modifiers: modifiers, + display_key: key, + } + } + + fn get_key_equivalents(&self) -> Option<&HashMap> { + None + } +} + impl WindowsKeyboardLayout { pub(crate) fn new() -> Result { let mut buffer = [0u16; KL_NAMELENGTH as usize]; @@ -48,6 +116,41 @@ impl WindowsKeyboardLayout { } } +impl WindowsKeyboardMapper { + pub(crate) fn new() -> Self { + let mut key_to_vkey = HashMap::default(); + let mut vkey_to_key = HashMap::default(); + let mut vkey_to_shifted = HashMap::default(); + for vkey in CANDIDATE_VKEYS { + if let Some(key) = get_key_from_vkey(*vkey) { + key_to_vkey.insert(key.clone(), (vkey.0, false)); + vkey_to_key.insert(vkey.0, key); + } + let scan_code = unsafe { MapVirtualKeyW(vkey.0 as u32, MAPVK_VK_TO_VSC) }; + if scan_code == 0 { + continue; + } + if let Some(shifted_key) = get_shifted_key(*vkey, scan_code) { + key_to_vkey.insert(shifted_key.clone(), (vkey.0, true)); + vkey_to_shifted.insert(vkey.0, shifted_key); + } + } + Self { + key_to_vkey, + vkey_to_key, + vkey_to_shifted, + } + } + + fn get_vkey_from_key(&self, key: &str, use_key_equivalents: bool) -> Option<(u16, bool)> { + if use_key_equivalents { + get_vkey_from_key_with_us_layout(key) + } else { + self.key_to_vkey.get(key).cloned() + } + } +} + pub(crate) fn get_keystroke_key( vkey: VIRTUAL_KEY, scan_code: u32, @@ -140,3 +243,134 @@ pub(crate) fn generate_key_char( _ => None, } } + +fn get_vkey_from_key_with_us_layout(key: &str) -> Option<(u16, bool)> { + match key { + // ` => VK_OEM_3 + "`" => Some((VK_OEM_3.0, false)), + "~" => Some((VK_OEM_3.0, true)), + "1" => Some((VK_1.0, false)), + "!" => Some((VK_1.0, true)), + "2" => Some((VK_2.0, false)), + "@" => Some((VK_2.0, true)), + "3" => Some((VK_3.0, false)), + "#" => Some((VK_3.0, true)), + "4" => Some((VK_4.0, false)), + "$" => Some((VK_4.0, true)), + "5" => Some((VK_5.0, false)), + "%" => Some((VK_5.0, true)), + "6" => Some((VK_6.0, false)), + "^" => Some((VK_6.0, true)), + "7" => Some((VK_7.0, false)), + "&" => Some((VK_7.0, true)), + "8" => Some((VK_8.0, false)), + "*" => Some((VK_8.0, true)), + "9" => Some((VK_9.0, false)), + "(" => Some((VK_9.0, true)), + "0" => Some((VK_0.0, false)), + ")" => Some((VK_0.0, true)), + "-" => Some((VK_OEM_MINUS.0, false)), + "_" => Some((VK_OEM_MINUS.0, true)), + "=" => Some((VK_OEM_PLUS.0, false)), + "+" => Some((VK_OEM_PLUS.0, true)), + "[" => Some((VK_OEM_4.0, false)), + "{" => Some((VK_OEM_4.0, true)), + "]" => Some((VK_OEM_6.0, false)), + "}" => Some((VK_OEM_6.0, true)), + "\\" => Some((VK_OEM_5.0, false)), + "|" => Some((VK_OEM_5.0, true)), + ";" => Some((VK_OEM_1.0, false)), + ":" => Some((VK_OEM_1.0, true)), + "'" => Some((VK_OEM_7.0, false)), + "\"" => Some((VK_OEM_7.0, true)), + "," => Some((VK_OEM_COMMA.0, false)), + "<" => Some((VK_OEM_COMMA.0, true)), + "." => Some((VK_OEM_PERIOD.0, false)), + ">" => Some((VK_OEM_PERIOD.0, true)), + "/" => Some((VK_OEM_2.0, false)), + "?" => Some((VK_OEM_2.0, true)), + _ => None, + } +} + +const CANDIDATE_VKEYS: &[VIRTUAL_KEY] = &[ + VK_OEM_3, + VK_OEM_MINUS, + VK_OEM_PLUS, + VK_OEM_4, + VK_OEM_5, + VK_OEM_6, + VK_OEM_1, + VK_OEM_7, + VK_OEM_COMMA, + VK_OEM_PERIOD, + VK_OEM_2, + VK_OEM_102, + VK_OEM_8, + VK_ABNT_C1, + VK_0, + VK_1, + VK_2, + VK_3, + VK_4, + VK_5, + VK_6, + VK_7, + VK_8, + VK_9, +]; + +#[cfg(test)] +mod tests { + use crate::{Keystroke, Modifiers, PlatformKeyboardMapper, WindowsKeyboardMapper}; + + #[test] + fn test_keyboard_mapper() { + let mapper = WindowsKeyboardMapper::new(); + + // Normal case + let keystroke = Keystroke { + modifiers: Modifiers::control(), + key: "a".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke.clone(), true); + assert_eq!(mapped.inner, keystroke); + assert_eq!(mapped.display_key, "a"); + assert_eq!(mapped.display_modifiers, Modifiers::control()); + + // Shifted case, ctrl-$ + let keystroke = Keystroke { + modifiers: Modifiers::control(), + key: "$".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke.clone(), true); + assert_eq!(mapped.inner, keystroke); + assert_eq!(mapped.display_key, "4"); + assert_eq!(mapped.display_modifiers, Modifiers::control_shift()); + + // Shifted case, but shift is true + let keystroke = Keystroke { + modifiers: Modifiers::control_shift(), + key: "$".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke, true); + assert_eq!(mapped.inner.modifiers, Modifiers::control()); + assert_eq!(mapped.display_key, "4"); + assert_eq!(mapped.display_modifiers, Modifiers::control_shift()); + + // Windows style + let keystroke = Keystroke { + modifiers: Modifiers::control_shift(), + key: "4".to_string(), + key_char: None, + }; + let mapped = mapper.map_key_equivalent(keystroke, true); + assert_eq!(mapped.inner.modifiers, Modifiers::control()); + assert_eq!(mapped.inner.key, "$"); + assert_eq!(mapped.display_key, "4"); + assert_eq!(mapped.display_modifiers, Modifiers::control_shift()); + } +} diff --git a/crates/gpui/src/platform/windows/platform.rs b/crates/gpui/src/platform/windows/platform.rs index 6202e05fb3..5ac2be2f23 100644 --- a/crates/gpui/src/platform/windows/platform.rs +++ b/crates/gpui/src/platform/windows/platform.rs @@ -351,6 +351,10 @@ impl Platform for WindowsPlatform { ) } + fn keyboard_mapper(&self) -> Rc { + Rc::new(WindowsKeyboardMapper::new()) + } + fn on_keyboard_layout_change(&self, callback: Box) { self.state.borrow_mut().callbacks.keyboard_layout_change = Some(callback); } diff --git a/crates/language_tools/src/key_context_view.rs b/crates/language_tools/src/key_context_view.rs index 057259d114..4140713544 100644 --- a/crates/language_tools/src/key_context_view.rs +++ b/crates/language_tools/src/key_context_view.rs @@ -4,7 +4,6 @@ use gpui::{ }; use itertools::Itertools; use serde_json::json; -use settings::get_key_equivalents; use ui::{Button, ButtonStyle}; use ui::{ ButtonCommon, Clickable, Context, FluentBuilder, InteractiveElement, Label, LabelCommon, @@ -169,7 +168,8 @@ impl Item for KeyContextView { impl Render for KeyContextView { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { use itertools::Itertools; - let key_equivalents = get_key_equivalents(cx.keyboard_layout().id()); + + let key_equivalents = cx.keyboard_mapper().get_key_equivalents(); v_flex() .id("key-context-view") .overflow_scroll() diff --git a/crates/settings/src/key_equivalents.rs b/crates/settings/src/key_equivalents.rs deleted file mode 100644 index 6580137535..0000000000 --- a/crates/settings/src/key_equivalents.rs +++ /dev/null @@ -1,1424 +0,0 @@ -use collections::HashMap; - -// On some keyboards (e.g. German QWERTZ) it is not possible to type the full ASCII range -// without using option. This means that some of our built in keyboard shortcuts do not work -// for those users. -// -// The way macOS solves this problem is to move shortcuts around so that they are all reachable, -// even if the mnemonic changes. https://developer.apple.com/documentation/swiftui/keyboardshortcut/localization-swift.struct -// -// For example, cmd-> is the "switch window" shortcut because the > key is right above tab. -// To ensure this doesn't cause problems for shortcuts defined for a QWERTY layout, apple moves -// any shortcuts defined as cmd-> to cmd-:. Coincidentally this s also the same keyboard position -// as cmd-> on a QWERTY layout. -// -// Another example is cmd-[ and cmd-], as they cannot be typed without option, those keys are remapped to cmd-ö -// and cmd-ä. These shortcuts are not in the same position as a QWERTY keyboard, because on a QWERTZ keyboard -// the + key is in the way; and shortcuts bound to cmd-+ are still typed as cmd-+ on either keyboard (though the -// specific key moves) -// -// As far as I can tell, there's no way to query the mappings Apple uses except by rendering a menu with every -// possible key combination, and inspecting the UI to see what it rendered. So that's what we did... -// -// These mappings were generated by running https://github.com/ConradIrwin/keyboard-inspector, tidying up the -// output to remove languages with no mappings and other oddities, and converting it to a less verbose representation with: -// jq -s 'map(to_entries | map({key: .key, value: [(.value | to_entries | map(.key) | join("")), (.value | to_entries | map(.value) | join(""))]}) | from_entries) | add' -// From there I used multi-cursor to produce this match statement. -#[cfg(target_os = "macos")] -pub fn get_key_equivalents(layout: &str) -> Option> { - let mappings: &[(char, char)] = match layout { - "com.apple.keylayout.ABC-AZERTY" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.ABC-QWERTZ" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Albanian" => &[ - ('"', '\''), - (':', 'Ç'), - (';', 'ç'), - ('<', ';'), - ('>', ':'), - ('@', '"'), - ('\'', '@'), - ('\\', 'ë'), - ('`', '<'), - ('|', 'Ë'), - ('~', '>'), - ], - "com.apple.keylayout.Austrian" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Azeri" => &[ - ('"', 'Ə'), - (',', 'ç'), - ('.', 'ş'), - ('/', '.'), - (':', 'I'), - (';', 'ı'), - ('<', 'Ç'), - ('>', 'Ş'), - ('?', ','), - ('W', 'Ü'), - ('[', 'ö'), - ('\'', 'ə'), - (']', 'ğ'), - ('w', 'ü'), - ('{', 'Ö'), - ('|', '/'), - ('}', 'Ğ'), - ], - "com.apple.keylayout.Belgian" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.Brazilian-ABNT2" => &[ - ('"', '`'), - ('/', 'ç'), - ('?', 'Ç'), - ('\'', '´'), - ('\\', '~'), - ('^', '¨'), - ('`', '\''), - ('|', '^'), - ('~', '"'), - ], - "com.apple.keylayout.Brazilian-Pro" => &[('^', 'ˆ'), ('~', '˜')], - "com.apple.keylayout.British" => &[('#', '£')], - "com.apple.keylayout.Canadian-CSA" => &[ - ('"', 'È'), - ('/', 'é'), - ('<', '\''), - ('>', '"'), - ('?', 'É'), - ('[', '^'), - ('\'', 'è'), - ('\\', 'à'), - (']', 'ç'), - ('`', 'ù'), - ('{', '¨'), - ('|', 'À'), - ('}', 'Ç'), - ('~', 'Ù'), - ], - "com.apple.keylayout.Croatian" => &[ - ('"', 'Ć'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Croatian-PC" => &[ - ('"', 'Ć'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Czech" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ě'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ř'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ů'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', ')'), - ('^', '6'), - ('`', '¨'), - ('{', 'Ú'), - ('}', '('), - ('~', '`'), - ], - "com.apple.keylayout.Czech-QWERTY" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ě'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ř'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ů'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', ')'), - ('^', '6'), - ('`', '¨'), - ('{', 'Ú'), - ('}', '('), - ('~', '`'), - ], - "com.apple.keylayout.Danish" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'æ'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ø'), - ('^', '&'), - ('`', '<'), - ('{', 'Æ'), - ('|', '*'), - ('}', 'Ø'), - ('~', '>'), - ], - "com.apple.keylayout.Faroese" => &[ - ('"', 'Ø'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Æ'), - (';', 'æ'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'å'), - ('\'', 'ø'), - ('\\', '\''), - (']', 'ð'), - ('^', '&'), - ('`', '<'), - ('{', 'Å'), - ('|', '*'), - ('}', 'Ð'), - ('~', '>'), - ], - "com.apple.keylayout.Finnish" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.FinnishExtended" => &[ - ('"', 'ˆ'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.FinnishSami-PC" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '@'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.French" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.French-PC" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('-', ')'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '-'), - ('7', 'è'), - ('8', '_'), - ('9', 'ç'), - (':', '§'), - (';', '!'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '*'), - (']', '$'), - ('^', '6'), - ('_', '°'), - ('`', '<'), - ('{', '¨'), - ('|', 'μ'), - ('}', '£'), - ('~', '>'), - ], - "com.apple.keylayout.French-numerical" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('.', ';'), - ('/', ':'), - ('0', 'à'), - ('1', '&'), - ('2', 'é'), - ('3', '"'), - ('4', '\''), - ('5', '('), - ('6', '§'), - ('7', 'è'), - ('8', '!'), - ('9', 'ç'), - (':', '°'), - (';', ')'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', '^'), - ('\'', 'ù'), - ('\\', '`'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '¨'), - ('|', '£'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.German" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.German-DIN-2137" => &[ - ('"', '`'), - ('#', '§'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', 'ß'), - (':', 'Ü'), - (';', 'ü'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '´'), - ('\\', '#'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '\''), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Hawaiian" => &[('\'', 'ʻ')], - "com.apple.keylayout.Hungarian" => &[ - ('!', '\''), - ('"', 'Á'), - ('#', '+'), - ('$', '!'), - ('&', '='), - ('(', ')'), - (')', 'Ö'), - ('*', '('), - ('+', 'Ó'), - ('/', 'ü'), - ('0', 'ö'), - (':', 'É'), - (';', 'é'), - ('<', 'Ü'), - ('=', 'ó'), - ('>', ':'), - ('@', '"'), - ('[', 'ő'), - ('\'', 'á'), - ('\\', 'ű'), - (']', 'ú'), - ('^', '/'), - ('`', 'í'), - ('{', 'Ő'), - ('|', 'Ű'), - ('}', 'Ú'), - ('~', 'Í'), - ], - "com.apple.keylayout.Hungarian-QWERTY" => &[ - ('!', '\''), - ('"', 'Á'), - ('#', '+'), - ('$', '!'), - ('&', '='), - ('(', ')'), - (')', 'Ö'), - ('*', '('), - ('+', 'Ó'), - ('/', 'ü'), - ('0', 'ö'), - (':', 'É'), - (';', 'é'), - ('<', 'Ü'), - ('=', 'ó'), - ('>', ':'), - ('@', '"'), - ('[', 'ő'), - ('\'', 'á'), - ('\\', 'ű'), - (']', 'ú'), - ('^', '/'), - ('`', 'í'), - ('{', 'Ő'), - ('|', 'Ű'), - ('}', 'Ú'), - ('~', 'Í'), - ], - "com.apple.keylayout.Icelandic" => &[ - ('"', 'Ö'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'Ð'), - (';', 'ð'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'æ'), - ('\'', 'ö'), - ('\\', 'þ'), - (']', '´'), - ('^', '&'), - ('`', '<'), - ('{', 'Æ'), - ('|', 'Þ'), - ('}', '´'), - ('~', '>'), - ], - "com.apple.keylayout.Irish" => &[('#', '£')], - "com.apple.keylayout.IrishExtended" => &[('#', '£')], - "com.apple.keylayout.Italian" => &[ - ('!', '1'), - ('"', '%'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - (',', ';'), - ('.', ':'), - ('/', ','), - ('0', 'é'), - ('1', '&'), - ('2', '"'), - ('3', '\''), - ('4', '('), - ('5', 'ç'), - ('6', 'è'), - ('7', ')'), - ('8', '£'), - ('9', 'à'), - (':', '!'), - (';', 'ò'), - ('<', '.'), - ('>', '/'), - ('@', '2'), - ('[', 'ì'), - ('\'', 'ù'), - ('\\', '§'), - (']', '$'), - ('^', '6'), - ('`', '<'), - ('{', '^'), - ('|', '°'), - ('}', '*'), - ('~', '>'), - ], - "com.apple.keylayout.Italian-Pro" => &[ - ('"', '^'), - ('#', '£'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'é'), - (';', 'è'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ò'), - ('\'', 'ì'), - ('\\', 'ù'), - (']', 'à'), - ('^', '&'), - ('`', '<'), - ('{', 'ç'), - ('|', '§'), - ('}', '°'), - ('~', '>'), - ], - "com.apple.keylayout.LatinAmerican" => &[ - ('"', '¨'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'Ñ'), - (';', 'ñ'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', '{'), - ('\'', '´'), - ('\\', '¿'), - (']', '}'), - ('^', '&'), - ('`', '<'), - ('{', '['), - ('|', '¡'), - ('}', ']'), - ('~', '>'), - ], - "com.apple.keylayout.Lithuanian" => &[ - ('!', 'Ą'), - ('#', 'Ę'), - ('$', 'Ė'), - ('%', 'Į'), - ('&', 'Ų'), - ('*', 'Ū'), - ('+', 'Ž'), - ('1', 'ą'), - ('2', 'č'), - ('3', 'ę'), - ('4', 'ė'), - ('5', 'į'), - ('6', 'š'), - ('7', 'ų'), - ('8', 'ū'), - ('=', 'ž'), - ('@', 'Č'), - ('^', 'Š'), - ], - "com.apple.keylayout.Maltese" => &[ - ('#', '£'), - ('[', 'ġ'), - (']', 'ħ'), - ('`', 'ż'), - ('{', 'Ġ'), - ('}', 'Ħ'), - ('~', 'Ż'), - ], - "com.apple.keylayout.NorthernSami" => &[ - ('"', 'Ŋ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('Q', 'Á'), - ('W', 'Š'), - ('X', 'Č'), - ('[', 'ø'), - ('\'', 'ŋ'), - ('\\', 'đ'), - (']', 'æ'), - ('^', '&'), - ('`', 'ž'), - ('q', 'á'), - ('w', 'š'), - ('x', 'č'), - ('{', 'Ø'), - ('|', 'Đ'), - ('}', 'Æ'), - ('~', 'Ž'), - ], - "com.apple.keylayout.Norwegian" => &[ - ('"', '^'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ø'), - ('\'', '¨'), - ('\\', '@'), - (']', 'æ'), - ('^', '&'), - ('`', '<'), - ('{', 'Ø'), - ('|', '*'), - ('}', 'Æ'), - ('~', '>'), - ], - "com.apple.keylayout.NorwegianExtended" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ø'), - ('\\', '@'), - (']', 'æ'), - ('`', '<'), - ('}', 'Æ'), - ('~', '>'), - ], - "com.apple.keylayout.NorwegianSami-PC" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ø'), - ('\'', '¨'), - ('\\', '@'), - (']', 'æ'), - ('^', '&'), - ('`', '<'), - ('{', 'Ø'), - ('|', '*'), - ('}', 'Æ'), - ('~', '>'), - ], - "com.apple.keylayout.Polish" => &[ - ('!', '§'), - ('"', 'ę'), - ('#', '!'), - ('$', '?'), - ('%', '+'), - ('&', ':'), - ('(', '/'), - (')', '"'), - ('*', '_'), - ('+', ']'), - (',', '.'), - ('.', ','), - ('/', 'ż'), - (':', 'Ł'), - (';', 'ł'), - ('<', 'ś'), - ('=', '['), - ('>', 'ń'), - ('?', 'Ż'), - ('@', '%'), - ('[', 'ó'), - ('\'', 'ą'), - ('\\', ';'), - (']', '('), - ('^', '='), - ('_', 'ć'), - ('`', '<'), - ('{', 'ź'), - ('|', '$'), - ('}', ')'), - ('~', '>'), - ], - "com.apple.keylayout.Portuguese" => &[ - ('"', '`'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '\''), - (':', 'ª'), - (';', 'º'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'ç'), - ('\'', '´'), - (']', '~'), - ('^', '&'), - ('`', '<'), - ('{', 'Ç'), - ('}', '^'), - ('~', '>'), - ], - "com.apple.keylayout.Sami-PC" => &[ - ('"', 'Ŋ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('Q', 'Á'), - ('W', 'Š'), - ('X', 'Č'), - ('[', 'ø'), - ('\'', 'ŋ'), - ('\\', 'đ'), - (']', 'æ'), - ('^', '&'), - ('`', 'ž'), - ('q', 'á'), - ('w', 'š'), - ('x', 'č'), - ('{', 'Ø'), - ('|', 'Đ'), - ('}', 'Æ'), - ('~', 'Ž'), - ], - "com.apple.keylayout.Serbian-Latin" => &[ - ('"', 'Ć'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Slovak" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ľ'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ť'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ô'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', 'ä'), - ('^', '6'), - ('`', 'ň'), - ('{', 'Ú'), - ('}', 'Ä'), - ('~', 'Ň'), - ], - "com.apple.keylayout.Slovak-QWERTY" => &[ - ('!', '1'), - ('"', '!'), - ('#', '3'), - ('$', '4'), - ('%', '5'), - ('&', '7'), - ('(', '9'), - (')', '0'), - ('*', '8'), - ('+', '%'), - ('/', '\''), - ('0', 'é'), - ('1', '+'), - ('2', 'ľ'), - ('3', 'š'), - ('4', 'č'), - ('5', 'ť'), - ('6', 'ž'), - ('7', 'ý'), - ('8', 'á'), - ('9', 'í'), - (':', '"'), - (';', 'ô'), - ('<', '?'), - ('>', ':'), - ('?', 'ˇ'), - ('@', '2'), - ('[', 'ú'), - ('\'', '§'), - (']', 'ä'), - ('^', '6'), - ('`', 'ň'), - ('{', 'Ú'), - ('}', 'Ä'), - ('~', 'Ň'), - ], - "com.apple.keylayout.Slovenian" => &[ - ('"', 'Ć'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (':', 'Č'), - (';', 'č'), - ('<', ';'), - ('=', '*'), - ('>', ':'), - ('@', '"'), - ('[', 'š'), - ('\'', 'ć'), - ('\\', 'ž'), - (']', 'đ'), - ('^', '&'), - ('`', '<'), - ('{', 'Š'), - ('|', 'Ž'), - ('}', 'Đ'), - ('~', '>'), - ], - "com.apple.keylayout.Spanish" => &[ - ('!', '¡'), - ('"', '¨'), - ('.', 'ç'), - ('/', '.'), - (':', 'º'), - (';', '´'), - ('<', '¿'), - ('>', 'Ç'), - ('@', '!'), - ('[', 'ñ'), - ('\'', '`'), - ('\\', '\''), - (']', ';'), - ('^', '/'), - ('`', '<'), - ('{', 'Ñ'), - ('|', '"'), - ('}', ':'), - ('~', '>'), - ], - "com.apple.keylayout.Spanish-ISO" => &[ - ('"', '¨'), - ('#', '·'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('.', 'ç'), - ('/', '.'), - (':', 'º'), - (';', '´'), - ('<', '¿'), - ('>', 'Ç'), - ('@', '"'), - ('[', 'ñ'), - ('\'', '`'), - ('\\', '\''), - (']', ';'), - ('^', '&'), - ('`', '<'), - ('{', 'Ñ'), - ('|', '"'), - ('}', '`'), - ('~', '>'), - ], - "com.apple.keylayout.Swedish" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.Swedish-Pro" => &[ - ('"', '^'), - ('$', '€'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '\''), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.SwedishSami-PC" => &[ - ('"', 'ˆ'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('/', '´'), - (':', 'Å'), - (';', 'å'), - ('<', ';'), - ('=', '`'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '¨'), - ('\\', '@'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'Ö'), - ('|', '*'), - ('}', 'Ä'), - ('~', '>'), - ], - "com.apple.keylayout.SwissFrench" => &[ - ('!', '+'), - ('"', '`'), - ('#', '*'), - ('$', 'ç'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('+', '!'), - ('/', '\''), - (':', 'ü'), - (';', 'è'), - ('<', ';'), - ('=', '¨'), - ('>', ':'), - ('@', '"'), - ('[', 'é'), - ('\'', '^'), - ('\\', '$'), - (']', 'à'), - ('^', '&'), - ('`', '<'), - ('{', 'ö'), - ('|', '£'), - ('}', 'ä'), - ('~', '>'), - ], - "com.apple.keylayout.SwissGerman" => &[ - ('!', '+'), - ('"', '`'), - ('#', '*'), - ('$', 'ç'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('+', '!'), - ('/', '\''), - (':', 'è'), - (';', 'ü'), - ('<', ';'), - ('=', '¨'), - ('>', ':'), - ('@', '"'), - ('[', 'ö'), - ('\'', '^'), - ('\\', '$'), - (']', 'ä'), - ('^', '&'), - ('`', '<'), - ('{', 'é'), - ('|', '£'), - ('}', 'à'), - ('~', '>'), - ], - "com.apple.keylayout.Turkish" => &[ - ('"', '-'), - ('#', '"'), - ('$', '\''), - ('%', '('), - ('&', ')'), - ('(', '%'), - (')', ':'), - ('*', '_'), - (',', 'ö'), - ('-', 'ş'), - ('.', 'ç'), - ('/', '.'), - (':', '$'), - ('<', 'Ö'), - ('>', 'Ç'), - ('@', '*'), - ('[', 'ğ'), - ('\'', ','), - ('\\', 'ü'), - (']', 'ı'), - ('^', '/'), - ('_', 'Ş'), - ('`', '<'), - ('{', 'Ğ'), - ('|', 'Ü'), - ('}', 'I'), - ('~', '>'), - ], - "com.apple.keylayout.Turkish-QWERTY-PC" => &[ - ('"', 'I'), - ('#', '^'), - ('$', '+'), - ('&', '/'), - ('(', ')'), - (')', '='), - ('*', '('), - ('+', ':'), - (',', 'ö'), - ('.', 'ç'), - ('/', '*'), - (':', 'Ş'), - (';', 'ş'), - ('<', 'Ö'), - ('=', '.'), - ('>', 'Ç'), - ('@', '\''), - ('[', 'ğ'), - ('\'', 'ı'), - ('\\', ','), - (']', 'ü'), - ('^', '&'), - ('`', '<'), - ('{', 'Ğ'), - ('|', ';'), - ('}', 'Ü'), - ('~', '>'), - ], - "com.apple.keylayout.Turkish-Standard" => &[ - ('"', 'Ş'), - ('#', '^'), - ('&', '\''), - ('(', ')'), - (')', '='), - ('*', '('), - (',', '.'), - ('.', ','), - (':', 'Ç'), - (';', 'ç'), - ('<', ':'), - ('=', '*'), - ('>', ';'), - ('@', '"'), - ('[', 'ğ'), - ('\'', 'ş'), - ('\\', 'ü'), - (']', 'ı'), - ('^', '&'), - ('`', 'ö'), - ('{', 'Ğ'), - ('|', 'Ü'), - ('}', 'I'), - ('~', 'Ö'), - ], - "com.apple.keylayout.Turkmen" => &[ - ('C', 'Ç'), - ('Q', 'Ä'), - ('V', 'Ý'), - ('X', 'Ü'), - ('[', 'ň'), - ('\\', 'ş'), - (']', 'ö'), - ('^', '№'), - ('`', 'ž'), - ('c', 'ç'), - ('q', 'ä'), - ('v', 'ý'), - ('x', 'ü'), - ('{', 'Ň'), - ('|', 'Ş'), - ('}', 'Ö'), - ('~', 'Ž'), - ], - "com.apple.keylayout.USInternational-PC" => &[('^', 'ˆ'), ('~', '˜')], - "com.apple.keylayout.Welsh" => &[('#', '£')], - - _ => return None, - }; - - Some(HashMap::from_iter(mappings.iter().cloned())) -} - -#[cfg(not(target_os = "macos"))] -pub fn get_key_equivalents(_layout: &str) -> Option> { - None -} diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index ae3f42853a..0e8303c4c1 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -3,7 +3,8 @@ use collections::{BTreeMap, HashMap, IndexMap}; use fs::Fs; use gpui::{ Action, ActionBuildError, App, InvalidKeystrokeError, KEYSTROKE_PARSE_EXPECTED_MESSAGE, - KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, Keystroke, NoAction, SharedString, + KeyBinding, KeyBindingContextPredicate, KeyBindingMetaIndex, KeybindingKeystroke, Keystroke, + NoAction, SharedString, }; use schemars::{JsonSchema, json_schema}; use serde::Deserialize; @@ -211,9 +212,6 @@ impl KeymapFile { } pub fn load(content: &str, cx: &App) -> KeymapFileLoadResult { - let key_equivalents = - crate::key_equivalents::get_key_equivalents(cx.keyboard_layout().id()); - if content.is_empty() { return KeymapFileLoadResult::Success { key_bindings: Vec::new(), @@ -255,12 +253,6 @@ impl KeymapFile { } }; - let key_equivalents = if *use_key_equivalents { - key_equivalents.as_ref() - } else { - None - }; - let mut section_errors = String::new(); if !unrecognized_fields.is_empty() { @@ -278,7 +270,7 @@ impl KeymapFile { keystrokes, action, context_predicate.clone(), - key_equivalents, + *use_key_equivalents, cx, ); match result { @@ -336,7 +328,7 @@ impl KeymapFile { keystrokes: &str, action: &KeymapAction, context: Option>, - key_equivalents: Option<&HashMap>, + use_key_equivalents: bool, cx: &App, ) -> std::result::Result { let (build_result, action_input_string) = match &action.0 { @@ -404,8 +396,9 @@ impl KeymapFile { keystrokes, action, context, - key_equivalents, + use_key_equivalents, action_input_string.map(SharedString::from), + cx.keyboard_mapper().as_ref(), ) { Ok(key_binding) => key_binding, Err(InvalidKeystrokeError { keystroke }) => { @@ -607,6 +600,7 @@ impl KeymapFile { mut operation: KeybindUpdateOperation<'a>, mut keymap_contents: String, tab_size: usize, + keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, ) -> Result { match operation { // if trying to replace a keybinding that is not user-defined, treat it as an add operation @@ -646,7 +640,7 @@ impl KeymapFile { .action_value() .context("Failed to generate target action JSON value")?; let Some((index, keystrokes_str)) = - find_binding(&keymap, &target, &target_action_value) + find_binding(&keymap, &target, &target_action_value, keyboard_mapper) else { anyhow::bail!("Failed to find keybinding to remove"); }; @@ -681,7 +675,7 @@ impl KeymapFile { .context("Failed to generate source action JSON value")?; if let Some((index, keystrokes_str)) = - find_binding(&keymap, &target, &target_action_value) + find_binding(&keymap, &target, &target_action_value, keyboard_mapper) { if target.context == source.context { // if we are only changing the keybinding (common case) @@ -781,7 +775,7 @@ impl KeymapFile { } let use_key_equivalents = from.and_then(|from| { let action_value = from.action_value().context("Failed to serialize action value. `use_key_equivalents` on new keybinding may be incorrect.").log_err()?; - let (index, _) = find_binding(&keymap, &from, &action_value)?; + let (index, _) = find_binding(&keymap, &from, &action_value, keyboard_mapper)?; Some(keymap.0[index].use_key_equivalents) }).unwrap_or(false); if use_key_equivalents { @@ -808,6 +802,7 @@ impl KeymapFile { keymap: &'b KeymapFile, target: &KeybindUpdateTarget<'a>, target_action_value: &Value, + keyboard_mapper: &dyn gpui::PlatformKeyboardMapper, ) -> Option<(usize, &'b str)> { let target_context_parsed = KeyBindingContextPredicate::parse(target.context.unwrap_or("")).ok(); @@ -823,8 +818,11 @@ impl KeymapFile { for (keystrokes_str, action) in bindings { let Ok(keystrokes) = keystrokes_str .split_whitespace() - .map(Keystroke::parse) - .collect::, _>>() + .map(|source| { + let keystroke = Keystroke::parse(source)?; + Ok(KeybindingKeystroke::new(keystroke, false, keyboard_mapper)) + }) + .collect::, InvalidKeystrokeError>>() else { continue; }; @@ -832,7 +830,7 @@ impl KeymapFile { || !keystrokes .iter() .zip(target.keystrokes) - .all(|(a, b)| a.should_match(b)) + .all(|(a, b)| a.inner.should_match(b)) { continue; } @@ -847,7 +845,7 @@ impl KeymapFile { } } -#[derive(Clone)] +#[derive(Clone, Debug)] pub enum KeybindUpdateOperation<'a> { Replace { /// Describes the keybind to create @@ -916,7 +914,7 @@ impl<'a> KeybindUpdateOperation<'a> { #[derive(Debug, Clone)] pub struct KeybindUpdateTarget<'a> { pub context: Option<&'a str>, - pub keystrokes: &'a [Keystroke], + pub keystrokes: &'a [KeybindingKeystroke], pub action_name: &'a str, pub action_arguments: Option<&'a str>, } @@ -941,6 +939,9 @@ impl<'a> KeybindUpdateTarget<'a> { fn keystrokes_unparsed(&self) -> String { let mut keystrokes = String::with_capacity(self.keystrokes.len() * 8); for keystroke in self.keystrokes { + // The reason use `keystroke.unparse()` instead of `keystroke.inner.unparse()` + // here is that, we want the user to use `ctrl-shift-4` instead of `ctrl-$` + // by default on Windows. keystrokes.push_str(&keystroke.unparse()); keystrokes.push(' '); } @@ -959,7 +960,7 @@ impl<'a> KeybindUpdateTarget<'a> { } } -#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Debug)] pub enum KeybindSource { User, Vim, @@ -1020,7 +1021,7 @@ impl From for KeyBindingMetaIndex { #[cfg(test)] mod tests { - use gpui::Keystroke; + use gpui::{DummyKeyboardMapper, KeybindingKeystroke, Keystroke}; use unindent::Unindent; use crate::{ @@ -1049,16 +1050,27 @@ mod tests { operation: KeybindUpdateOperation, expected: impl ToString, ) { - let result = KeymapFile::update_keybinding(operation, input.to_string(), 4) - .expect("Update succeeded"); + let result = KeymapFile::update_keybinding( + operation, + input.to_string(), + 4, + &gpui::DummyKeyboardMapper, + ) + .expect("Update succeeded"); pretty_assertions::assert_eq!(expected.to_string(), result); } #[track_caller] - fn parse_keystrokes(keystrokes: &str) -> Vec { + fn parse_keystrokes(keystrokes: &str) -> Vec { keystrokes .split(' ') - .map(|s| Keystroke::parse(s).expect("Keystrokes valid")) + .map(|s| { + KeybindingKeystroke::new( + Keystroke::parse(s).expect("Keystrokes valid"), + false, + &DummyKeyboardMapper, + ) + }) .collect() } diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index b73ab9ae95..1966755d62 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -1,6 +1,5 @@ mod base_keymap_setting; mod editable_setting_control; -mod key_equivalents; mod keymap_file; mod settings_file; mod settings_json; @@ -14,7 +13,6 @@ use util::asset_str; pub use base_keymap_setting::*; pub use editable_setting_control::*; -pub use key_equivalents::*; pub use keymap_file::{ KeyBindingValidator, KeyBindingValidatorRegistration, KeybindSource, KeybindUpdateOperation, KeybindUpdateTarget, KeymapFile, KeymapFileLoadResult, @@ -89,7 +87,10 @@ pub fn default_settings() -> Cow<'static, str> { #[cfg(target_os = "macos")] pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-macos.json"; -#[cfg(not(target_os = "macos"))] +#[cfg(target_os = "windows")] +pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-windows.json"; + +#[cfg(not(any(target_os = "macos", target_os = "windows")))] pub const DEFAULT_KEYMAP_PATH: &str = "keymaps/default-linux.json"; pub fn default_keymap() -> Cow<'static, str> { diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 288f59c8e0..76c7166007 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -14,9 +14,9 @@ use gpui::{ Action, AppContext as _, AsyncApp, Axis, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Global, IsZero, KeyBindingContextPredicate::{And, Descendant, Equal, Identifier, Not, NotEqual, Or}, - KeyContext, Keystroke, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful, - StyledText, Subscription, Task, TextStyleRefinement, WeakEntity, actions, anchored, deferred, - div, + KeyContext, KeybindingKeystroke, Keystroke, MouseButton, PlatformKeyboardMapper, Point, + ScrollStrategy, ScrollWheelEvent, Stateful, StyledText, Subscription, Task, + TextStyleRefinement, WeakEntity, actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; use notifications::status_toast::{StatusToast, ToastIcon}; @@ -174,7 +174,7 @@ impl FilterState { #[derive(Debug, Default, PartialEq, Eq, Clone, Hash)] struct ActionMapping { - keystrokes: Vec, + keystrokes: Vec, context: Option, } @@ -236,7 +236,7 @@ struct ConflictState { } type ConflictKeybindMapping = HashMap< - Vec, + Vec, Vec<( Option, Vec, @@ -414,12 +414,14 @@ impl Focusable for KeymapEditor { } } /// Helper function to check if two keystroke sequences match exactly -fn keystrokes_match_exactly(keystrokes1: &[Keystroke], keystrokes2: &[Keystroke]) -> bool { +fn keystrokes_match_exactly( + keystrokes1: &[KeybindingKeystroke], + keystrokes2: &[KeybindingKeystroke], +) -> bool { keystrokes1.len() == keystrokes2.len() - && keystrokes1 - .iter() - .zip(keystrokes2) - .all(|(k1, k2)| k1.key == k2.key && k1.modifiers == k2.modifiers) + && keystrokes1.iter().zip(keystrokes2).all(|(k1, k2)| { + k1.inner.key == k2.inner.key && k1.inner.modifiers == k2.inner.modifiers + }) } impl KeymapEditor { @@ -509,7 +511,7 @@ impl KeymapEditor { self.filter_editor.read(cx).text(cx) } - fn current_keystroke_query(&self, cx: &App) -> Vec { + fn current_keystroke_query(&self, cx: &App) -> Vec { match self.search_mode { SearchMode::KeyStroke { .. } => self.keystroke_editor.read(cx).keystrokes().to_vec(), SearchMode::Normal => Default::default(), @@ -530,7 +532,7 @@ impl KeymapEditor { let keystroke_query = keystroke_query .into_iter() - .map(|keystroke| keystroke.unparse()) + .map(|keystroke| keystroke.inner.unparse()) .collect::>() .join(" "); @@ -554,7 +556,7 @@ impl KeymapEditor { async fn update_matches( this: WeakEntity, action_query: String, - keystroke_query: Vec, + keystroke_query: Vec, cx: &mut AsyncApp, ) -> anyhow::Result<()> { let action_query = command_palette::normalize_action_query(&action_query); @@ -603,13 +605,15 @@ impl KeymapEditor { { let query = &keystroke_query[query_cursor]; let keystroke = &keystrokes[keystroke_cursor]; - let matches = - query.modifiers.is_subset_of(&keystroke.modifiers) - && ((query.key.is_empty() - || query.key == keystroke.key) - && query.key_char.as_ref().is_none_or( - |q_kc| q_kc == &keystroke.key, - )); + let matches = query + .inner + .modifiers + .is_subset_of(&keystroke.inner.modifiers) + && ((query.inner.key.is_empty() + || query.inner.key == keystroke.inner.key) + && query.inner.key_char.as_ref().is_none_or( + |q_kc| q_kc == &keystroke.inner.key, + )); if matches { found_count += 1; query_cursor += 1; @@ -678,7 +682,7 @@ impl KeymapEditor { .map(KeybindSource::from_meta) .unwrap_or(KeybindSource::Unknown); - let keystroke_text = ui::text_for_keystrokes(key_binding.keystrokes(), cx); + let keystroke_text = ui::text_for_keybinding_keystrokes(key_binding.keystrokes(), cx); let ui_key_binding = ui::KeyBinding::new_from_gpui(key_binding.clone(), cx) .vim_mode(source == KeybindSource::Vim); @@ -1202,8 +1206,11 @@ impl KeymapEditor { .read(cx) .get_scrollbar_offset(Axis::Vertical), )); - cx.spawn(async move |_, _| remove_keybinding(to_remove, &fs, tab_size).await) - .detach_and_notify_err(window, cx); + let keyboard_mapper = cx.keyboard_mapper().clone(); + cx.spawn(async move |_, _| { + remove_keybinding(to_remove, &fs, tab_size, keyboard_mapper.as_ref()).await + }) + .detach_and_notify_err(window, cx); } fn copy_context_to_clipboard( @@ -1422,7 +1429,7 @@ impl ProcessedBinding { .map(|keybind| keybind.get_action_mapping()) } - fn keystrokes(&self) -> Option<&[Keystroke]> { + fn keystrokes(&self) -> Option<&[KeybindingKeystroke]> { self.ui_key_binding() .map(|binding| binding.keystrokes.as_slice()) } @@ -2220,7 +2227,7 @@ impl KeybindingEditorModal { Ok(action_arguments) } - fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { + fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { let new_keystrokes = self .keybind_editor .read_with(cx, |editor, _| editor.keystrokes().to_vec()); @@ -2316,6 +2323,7 @@ impl KeybindingEditorModal { }).unwrap_or(Ok(()))?; let create = self.creating; + let keyboard_mapper = cx.keyboard_mapper().clone(); cx.spawn(async move |this, cx| { let action_name = existing_keybind.action().name; @@ -2328,6 +2336,7 @@ impl KeybindingEditorModal { new_action_args.as_deref(), &fs, tab_size, + keyboard_mapper.as_ref(), ) .await { @@ -2445,11 +2454,21 @@ impl KeybindingEditorModal { } } -fn remove_key_char(Keystroke { modifiers, key, .. }: Keystroke) -> Keystroke { - Keystroke { - modifiers, - key, - ..Default::default() +fn remove_key_char( + KeybindingKeystroke { + inner, + display_modifiers, + display_key, + }: KeybindingKeystroke, +) -> KeybindingKeystroke { + KeybindingKeystroke { + inner: Keystroke { + modifiers: inner.modifiers, + key: inner.key, + key_char: None, + }, + display_modifiers, + display_key, } } @@ -2992,6 +3011,7 @@ async fn save_keybinding_update( new_args: Option<&str>, fs: &Arc, tab_size: usize, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let keymap_contents = settings::KeymapFile::load_keymap_file(fs) .await @@ -3034,9 +3054,13 @@ async fn save_keybinding_update( let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); - let updated_keymap_contents = - settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; + let updated_keymap_contents = settings::KeymapFile::update_keybinding( + operation, + keymap_contents, + tab_size, + keyboard_mapper, + ) + .map_err(|err| anyhow::anyhow!("Could not save updated keybinding: {}", err))?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), @@ -3057,6 +3081,7 @@ async fn remove_keybinding( existing: ProcessedBinding, fs: &Arc, tab_size: usize, + keyboard_mapper: &dyn PlatformKeyboardMapper, ) -> anyhow::Result<()> { let Some(keystrokes) = existing.keystrokes() else { anyhow::bail!("Cannot remove a keybinding that does not exist"); @@ -3080,9 +3105,13 @@ async fn remove_keybinding( }; let (new_keybinding, removed_keybinding, source) = operation.generate_telemetry(); - let updated_keymap_contents = - settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) - .context("Failed to update keybinding")?; + let updated_keymap_contents = settings::KeymapFile::update_keybinding( + operation, + keymap_contents, + tab_size, + keyboard_mapper, + ) + .context("Failed to update keybinding")?; fs.write( paths::keymap_file().as_path(), updated_keymap_contents.as_bytes(), diff --git a/crates/settings_ui/src/ui_components/keystroke_input.rs b/crates/settings_ui/src/ui_components/keystroke_input.rs index 1b8010853e..ca50d5c03d 100644 --- a/crates/settings_ui/src/ui_components/keystroke_input.rs +++ b/crates/settings_ui/src/ui_components/keystroke_input.rs @@ -1,6 +1,6 @@ use gpui::{ Animation, AnimationExt, Context, EventEmitter, FocusHandle, Focusable, FontWeight, KeyContext, - Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, + KeybindingKeystroke, Keystroke, Modifiers, ModifiersChangedEvent, Subscription, Task, actions, }; use ui::{ ActiveTheme as _, Color, IconButton, IconButtonShape, IconName, IconSize, Label, LabelSize, @@ -42,8 +42,8 @@ impl PartialEq for CloseKeystrokeResult { } pub struct KeystrokeInput { - keystrokes: Vec, - placeholder_keystrokes: Option>, + keystrokes: Vec, + placeholder_keystrokes: Option>, outer_focus_handle: FocusHandle, inner_focus_handle: FocusHandle, intercept_subscription: Option, @@ -70,7 +70,7 @@ impl KeystrokeInput { const KEYSTROKE_COUNT_MAX: usize = 3; pub fn new( - placeholder_keystrokes: Option>, + placeholder_keystrokes: Option>, window: &mut Window, cx: &mut Context, ) -> Self { @@ -97,7 +97,7 @@ impl KeystrokeInput { } } - pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { + pub fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { self.keystrokes = keystrokes; self.keystrokes_changed(cx); } @@ -106,7 +106,7 @@ impl KeystrokeInput { self.search = search; } - pub fn keystrokes(&self) -> &[Keystroke] { + pub fn keystrokes(&self) -> &[KeybindingKeystroke] { if let Some(placeholders) = self.placeholder_keystrokes.as_ref() && self.keystrokes.is_empty() { @@ -116,18 +116,22 @@ impl KeystrokeInput { && self .keystrokes .last() - .is_some_and(|last| last.key.is_empty()) + .is_some_and(|last| last.display_key.is_empty()) { return &self.keystrokes[..self.keystrokes.len() - 1]; } &self.keystrokes } - fn dummy(modifiers: Modifiers) -> Keystroke { - Keystroke { - modifiers, - key: "".to_string(), - key_char: None, + fn dummy(modifiers: Modifiers) -> KeybindingKeystroke { + KeybindingKeystroke { + inner: Keystroke { + modifiers, + key: "".to_string(), + key_char: None, + }, + display_modifiers: modifiers, + display_key: "".to_string(), } } @@ -254,7 +258,7 @@ impl KeystrokeInput { self.keystrokes_changed(cx); if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() + && last.display_key.is_empty() && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX { if !self.search && !event.modifiers.modified() { @@ -263,13 +267,15 @@ impl KeystrokeInput { } if self.search { if self.previous_modifiers.modified() { - last.modifiers |= event.modifiers; + last.display_modifiers |= event.modifiers; + last.inner.modifiers |= event.modifiers; } else { self.keystrokes.push(Self::dummy(event.modifiers)); } self.previous_modifiers |= event.modifiers; } else { - last.modifiers = event.modifiers; + last.display_modifiers = event.modifiers; + last.inner.modifiers = event.modifiers; return; } } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { @@ -297,14 +303,17 @@ impl KeystrokeInput { return; } - let mut keystroke = keystroke.clone(); + let mut keystroke = + KeybindingKeystroke::new(keystroke.clone(), false, cx.keyboard_mapper().as_ref()); if let Some(last) = self.keystrokes.last() - && last.key.is_empty() + && last.display_key.is_empty() && (!self.search || self.previous_modifiers.modified()) { - let key = keystroke.key.clone(); + let display_key = keystroke.display_key.clone(); + let inner_key = keystroke.inner.key.clone(); keystroke = last.clone(); - keystroke.key = key; + keystroke.display_key = display_key; + keystroke.inner.key = inner_key; self.keystrokes.pop(); } @@ -324,11 +333,14 @@ impl KeystrokeInput { self.keystrokes_changed(cx); if self.search { - self.previous_modifiers = keystroke.modifiers; + self.previous_modifiers = keystroke.display_modifiers; return; } - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX && keystroke.modifiers.modified() { - self.keystrokes.push(Self::dummy(keystroke.modifiers)); + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX + && keystroke.display_modifiers.modified() + { + self.keystrokes + .push(Self::dummy(keystroke.display_modifiers)); } } @@ -364,7 +376,7 @@ impl KeystrokeInput { &self.keystrokes }; keystrokes.iter().map(move |keystroke| { - h_flex().children(ui::render_keystroke( + h_flex().children(ui::render_keybinding_keystroke( keystroke, Some(Color::Default), Some(rems(0.875).into()), @@ -809,9 +821,13 @@ mod tests { /// Verifies that the keystrokes match the expected strings #[track_caller] pub fn expect_keystrokes(&mut self, expected: &[&str]) -> &mut Self { - let actual = self - .input - .read_with(&self.cx, |input, _| input.keystrokes.clone()); + let actual: Vec = self.input.read_with(&self.cx, |input, _| { + input + .keystrokes + .iter() + .map(|keystroke| keystroke.inner.clone()) + .collect() + }); Self::expect_keystrokes_equal(&actual, expected); self } @@ -939,7 +955,7 @@ mod tests { } struct KeystrokeUpdateTracker { - initial_keystrokes: Vec, + initial_keystrokes: Vec, _subscription: Subscription, input: Entity, received_keystrokes_updated: bool, @@ -983,8 +999,8 @@ mod tests { ); } - fn keystrokes_str(ks: &[Keystroke]) -> String { - ks.iter().map(|ks| ks.unparse()).join(" ") + fn keystrokes_str(ks: &[KeybindingKeystroke]) -> String { + ks.iter().map(|ks| ks.inner.unparse()).join(" ") } } } diff --git a/crates/ui/src/components/keybinding.rs b/crates/ui/src/components/keybinding.rs index 1e7bb40c40..81817045dc 100644 --- a/crates/ui/src/components/keybinding.rs +++ b/crates/ui/src/components/keybinding.rs @@ -1,8 +1,8 @@ use crate::PlatformStyle; use crate::{Icon, IconName, IconSize, h_flex, prelude::*}; use gpui::{ - Action, AnyElement, App, FocusHandle, Global, IntoElement, Keystroke, Modifiers, Window, - relative, + Action, AnyElement, App, FocusHandle, Global, IntoElement, KeybindingKeystroke, Keystroke, + Modifiers, Window, relative, }; use itertools::Itertools; @@ -13,7 +13,7 @@ pub struct KeyBinding { /// More than one keystroke produces a chord. /// /// This should always contain at least one keystroke. - pub keystrokes: Vec, + pub keystrokes: Vec, /// The [`PlatformStyle`] to use when displaying this keybinding. platform_style: PlatformStyle, @@ -59,7 +59,7 @@ impl KeyBinding { cx.try_global::().is_some_and(|g| g.0) } - pub fn new(keystrokes: Vec, cx: &App) -> Self { + pub fn new(keystrokes: Vec, cx: &App) -> Self { Self { keystrokes, platform_style: PlatformStyle::platform(), @@ -99,16 +99,16 @@ impl KeyBinding { } fn render_key( - keystroke: &Keystroke, + key: &str, color: Option, platform_style: PlatformStyle, size: impl Into>, ) -> AnyElement { - let key_icon = icon_for_key(keystroke, platform_style); + let key_icon = icon_for_key(key, platform_style); match key_icon { Some(icon) => KeyIcon::new(icon, color).size(size).into_any_element(), None => { - let key = util::capitalize(&keystroke.key); + let key = util::capitalize(key); Key::new(&key, color).size(size).into_any_element() } } @@ -124,7 +124,7 @@ impl RenderOnce for KeyBinding { "KEY_BINDING-{}", self.keystrokes .iter() - .map(|k| k.key.to_string()) + .map(|k| k.display_key.to_string()) .collect::>() .join(" ") ) @@ -137,7 +137,7 @@ impl RenderOnce for KeyBinding { .py_0p5() .rounded_xs() .text_color(cx.theme().colors().text_muted) - .children(render_keystroke( + .children(render_keybinding_keystroke( keystroke, color, self.size, @@ -148,8 +148,8 @@ impl RenderOnce for KeyBinding { } } -pub fn render_keystroke( - keystroke: &Keystroke, +pub fn render_keybinding_keystroke( + keystroke: &KeybindingKeystroke, color: Option, size: impl Into>, platform_style: PlatformStyle, @@ -163,26 +163,39 @@ pub fn render_keystroke( let size = size.into(); if use_text { - let element = Key::new(keystroke_text(keystroke, platform_style, vim_mode), color) - .size(size) - .into_any_element(); + let element = Key::new( + keystroke_text( + &keystroke.display_modifiers, + &keystroke.display_key, + platform_style, + vim_mode, + ), + color, + ) + .size(size) + .into_any_element(); vec![element] } else { let mut elements = Vec::new(); elements.extend(render_modifiers( - &keystroke.modifiers, + &keystroke.display_modifiers, platform_style, color, size, true, )); - elements.push(render_key(keystroke, color, platform_style, size)); + elements.push(render_key( + &keystroke.display_key, + color, + platform_style, + size, + )); elements } } -fn icon_for_key(keystroke: &Keystroke, platform_style: PlatformStyle) -> Option { - match keystroke.key.as_str() { +fn icon_for_key(key: &str, platform_style: PlatformStyle) -> Option { + match key { "left" => Some(IconName::ArrowLeft), "right" => Some(IconName::ArrowRight), "up" => Some(IconName::ArrowUp), @@ -379,7 +392,7 @@ impl KeyIcon { /// Returns a textual representation of the key binding for the given [`Action`]. pub fn text_for_action(action: &dyn Action, window: &Window, cx: &App) -> Option { let key_binding = window.highest_precedence_binding_for_action(action)?; - Some(text_for_keystrokes(key_binding.keystrokes(), cx)) + Some(text_for_keybinding_keystrokes(key_binding.keystrokes(), cx)) } pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String { @@ -387,22 +400,50 @@ pub fn text_for_keystrokes(keystrokes: &[Keystroke], cx: &App) -> String { let vim_enabled = cx.try_global::().is_some(); keystrokes .iter() - .map(|keystroke| keystroke_text(keystroke, platform_style, vim_enabled)) + .map(|keystroke| { + keystroke_text( + &keystroke.modifiers, + &keystroke.key, + platform_style, + vim_enabled, + ) + }) .join(" ") } -pub fn text_for_keystroke(keystroke: &Keystroke, cx: &App) -> String { +pub fn text_for_keybinding_keystrokes(keystrokes: &[KeybindingKeystroke], cx: &App) -> String { let platform_style = PlatformStyle::platform(); let vim_enabled = cx.try_global::().is_some(); - keystroke_text(keystroke, platform_style, vim_enabled) + keystrokes + .iter() + .map(|keystroke| { + keystroke_text( + &keystroke.display_modifiers, + &keystroke.display_key, + platform_style, + vim_enabled, + ) + }) + .join(" ") +} + +pub fn text_for_keystroke(modifiers: &Modifiers, key: &str, cx: &App) -> String { + let platform_style = PlatformStyle::platform(); + let vim_enabled = cx.try_global::().is_some(); + keystroke_text(modifiers, key, platform_style, vim_enabled) } /// Returns a textual representation of the given [`Keystroke`]. -fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode: bool) -> String { +fn keystroke_text( + modifiers: &Modifiers, + key: &str, + platform_style: PlatformStyle, + vim_mode: bool, +) -> String { let mut text = String::new(); let delimiter = '-'; - if keystroke.modifiers.function { + if modifiers.function { match vim_mode { false => text.push_str("Fn"), true => text.push_str("fn"), @@ -411,7 +452,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.control { + if modifiers.control { match (platform_style, vim_mode) { (PlatformStyle::Mac, false) => text.push_str("Control"), (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Ctrl"), @@ -421,7 +462,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.platform { + if modifiers.platform { match (platform_style, vim_mode) { (PlatformStyle::Mac, false) => text.push_str("Command"), (PlatformStyle::Mac, true) => text.push_str("cmd"), @@ -434,7 +475,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.alt { + if modifiers.alt { match (platform_style, vim_mode) { (PlatformStyle::Mac, false) => text.push_str("Option"), (PlatformStyle::Linux | PlatformStyle::Windows, false) => text.push_str("Alt"), @@ -444,7 +485,7 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode text.push(delimiter); } - if keystroke.modifiers.shift { + if modifiers.shift { match (platform_style, vim_mode) { (_, false) => text.push_str("Shift"), (_, true) => text.push_str("shift"), @@ -453,9 +494,9 @@ fn keystroke_text(keystroke: &Keystroke, platform_style: PlatformStyle, vim_mode } if vim_mode { - text.push_str(&keystroke.key) + text.push_str(key) } else { - let key = match keystroke.key.as_str() { + let key = match key { "pageup" => "PageUp", "pagedown" => "PageDown", key => &util::capitalize(key), @@ -562,9 +603,11 @@ mod tests { #[test] fn test_text_for_keystroke() { + let keystroke = Keystroke::parse("cmd-c").unwrap(); assert_eq!( keystroke_text( - &Keystroke::parse("cmd-c").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Mac, false ), @@ -572,7 +615,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("cmd-c").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Linux, false ), @@ -580,16 +624,19 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("cmd-c").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Windows, false ), "Win-C".to_string() ); + let keystroke = Keystroke::parse("ctrl-alt-delete").unwrap(); assert_eq!( keystroke_text( - &Keystroke::parse("ctrl-alt-delete").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Mac, false ), @@ -597,7 +644,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("ctrl-alt-delete").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Linux, false ), @@ -605,16 +653,19 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("ctrl-alt-delete").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Windows, false ), "Ctrl-Alt-Delete".to_string() ); + let keystroke = Keystroke::parse("shift-pageup").unwrap(); assert_eq!( keystroke_text( - &Keystroke::parse("shift-pageup").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Mac, false ), @@ -622,7 +673,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("shift-pageup").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Linux, false, ), @@ -630,7 +682,8 @@ mod tests { ); assert_eq!( keystroke_text( - &Keystroke::parse("shift-pageup").unwrap(), + &keystroke.modifiers, + &keystroke.key, PlatformStyle::Windows, false ), diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 638e1dca0e..553444ebdb 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -1308,11 +1308,11 @@ pub fn handle_keymap_file_changes( }) .detach(); - let mut current_mapping = settings::get_key_equivalents(cx.keyboard_layout().id()); + let mut current_layout_id = cx.keyboard_layout().id().to_string(); cx.on_keyboard_layout_change(move |cx| { - let next_mapping = settings::get_key_equivalents(cx.keyboard_layout().id()); - if next_mapping != current_mapping { - current_mapping = next_mapping; + let next_layout_id = cx.keyboard_layout().id(); + if next_layout_id != current_layout_id { + current_layout_id = next_layout_id.to_string(); keyboard_layout_tx.unbounded_send(()).ok(); } }) @@ -4729,7 +4729,7 @@ mod tests { // and key strokes contain the given key bindings .into_iter() - .any(|binding| binding.keystrokes().iter().any(|k| k.key == key)), + .any(|binding| binding.keystrokes().iter().any(|k| k.display_key == key)), "On {} Failed to find {} with key binding {}", line, action.name(), diff --git a/crates/zed/src/zed/quick_action_bar/preview.rs b/crates/zed/src/zed/quick_action_bar/preview.rs index 3772104f39..fb5a75f78d 100644 --- a/crates/zed/src/zed/quick_action_bar/preview.rs +++ b/crates/zed/src/zed/quick_action_bar/preview.rs @@ -72,7 +72,10 @@ impl QuickActionBar { Tooltip::with_meta( tooltip_text, Some(open_action_for_tooltip), - format!("{} to open in a split", text_for_keystroke(&alt_click, cx)), + format!( + "{} to open in a split", + text_for_keystroke(&alt_click.modifiers, &alt_click.key, cx) + ), window, cx, ) From c5d3c7d790cdfda178aed768f00d488c45805e60 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:58:23 -0300 Subject: [PATCH 53/54] thread view: Improve agent installation UI (#36957) Release Notes: - N/A --------- Co-authored-by: Conrad Irwin --- assets/icons/terminal_ghost.svg | 4 + crates/agent_ui/src/acp/thread_view.rs | 253 ++++++++++++++----------- crates/icons/src/icons.rs | 1 + 3 files changed, 143 insertions(+), 115 deletions(-) create mode 100644 assets/icons/terminal_ghost.svg diff --git a/assets/icons/terminal_ghost.svg b/assets/icons/terminal_ghost.svg new file mode 100644 index 0000000000..7d0d0e068e --- /dev/null +++ b/assets/icons/terminal_ghost.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/agent_ui/src/acp/thread_view.rs b/crates/agent_ui/src/acp/thread_view.rs index f3b1e6ce3b..c68c3a3e93 100644 --- a/crates/agent_ui/src/acp/thread_view.rs +++ b/crates/agent_ui/src/acp/thread_view.rs @@ -43,7 +43,7 @@ use text::Anchor; use theme::ThemeSettings; use ui::{ Callout, Disclosure, Divider, DividerColor, ElevationIndex, KeyBinding, PopoverMenuHandle, - Scrollbar, ScrollbarState, SpinnerLabel, Tooltip, prelude::*, + Scrollbar, ScrollbarState, SpinnerLabel, TintColor, Tooltip, prelude::*, }; use util::{ResultExt, size::format_file_size, time::duration_alt_display}; use workspace::{CollaboratorId, Workspace}; @@ -278,6 +278,7 @@ pub struct AcpThreadView { editing_message: Option, prompt_capabilities: Rc>, is_loading_contents: bool, + install_command_markdown: Entity, _cancel_task: Option>, _subscriptions: [Subscription; 3], } @@ -391,6 +392,7 @@ impl AcpThreadView { hovered_recent_history_item: None, prompt_capabilities, is_loading_contents: false, + install_command_markdown: cx.new(|cx| Markdown::new("".into(), None, None, cx)), _subscriptions: subscriptions, _cancel_task: None, focus_handle: cx.focus_handle(), @@ -666,7 +668,12 @@ impl AcpThreadView { match &self.thread_state { ThreadState::Ready { .. } | ThreadState::Unauthenticated { .. } => "New Thread".into(), ThreadState::Loading { .. } => "Loading…".into(), - ThreadState::LoadError(_) => "Failed to load".into(), + ThreadState::LoadError(error) => match error { + LoadError::NotInstalled { .. } => format!("Install {}", self.agent.name()).into(), + LoadError::Unsupported { .. } => format!("Upgrade {}", self.agent.name()).into(), + LoadError::Exited { .. } => format!("{} Exited", self.agent.name()).into(), + LoadError::Other(_) => format!("Error Loading {}", self.agent.name()).into(), + }, } } @@ -2834,125 +2841,26 @@ impl AcpThreadView { ) } - fn render_load_error(&self, e: &LoadError, cx: &Context) -> AnyElement { - let (message, action_slot) = match e { + fn render_load_error( + &self, + e: &LoadError, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let (message, action_slot): (SharedString, _) = match e { LoadError::NotInstalled { - error_message, - install_message, + error_message: _, + install_message: _, install_command, } => { - let install_command = install_command.clone(); - let button = Button::new("install", install_message) - .tooltip(Tooltip::text(install_command.clone())) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, window, cx| { - telemetry::event!("Agent Install CLI", agent = this.agent.telemetry_id()); - - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(install_command.clone()), - full_label: install_command.clone(), - label: install_command.clone(), - command: Some(install_command.clone()), - args: Vec::new(), - command_label: install_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })); - - (error_message.clone(), Some(button.into_any_element())) + return self.render_not_installed(install_command.clone(), false, window, cx); } LoadError::Unsupported { - error_message, - upgrade_message, + error_message: _, + upgrade_message: _, upgrade_command, } => { - let upgrade_command = upgrade_command.clone(); - let button = Button::new("upgrade", upgrade_message) - .tooltip(Tooltip::text(upgrade_command.clone())) - .style(ButtonStyle::Outlined) - .label_size(LabelSize::Small) - .icon(IconName::Download) - .icon_size(IconSize::Small) - .icon_color(Color::Muted) - .icon_position(IconPosition::Start) - .on_click(cx.listener(move |this, _, window, cx| { - telemetry::event!("Agent Upgrade CLI", agent = this.agent.telemetry_id()); - - let task = this - .workspace - .update(cx, |workspace, cx| { - let project = workspace.project().read(cx); - let cwd = project.first_project_directory(cx); - let shell = project.terminal_settings(&cwd, cx).shell.clone(); - let spawn_in_terminal = task::SpawnInTerminal { - id: task::TaskId(upgrade_command.to_string()), - full_label: upgrade_command.clone(), - label: upgrade_command.clone(), - command: Some(upgrade_command.clone()), - args: Vec::new(), - command_label: upgrade_command.clone(), - cwd, - env: Default::default(), - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: Default::default(), - reveal_target: Default::default(), - hide: Default::default(), - shell, - show_summary: true, - show_command: true, - show_rerun: false, - }; - workspace.spawn_in_terminal(spawn_in_terminal, window, cx) - }) - .ok(); - let Some(task) = task else { return }; - cx.spawn_in(window, async move |this, cx| { - if let Some(Ok(_)) = task.await { - this.update_in(cx, |this, window, cx| { - this.reset(window, cx); - }) - .ok(); - } - }) - .detach() - })); - - (error_message.clone(), Some(button.into_any_element())) + return self.render_not_installed(upgrade_command.clone(), true, window, cx); } LoadError::Exited { .. } => ("Server exited with status {status}".into(), None), LoadError::Other(msg) => ( @@ -2970,6 +2878,121 @@ impl AcpThreadView { .into_any_element() } + fn install_agent(&self, install_command: String, window: &mut Window, cx: &mut Context) { + telemetry::event!("Agent Install CLI", agent = self.agent.telemetry_id()); + let task = self + .workspace + .update(cx, |workspace, cx| { + let project = workspace.project().read(cx); + let cwd = project.first_project_directory(cx); + let shell = project.terminal_settings(&cwd, cx).shell.clone(); + let spawn_in_terminal = task::SpawnInTerminal { + id: task::TaskId(install_command.clone()), + full_label: install_command.clone(), + label: install_command.clone(), + command: Some(install_command.clone()), + args: Vec::new(), + command_label: install_command.clone(), + cwd, + env: Default::default(), + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: Default::default(), + reveal_target: Default::default(), + hide: Default::default(), + shell, + show_summary: true, + show_command: true, + show_rerun: false, + }; + workspace.spawn_in_terminal(spawn_in_terminal, window, cx) + }) + .ok(); + let Some(task) = task else { return }; + cx.spawn_in(window, async move |this, cx| { + if let Some(Ok(_)) = task.await { + this.update_in(cx, |this, window, cx| { + this.reset(window, cx); + }) + .ok(); + } + }) + .detach() + } + + fn render_not_installed( + &self, + install_command: String, + is_upgrade: bool, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + self.install_command_markdown.update(cx, |markdown, cx| { + if !markdown.source().contains(&install_command) { + markdown.replace(format!("```\n{}\n```", install_command), cx); + } + }); + + let (heading_label, description_label, button_label, or_label) = if is_upgrade { + ( + "Upgrade Gemini CLI in Zed", + "Get access to the latest version with support for Zed.", + "Upgrade Gemini CLI", + "Or, to upgrade it manually:", + ) + } else { + ( + "Get Started with Gemini CLI in Zed", + "Use Google's new coding agent directly in Zed.", + "Install Gemini CLI", + "Or, to install it manually:", + ) + }; + + v_flex() + .w_full() + .p_3p5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().colors().border) + .bg(linear_gradient( + 180., + linear_color_stop(cx.theme().colors().editor_background.opacity(0.4), 4.), + linear_color_stop(cx.theme().status().info_background.opacity(0.), 0.), + )) + .child( + v_flex().gap_0p5().child(Label::new(heading_label)).child( + Label::new(description_label) + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + Button::new("install_gemini", button_label) + .full_width() + .size(ButtonSize::Medium) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .label_size(LabelSize::Small) + .icon(IconName::TerminalGhost) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .on_click(cx.listener(move |this, _, window, cx| { + this.install_agent(install_command.clone(), window, cx) + })), + ) + .child( + Label::new(or_label) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(MarkdownElement::new( + self.install_command_markdown.clone(), + default_markdown_style(false, false, window, cx), + )) + .into_any_element() + } + fn render_activity_bar( &self, thread_entity: &Entity, @@ -4943,7 +4966,7 @@ impl Render for AcpThreadView { .size_full() .items_center() .justify_end() - .child(self.render_load_error(e, cx)), + .child(self.render_load_error(e, window, cx)), ThreadState::Ready { .. } => v_flex().flex_1().map(|this| { if has_messages { this.child( diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 4fc6039fd7..f7363395ae 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -215,6 +215,7 @@ pub enum IconName { Tab, Terminal, TerminalAlt, + TerminalGhost, TextSnippet, TextThread, Thread, From bd4e943597d4b6c3ac52cd2edae2ae2e2abbec81 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:59:12 -0300 Subject: [PATCH 54/54] acp: Add onboarding modal & title bar banner (#36784) Release Notes: - N/A --------- Co-authored-by: Bennet Bo Fenner --- assets/images/acp_grid.svg | 1257 +++++++++++++++++ assets/images/acp_logo.svg | 1 + assets/images/acp_logo_serif.svg | 2 + crates/agent_ui/src/agent_configuration.rs | 2 +- crates/agent_ui/src/agent_panel.rs | 41 +- crates/agent_ui/src/ui.rs | 2 + .../agent_ui/src/ui/acp_onboarding_modal.rs | 254 ++++ crates/client/src/zed_urls.rs | 8 + crates/title_bar/src/onboarding_banner.rs | 2 +- crates/title_bar/src/title_bar.rs | 10 +- crates/ui/src/components/image.rs | 3 + crates/zed_actions/src/lib.rs | 2 + 12 files changed, 1556 insertions(+), 28 deletions(-) create mode 100644 assets/images/acp_grid.svg create mode 100644 assets/images/acp_logo.svg create mode 100644 assets/images/acp_logo_serif.svg create mode 100644 crates/agent_ui/src/ui/acp_onboarding_modal.rs diff --git a/assets/images/acp_grid.svg b/assets/images/acp_grid.svg new file mode 100644 index 0000000000..8ebff8e1bc --- /dev/null +++ b/assets/images/acp_grid.svg @@ -0,0 +1,1257 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/acp_logo.svg b/assets/images/acp_logo.svg new file mode 100644 index 0000000000..efaa46707b --- /dev/null +++ b/assets/images/acp_logo.svg @@ -0,0 +1 @@ + diff --git a/assets/images/acp_logo_serif.svg b/assets/images/acp_logo_serif.svg new file mode 100644 index 0000000000..6bc359cf82 --- /dev/null +++ b/assets/images/acp_logo_serif.svg @@ -0,0 +1,2 @@ + + diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index c279115880..224f49cc3e 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -1093,7 +1093,7 @@ impl AgentConfiguration { ) .child( Label::new( - "Use the full power of Zed's UI with your favorite agent, connected via the Agent Client Protocol.", + "Bring the agent of your choice to Zed via our new Agent Client Protocol.", ) .color(Color::Muted), ), diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 267c76d73f..d1cf748733 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -14,6 +14,7 @@ use zed_actions::agent::ReauthenticateAgent; use crate::acp::{AcpThreadHistory, ThreadHistoryEvent}; use crate::agent_diff::AgentDiffThread; +use crate::ui::AcpOnboardingModal; use crate::{ AddContextServer, AgentDiffPane, ContinueThread, ContinueWithBurnMode, DeleteRecentlyOpenThread, ExpandMessageEditor, Follow, InlineAssistant, NewTextThread, @@ -77,7 +78,10 @@ use workspace::{ }; use zed_actions::{ DecreaseBufferFontSize, IncreaseBufferFontSize, ResetBufferFontSize, - agent::{OpenOnboardingModal, OpenSettings, ResetOnboarding, ToggleModelSelector}, + agent::{ + OpenAcpOnboardingModal, OpenOnboardingModal, OpenSettings, ResetOnboarding, + ToggleModelSelector, + }, assistant::{OpenRulesLibrary, ToggleFocus}, }; @@ -201,6 +205,9 @@ pub fn init(cx: &mut App) { .register_action(|workspace, _: &OpenOnboardingModal, window, cx| { AgentOnboardingModal::toggle(workspace, window, cx) }) + .register_action(|workspace, _: &OpenAcpOnboardingModal, window, cx| { + AcpOnboardingModal::toggle(workspace, window, cx) + }) .register_action(|_workspace, _: &ResetOnboarding, window, cx| { window.dispatch_action(workspace::RestoreBanner.boxed_clone(), cx); window.refresh(); @@ -1841,19 +1848,6 @@ impl AgentPanel { menu } - pub fn set_selected_agent( - &mut self, - agent: AgentType, - window: &mut Window, - cx: &mut Context, - ) { - if self.selected_agent != agent { - self.selected_agent = agent.clone(); - self.serialize(cx); - } - self.new_agent_thread(agent, window, cx); - } - pub fn selected_agent(&self) -> AgentType { self.selected_agent.clone() } @@ -1864,6 +1858,11 @@ impl AgentPanel { window: &mut Window, cx: &mut Context, ) { + if self.selected_agent != agent { + self.selected_agent = agent.clone(); + self.serialize(cx); + } + match agent { AgentType::Zed => { window.dispatch_action( @@ -2544,7 +2543,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::NativeAgent, window, cx, @@ -2570,7 +2569,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::TextThread, window, cx, @@ -2598,7 +2597,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::Gemini, window, cx, @@ -2625,7 +2624,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::ClaudeCode, window, cx, @@ -2658,7 +2657,7 @@ impl AgentPanel { workspace.panel::(cx) { panel.update(cx, |panel, cx| { - panel.set_selected_agent( + panel.new_agent_thread( AgentType::Custom { name: agent_name .clone(), @@ -2682,9 +2681,9 @@ impl AgentPanel { }) .when(cx.has_flag::(), |menu| { menu.separator().link( - "Add Your Own Agent", + "Add Other Agents", OpenBrowser { - url: "https://agentclientprotocol.com/".into(), + url: zed_urls::external_agents_docs(cx), } .boxed_clone(), ) diff --git a/crates/agent_ui/src/ui.rs b/crates/agent_ui/src/ui.rs index ada973cddf..600698b07e 100644 --- a/crates/agent_ui/src/ui.rs +++ b/crates/agent_ui/src/ui.rs @@ -1,3 +1,4 @@ +mod acp_onboarding_modal; mod agent_notification; mod burn_mode_tooltip; mod context_pill; @@ -6,6 +7,7 @@ mod onboarding_modal; pub mod preview; mod unavailable_editing_tooltip; +pub use acp_onboarding_modal::*; pub use agent_notification::*; pub use burn_mode_tooltip::*; pub use context_pill::*; diff --git a/crates/agent_ui/src/ui/acp_onboarding_modal.rs b/crates/agent_ui/src/ui/acp_onboarding_modal.rs new file mode 100644 index 0000000000..0ed9de7221 --- /dev/null +++ b/crates/agent_ui/src/ui/acp_onboarding_modal.rs @@ -0,0 +1,254 @@ +use client::zed_urls; +use gpui::{ + ClickEvent, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, MouseDownEvent, Render, + linear_color_stop, linear_gradient, +}; +use ui::{TintColor, Vector, VectorName, prelude::*}; +use workspace::{ModalView, Workspace}; + +use crate::agent_panel::{AgentPanel, AgentType}; + +macro_rules! acp_onboarding_event { + ($name:expr) => { + telemetry::event!($name, source = "ACP Onboarding"); + }; + ($name:expr, $($key:ident $(= $value:expr)?),+ $(,)?) => { + telemetry::event!($name, source = "ACP Onboarding", $($key $(= $value)?),+); + }; +} + +pub struct AcpOnboardingModal { + focus_handle: FocusHandle, + workspace: Entity, +} + +impl AcpOnboardingModal { + pub fn toggle(workspace: &mut Workspace, window: &mut Window, cx: &mut Context) { + let workspace_entity = cx.entity(); + workspace.toggle_modal(window, cx, |_window, cx| Self { + workspace: workspace_entity, + focus_handle: cx.focus_handle(), + }); + } + + fn open_panel(&mut self, _: &ClickEvent, window: &mut Window, cx: &mut Context) { + self.workspace.update(cx, |workspace, cx| { + workspace.focus_panel::(window, cx); + + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.new_agent_thread(AgentType::Gemini, window, cx); + }); + } + }); + + cx.emit(DismissEvent); + + acp_onboarding_event!("Open Panel Clicked"); + } + + fn view_docs(&mut self, _: &ClickEvent, _: &mut Window, cx: &mut Context) { + cx.open_url(&zed_urls::external_agents_docs(cx)); + cx.notify(); + + acp_onboarding_event!("Documentation Link Clicked"); + } + + fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent); + } +} + +impl EventEmitter for AcpOnboardingModal {} + +impl Focusable for AcpOnboardingModal { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl ModalView for AcpOnboardingModal {} + +impl Render for AcpOnboardingModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let illustration_element = |label: bool, opacity: f32| { + h_flex() + .px_1() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.05)) + .border_1() + .border_color(cx.theme().colors().border) + .border_dashed() + .child( + Icon::new(IconName::Stop) + .size(IconSize::Small) + .color(Color::Custom(cx.theme().colors().text_muted.opacity(0.15))), + ) + .map(|this| { + if label { + this.child( + Label::new("Your Agent Here") + .size(LabelSize::Small) + .color(Color::Muted), + ) + } else { + this.child( + div().w_16().h_1().rounded_full().bg(cx + .theme() + .colors() + .element_active + .opacity(0.6)), + ) + } + }) + .opacity(opacity) + }; + + let illustration = h_flex() + .relative() + .h(rems_from_px(126.)) + .bg(cx.theme().colors().editor_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .justify_center() + .gap_8() + .rounded_t_md() + .overflow_hidden() + .child( + div().absolute().inset_0().w(px(515.)).h(px(126.)).child( + Vector::new(VectorName::AcpGrid, rems_from_px(515.), rems_from_px(126.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.02))), + ), + ) + .child(div().absolute().inset_0().size_full().bg(linear_gradient( + 0., + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.1), + 0.9, + ), + linear_color_stop( + cx.theme().colors().elevated_surface_background.opacity(0.), + 0., + ), + ))) + .child( + div() + .absolute() + .inset_0() + .size_full() + .bg(gpui::black().opacity(0.15)), + ) + .child( + h_flex() + .gap_4() + .child( + Vector::new(VectorName::AcpLogo, rems_from_px(106.), rems_from_px(40.)) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ) + .child( + Vector::new( + VectorName::AcpLogoSerif, + rems_from_px(111.), + rems_from_px(41.), + ) + .color(ui::Color::Custom(cx.theme().colors().text.opacity(0.8))), + ), + ) + .child( + v_flex() + .gap_1p5() + .child(illustration_element(false, 0.15)) + .child(illustration_element(true, 0.3)) + .child( + h_flex() + .pl_1() + .pr_2() + .py_0p5() + .gap_1() + .rounded_sm() + .bg(cx.theme().colors().element_active.opacity(0.2)) + .border_1() + .border_color(cx.theme().colors().border) + .child( + Icon::new(IconName::AiGemini) + .size(IconSize::Small) + .color(Color::Muted), + ) + .child(Label::new("New Gemini CLI Thread").size(LabelSize::Small)), + ) + .child(illustration_element(true, 0.3)) + .child(illustration_element(false, 0.15)), + ); + + let heading = v_flex() + .w_full() + .gap_1() + .child( + Label::new("Now Available") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child(Headline::new("Bring Your Own Agent to Zed").size(HeadlineSize::Large)); + + let copy = "Bring the agent of your choice to Zed via our new Agent Client Protocol (ACP), starting with Google's Gemini CLI integration."; + + let open_panel_button = Button::new("open-panel", "Start with Gemini CLI") + .icon_size(IconSize::Indicator) + .style(ButtonStyle::Tinted(TintColor::Accent)) + .full_width() + .on_click(cx.listener(Self::open_panel)); + + let docs_button = Button::new("add-other-agents", "Add Other Agents") + .icon(IconName::ArrowUpRight) + .icon_size(IconSize::Indicator) + .icon_color(Color::Muted) + .full_width() + .on_click(cx.listener(Self::view_docs)); + + let close_button = h_flex().absolute().top_2().right_2().child( + IconButton::new("cancel", IconName::Close).on_click(cx.listener( + |_, _: &ClickEvent, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "X click"); + cx.emit(DismissEvent); + }, + )), + ); + + v_flex() + .id("acp-onboarding") + .key_context("AcpOnboardingModal") + .relative() + .w(rems(34.)) + .h_full() + .elevation_3(cx) + .track_focus(&self.focus_handle(cx)) + .overflow_hidden() + .on_action(cx.listener(Self::cancel)) + .on_action(cx.listener(|_, _: &menu::Cancel, _window, cx| { + acp_onboarding_event!("Canceled", trigger = "Action"); + cx.emit(DismissEvent); + })) + .on_any_mouse_down(cx.listener(|this, _: &MouseDownEvent, window, _cx| { + this.focus_handle.focus(window); + })) + .child(illustration) + .child( + v_flex() + .p_4() + .gap_2() + .child(heading) + .child(Label::new(copy).color(Color::Muted)) + .child( + v_flex() + .w_full() + .mt_2() + .gap_1() + .child(open_panel_button) + .child(docs_button), + ), + ) + .child(close_button) + } +} diff --git a/crates/client/src/zed_urls.rs b/crates/client/src/zed_urls.rs index 9df41906d7..7193c09947 100644 --- a/crates/client/src/zed_urls.rs +++ b/crates/client/src/zed_urls.rs @@ -43,3 +43,11 @@ pub fn ai_privacy_and_security(cx: &App) -> String { server_url = server_url(cx) ) } + +/// Returns the URL to Zed AI's external agents documentation. +pub fn external_agents_docs(cx: &App) -> String { + format!( + "{server_url}/docs/ai/external-agents", + server_url = server_url(cx) + ) +} diff --git a/crates/title_bar/src/onboarding_banner.rs b/crates/title_bar/src/onboarding_banner.rs index ed43c5277a..1c28942490 100644 --- a/crates/title_bar/src/onboarding_banner.rs +++ b/crates/title_bar/src/onboarding_banner.rs @@ -119,7 +119,7 @@ impl Render for OnboardingBanner { h_flex() .h_full() .gap_1() - .child(Icon::new(self.details.icon_name).size(IconSize::Small)) + .child(Icon::new(self.details.icon_name).size(IconSize::XSmall)) .child( h_flex() .gap_0p5() diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index b84a2800b6..ad64dac9c6 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -275,11 +275,11 @@ impl TitleBar { let banner = cx.new(|cx| { OnboardingBanner::new( - "Debugger Onboarding", - IconName::Debug, - "The Debugger", - None, - zed_actions::debugger::OpenOnboardingModal.boxed_clone(), + "ACP Onboarding", + IconName::Sparkle, + "Bring Your Own Agent", + Some("Introducing:".into()), + zed_actions::agent::OpenAcpOnboardingModal.boxed_clone(), cx, ) }); diff --git a/crates/ui/src/components/image.rs b/crates/ui/src/components/image.rs index 09c3bbeb94..6e552ddcee 100644 --- a/crates/ui/src/components/image.rs +++ b/crates/ui/src/components/image.rs @@ -13,6 +13,9 @@ use crate::prelude::*; )] #[strum(serialize_all = "snake_case")] pub enum VectorName { + AcpGrid, + AcpLogo, + AcpLogoSerif, AiGrid, DebuggerGrid, Grid, diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index a5223a2cdf..8f4c42ca49 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -284,6 +284,8 @@ pub mod agent { OpenSettings, /// Opens the agent onboarding modal. OpenOnboardingModal, + /// Opens the ACP onboarding modal. + OpenAcpOnboardingModal, /// Resets the agent onboarding state. ResetOnboarding, /// Starts a chat conversation with the agent.