From 5f3e7a5f917b678755a84cca6495ca8a922cb072 Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Tue, 15 Jul 2025 21:00:57 +0530 Subject: [PATCH 0001/1056] lsp: Wait for shutdown response before sending exit notification (#33417) Follow up: #18634 Closes #33328 Release Notes: - Fixed language server shutdown process to prevent race conditions and improper termination by waiting for shutdown confirmation before closing connections. --- crates/lsp/src/lsp.rs | 7 ++++--- crates/vim/src/test.rs | 4 ---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 53dc24a21a93fecee9a320a44a9b9c46655f31be..ad32d2dd34c57d5dca94b3b8ada699bb2e0a0e2a 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -874,8 +874,6 @@ impl LanguageServer { &executor, (), ); - let exit = Self::notify_internal::(&outbound_tx, &()); - outbound_tx.close(); let server = self.server.clone(); let name = self.name.clone(); @@ -901,7 +899,8 @@ impl LanguageServer { } response_handlers.lock().take(); - exit?; + Self::notify_internal::(&outbound_tx, &()).ok(); + outbound_tx.close(); output_done.recv().await; server.lock().take().map(|mut child| child.kill()); log::debug!("language server shutdown finished"); @@ -1508,6 +1507,8 @@ impl FakeLanguageServer { } }); + fake.set_request_handler::(|_, _| async move { Ok(()) }); + (server, fake) } #[cfg(target_os = "windows")] diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 2db1d4a20cb7c4162ca2e795f880ece500d88e0f..ce04b621cb91c7b6b7da57bd1e1b74e9c0e00bbc 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -1006,8 +1006,6 @@ async fn test_rename(cx: &mut gpui::TestAppContext) { cx.assert_state("const afterˇ = 2; console.log(after)", Mode::Normal) } -// TODO: this test is flaky on our linux CI machines -#[cfg(target_os = "macos")] #[gpui::test] async fn test_remap(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; @@ -1048,8 +1046,6 @@ async fn test_remap(cx: &mut gpui::TestAppContext) { cx.simulate_keystrokes("g x"); cx.assert_state("1234fooˇ56789", Mode::Normal); - cx.executor().allow_parking(); - // test command cx.update(|_, cx| { cx.bind_keys([KeyBinding::new( From d7bb1c1d0e3d0f5d0c8a14df5b208decf71d1863 Mon Sep 17 00:00:00 2001 From: teapo <75266237+4teapo@users.noreply.github.com> Date: Tue, 15 Jul 2025 17:41:45 +0200 Subject: [PATCH 0002/1056] lsp: Fix workspace diagnostics lag & add streaming support (#34022) Closes https://github.com/zed-industries/zed/issues/33980 Closes https://github.com/zed-industries/zed/discussions/33979 - Switches to the debounce task pattern for diagnostic summary computations, which most importantly lets us do them only once when a large number of DiagnosticUpdated events are received at once. - Makes workspace diagnostic requests not time out if a partial result is received. - Makes diagnostics from workspace diagnostic partial results get merged. There might be some related areas where we're not fully complying with the LSP spec but they may be outside the scope of what this PR should include. Release Notes: - Added support for streaming LSP workspace diagnostics. - Fixed editor freeze from large LSP workspace diagnostic responses. --- Cargo.lock | 1 + crates/collab/Cargo.toml | 1 + crates/collab/src/tests/editor_tests.rs | 207 ++++++++++++++++-- crates/diagnostics/src/diagnostics.rs | 13 +- crates/diagnostics/src/items.rs | 15 +- crates/lsp/src/lsp.rs | 61 +++++- crates/project/src/lsp_store.rs | 247 ++++++++++++++-------- crates/project_panel/src/project_panel.rs | 14 +- crates/workspace/src/pane.rs | 15 +- 9 files changed, 460 insertions(+), 114 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a5a1a01fe69e250a10c0fd26867d69d0337336f..de808ff263088b952c60befb84d6c6ce786a19e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3168,6 +3168,7 @@ dependencies = [ "session", "settings", "sha2", + "smol", "sqlx", "strum 0.27.1", "subtle", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index 7b536a2d24bd408d7fa49e80453ec463c95e5347..242694d96365d218060601df7030b18552ee1e9b 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -127,6 +127,7 @@ sea-orm = { version = "1.1.0-rc.1", features = ["sqlx-sqlite"] } serde_json.workspace = true session = { workspace = true, features = ["test-support"] } settings = { workspace = true, features = ["test-support"] } +smol.workspace = true sqlx = { version = "0.8", features = ["sqlite"] } task.workspace = true theme.workspace = true diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 2cc3ca76d1b639cc479cb44cde93a73570d5eb7f..73ab2b8167e9537f57f33da22d079882fb7e8818 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -2246,8 +2246,11 @@ async fn test_lsp_document_color(cx_a: &mut TestAppContext, cx_b: &mut TestAppCo }); } -#[gpui::test(iterations = 10)] -async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { +async fn test_lsp_pull_diagnostics( + should_stream_workspace_diagnostic: bool, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { let mut server = TestServer::start(cx_a.executor()).await; let executor = cx_a.executor(); let client_a = server.create_client(cx_a, "user_a").await; @@ -2396,12 +2399,25 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp let closure_workspace_diagnostics_pulls_made = workspace_diagnostics_pulls_made.clone(); let closure_workspace_diagnostics_pulls_result_ids = workspace_diagnostics_pulls_result_ids.clone(); + let (workspace_diagnostic_cancel_tx, closure_workspace_diagnostic_cancel_rx) = + smol::channel::bounded::<()>(1); + let (closure_workspace_diagnostic_received_tx, workspace_diagnostic_received_rx) = + smol::channel::bounded::<()>(1); + let expected_workspace_diagnostic_token = lsp::ProgressToken::String(format!( + "workspace/diagnostic-{}-1", + fake_language_server.server.server_id() + )); + let closure_expected_workspace_diagnostic_token = expected_workspace_diagnostic_token.clone(); let mut workspace_diagnostics_pulls_handle = fake_language_server .set_request_handler::( move |params, _| { let workspace_requests_made = closure_workspace_diagnostics_pulls_made.clone(); let workspace_diagnostics_pulls_result_ids = closure_workspace_diagnostics_pulls_result_ids.clone(); + let workspace_diagnostic_cancel_rx = closure_workspace_diagnostic_cancel_rx.clone(); + let workspace_diagnostic_received_tx = closure_workspace_diagnostic_received_tx.clone(); + let expected_workspace_diagnostic_token = + closure_expected_workspace_diagnostic_token.clone(); async move { let workspace_request_count = workspace_requests_made.fetch_add(1, atomic::Ordering::Release) + 1; @@ -2411,6 +2427,21 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp .await .extend(params.previous_result_ids.into_iter().map(|id| id.value)); } + if should_stream_workspace_diagnostic && !workspace_diagnostic_cancel_rx.is_closed() + { + assert_eq!( + params.partial_result_params.partial_result_token, + Some(expected_workspace_diagnostic_token) + ); + workspace_diagnostic_received_tx.send(()).await.unwrap(); + workspace_diagnostic_cancel_rx.recv().await.unwrap(); + workspace_diagnostic_cancel_rx.close(); + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#partialResults + // > The final response has to be empty in terms of result values. + return Ok(lsp::WorkspaceDiagnosticReportResult::Report( + lsp::WorkspaceDiagnosticReport { items: Vec::new() }, + )); + } Ok(lsp::WorkspaceDiagnosticReportResult::Report( lsp::WorkspaceDiagnosticReport { items: vec![ @@ -2479,7 +2510,11 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp }, ); - workspace_diagnostics_pulls_handle.next().await.unwrap(); + if should_stream_workspace_diagnostic { + workspace_diagnostic_received_rx.recv().await.unwrap(); + } else { + workspace_diagnostics_pulls_handle.next().await.unwrap(); + } assert_eq!( 1, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), @@ -2503,10 +2538,10 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp "Expected single diagnostic, but got: {all_diagnostics:?}" ); let diagnostic = &all_diagnostics[0]; - let expected_messages = [ - expected_workspace_pull_diagnostics_main_message, - expected_pull_diagnostic_main_message, - ]; + let mut expected_messages = vec![expected_pull_diagnostic_main_message]; + if !should_stream_workspace_diagnostic { + expected_messages.push(expected_workspace_pull_diagnostics_main_message); + } assert!( expected_messages.contains(&diagnostic.diagnostic.message.as_str()), "Expected {expected_messages:?} on the host, but got: {}", @@ -2556,6 +2591,70 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp version: None, }, ); + + if should_stream_workspace_diagnostic { + fake_language_server.notify::(&lsp::ProgressParams { + token: expected_workspace_diagnostic_token.clone(), + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![ + lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Url::from_file_path(path!("/a/main.rs")).unwrap(), + version: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: Some(format!( + "workspace_{}", + workspace_diagnostics_pulls_made + .fetch_add(1, atomic::Ordering::Release) + + 1 + )), + items: vec![lsp::Diagnostic { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 1, + }, + end: lsp::Position { + line: 0, + character: 2, + }, + }, + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: + expected_workspace_pull_diagnostics_main_message + .to_string(), + ..lsp::Diagnostic::default() + }], + }, + }, + ), + lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap(), + version: None, + full_document_diagnostic_report: + lsp::FullDocumentDiagnosticReport { + result_id: Some(format!( + "workspace_{}", + workspace_diagnostics_pulls_made + .fetch_add(1, atomic::Ordering::Release) + + 1 + )), + items: Vec::new(), + }, + }, + ), + ], + }), + ), + }); + }; + + let mut workspace_diagnostic_start_count = + workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire); + executor.run_until_parked(); editor_a_main.update(cx_a, |editor, cx| { let snapshot = editor.buffer().read(cx).snapshot(cx); @@ -2599,7 +2698,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); executor.run_until_parked(); assert_eq!( - 1, + workspace_diagnostic_start_count, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), "Workspace diagnostics should not be changed as the remote client does not initialize the workspace diagnostics pull" ); @@ -2646,7 +2745,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); executor.run_until_parked(); assert_eq!( - 1, + workspace_diagnostic_start_count, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), "The remote client still did not anything to trigger the workspace diagnostics pull" ); @@ -2673,6 +2772,75 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); } }); + + if should_stream_workspace_diagnostic { + fake_language_server.notify::(&lsp::ProgressParams { + token: expected_workspace_diagnostic_token.clone(), + value: lsp::ProgressParamsValue::WorkspaceDiagnostic( + lsp::WorkspaceDiagnosticReportResult::Report(lsp::WorkspaceDiagnosticReport { + items: vec![lsp::WorkspaceDocumentDiagnosticReport::Full( + lsp::WorkspaceFullDocumentDiagnosticReport { + uri: lsp::Url::from_file_path(path!("/a/lib.rs")).unwrap(), + version: None, + full_document_diagnostic_report: lsp::FullDocumentDiagnosticReport { + result_id: Some(format!( + "workspace_{}", + workspace_diagnostics_pulls_made + .fetch_add(1, atomic::Ordering::Release) + + 1 + )), + items: vec![lsp::Diagnostic { + range: lsp::Range { + start: lsp::Position { + line: 0, + character: 1, + }, + end: lsp::Position { + line: 0, + character: 2, + }, + }, + severity: Some(lsp::DiagnosticSeverity::ERROR), + message: expected_workspace_pull_diagnostics_lib_message + .to_string(), + ..lsp::Diagnostic::default() + }], + }, + }, + )], + }), + ), + }); + workspace_diagnostic_start_count = + workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire); + workspace_diagnostic_cancel_tx.send(()).await.unwrap(); + workspace_diagnostics_pulls_handle.next().await.unwrap(); + executor.run_until_parked(); + editor_b_lib.update(cx_b, |editor, cx| { + let snapshot = editor.buffer().read(cx).snapshot(cx); + let all_diagnostics = snapshot + .diagnostics_in_range(0..snapshot.len()) + .collect::>(); + let expected_messages = [ + expected_workspace_pull_diagnostics_lib_message, + // TODO bug: the pushed diagnostics are not being sent to the client when they open the corresponding buffer. + // expected_push_diagnostic_lib_message, + ]; + assert_eq!( + all_diagnostics.len(), + 1, + "Expected pull diagnostics, but got: {all_diagnostics:?}" + ); + for diagnostic in all_diagnostics { + assert!( + expected_messages.contains(&diagnostic.diagnostic.message.as_str()), + "The client should get both push and pull messages: {expected_messages:?}, but got: {}", + diagnostic.diagnostic.message + ); + } + }); + }; + { assert!( diagnostics_pulls_result_ids.lock().await.len() > 0, @@ -2701,7 +2869,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); workspace_diagnostics_pulls_handle.next().await.unwrap(); assert_eq!( - 2, + workspace_diagnostic_start_count + 1, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), "After client lib.rs edits, the workspace diagnostics request should follow" ); @@ -2720,7 +2888,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); workspace_diagnostics_pulls_handle.next().await.unwrap(); assert_eq!( - 3, + workspace_diagnostic_start_count + 2, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), "After client main.rs edits, the workspace diagnostics pull should follow" ); @@ -2739,7 +2907,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); workspace_diagnostics_pulls_handle.next().await.unwrap(); assert_eq!( - 4, + workspace_diagnostic_start_count + 3, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), "After host main.rs edits, the workspace diagnostics pull should follow" ); @@ -2769,7 +2937,7 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp ); workspace_diagnostics_pulls_handle.next().await.unwrap(); assert_eq!( - 5, + workspace_diagnostic_start_count + 4, workspace_diagnostics_pulls_made.load(atomic::Ordering::Acquire), "Another workspace diagnostics pull should happen after the diagnostics refresh server request" ); @@ -2840,6 +3008,19 @@ async fn test_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestApp }); } +#[gpui::test(iterations = 10)] +async fn test_non_streamed_lsp_pull_diagnostics( + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + test_lsp_pull_diagnostics(false, cx_a, cx_b).await; +} + +#[gpui::test(iterations = 10)] +async fn test_streamed_lsp_pull_diagnostics(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + test_lsp_pull_diagnostics(true, cx_a, cx_b).await; +} + #[gpui::test(iterations = 10)] async fn test_git_blame_is_forwarded(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { let mut server = TestServer::start(cx_a.executor()).await; diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index b2e0a682056356cddd077d42418a2b4fa763cffa..ba64ba0eedbbb51d9c599a48634ff88ab632a9ed 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -80,6 +80,7 @@ pub(crate) struct ProjectDiagnosticsEditor { include_warnings: bool, update_excerpts_task: Option>>, cargo_diagnostics_fetch: CargoDiagnosticsFetchState, + diagnostic_summary_update: Task<()>, _subscription: Subscription, } @@ -179,7 +180,16 @@ impl ProjectDiagnosticsEditor { path, } => { this.paths_to_update.insert(path.clone()); - this.summary = project.read(cx).diagnostic_summary(false, cx); + let project = project.clone(); + this.diagnostic_summary_update = cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + this.update(cx, |this, cx| { + this.summary = project.read(cx).diagnostic_summary(false, cx); + }) + .log_err(); + }); cx.emit(EditorEvent::TitleChanged); if this.editor.focus_handle(cx).contains_focused(window, cx) || this.focus_handle.contains_focused(window, cx) { @@ -276,6 +286,7 @@ impl ProjectDiagnosticsEditor { cancel_task: None, diagnostic_sources: Arc::new(Vec::new()), }, + diagnostic_summary_update: Task::ready(()), _subscription: project_event_subscription, }; this.update_all_diagnostics(true, window, cx); diff --git a/crates/diagnostics/src/items.rs b/crates/diagnostics/src/items.rs index 4eea5e7e1f7b2fe6d17821615461650266619392..7ac6d101f315674cec4fd07f4ad2df0830284124 100644 --- a/crates/diagnostics/src/items.rs +++ b/crates/diagnostics/src/items.rs @@ -9,6 +9,7 @@ use language::Diagnostic; use project::project_settings::{GoToDiagnosticSeverityFilter, ProjectSettings}; use settings::Settings; use ui::{Button, ButtonLike, Color, Icon, IconName, Label, Tooltip, h_flex, prelude::*}; +use util::ResultExt; use workspace::{StatusItemView, ToolbarItemEvent, Workspace, item::ItemHandle}; use crate::{Deploy, IncludeWarnings, ProjectDiagnosticsEditor}; @@ -20,6 +21,7 @@ pub struct DiagnosticIndicator { current_diagnostic: Option, _observe_active_editor: Option, diagnostics_update: Task<()>, + diagnostic_summary_update: Task<()>, } impl Render for DiagnosticIndicator { @@ -135,8 +137,16 @@ impl DiagnosticIndicator { } project::Event::DiagnosticsUpdated { .. } => { - this.summary = project.read(cx).diagnostic_summary(false, cx); - cx.notify(); + this.diagnostic_summary_update = cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + this.update(cx, |this, cx| { + this.summary = project.read(cx).diagnostic_summary(false, cx); + cx.notify(); + }) + .log_err(); + }); } _ => {} @@ -150,6 +160,7 @@ impl DiagnosticIndicator { current_diagnostic: None, _observe_active_editor: None, diagnostics_update: Task::ready(()), + diagnostic_summary_update: Task::ready(()), } } diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index ad32d2dd34c57d5dca94b3b8ada699bb2e0a0e2a..4248f910eedd2b9a242365569318ad0d9b32510b 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -1106,6 +1106,7 @@ impl LanguageServer { pub fn binary(&self) -> &LanguageServerBinary { &self.binary } + /// Sends a RPC request to the language server. /// /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) @@ -1125,16 +1126,40 @@ impl LanguageServer { ) } - fn request_internal( + /// Sends a RPC request to the language server, with a custom timer, a future which when becoming + /// ready causes the request to be timed out with the future's output message. + /// + /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#requestMessage) + pub fn request_with_timer>( + &self, + params: T::Params, + timer: U, + ) -> impl LspRequestFuture + use + where + T::Result: 'static + Send, + { + Self::request_internal_with_timer::( + &self.next_id, + &self.response_handlers, + &self.outbound_tx, + &self.executor, + timer, + params, + ) + } + + fn request_internal_with_timer( next_id: &AtomicI32, response_handlers: &Mutex>>, outbound_tx: &channel::Sender, executor: &BackgroundExecutor, + timer: U, params: T::Params, - ) -> impl LspRequestFuture + use + ) -> impl LspRequestFuture + use where T::Result: 'static + Send, T: request::Request, + U: Future, { let id = next_id.fetch_add(1, SeqCst); let message = serde_json::to_string(&Request { @@ -1179,7 +1204,6 @@ impl LanguageServer { .context("failed to write to language server's stdin"); let outbound_tx = outbound_tx.downgrade(); - let mut timeout = executor.timer(LSP_REQUEST_TIMEOUT).fuse(); let started = Instant::now(); LspRequest::new(id, async move { if let Err(e) = handle_response { @@ -1216,14 +1240,41 @@ impl LanguageServer { } } - _ = timeout => { - log::error!("Cancelled LSP request task for {method:?} id {id} which took over {LSP_REQUEST_TIMEOUT:?}"); + message = timer.fuse() => { + log::error!("Cancelled LSP request task for {method:?} id {id} {message}"); ConnectionResult::Timeout } } }) } + fn request_internal( + next_id: &AtomicI32, + response_handlers: &Mutex>>, + outbound_tx: &channel::Sender, + executor: &BackgroundExecutor, + params: T::Params, + ) -> impl LspRequestFuture + use + where + T::Result: 'static + Send, + T: request::Request, + { + Self::request_internal_with_timer::( + next_id, + response_handlers, + outbound_tx, + executor, + Self::default_request_timer(executor.clone()), + params, + ) + } + + pub fn default_request_timer(executor: BackgroundExecutor) -> impl Future { + executor + .timer(LSP_REQUEST_TIMEOUT) + .map(|_| format!("which took over {LSP_REQUEST_TIMEOUT:?}")) + } + /// Sends a RPC notification to the language server. /// /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index fd626cf2d6889668447b90cf88f963804fd65eba..e4078393ee20fb906d6501bd6820e73a46bf9c39 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -29,7 +29,7 @@ use clock::Global; use collections::{BTreeMap, BTreeSet, HashMap, HashSet, btree_map}; use futures::{ AsyncWriteExt, Future, FutureExt, StreamExt, - future::{Shared, join_all}, + future::{Either, Shared, join_all, pending, select}, select, select_biased, stream::FuturesUnordered, }; @@ -85,9 +85,11 @@ use std::{ cmp::{Ordering, Reverse}, convert::TryInto, ffi::OsStr, + future::ready, iter, mem, ops::{ControlFlow, Range}, path::{self, Path, PathBuf}, + pin::pin, rc::Rc, sync::Arc, time::{Duration, Instant}, @@ -7585,7 +7587,8 @@ impl LspStore { diagnostics, |_, _, _| false, cx, - ) + )?; + Ok(()) } pub fn merge_diagnostic_entries( @@ -9130,13 +9133,39 @@ impl LspStore { } }; - let progress = match progress.value { - lsp::ProgressParamsValue::WorkDone(progress) => progress, - lsp::ProgressParamsValue::WorkspaceDiagnostic(_) => { - return; + match progress.value { + lsp::ProgressParamsValue::WorkDone(progress) => { + self.handle_work_done_progress( + progress, + language_server_id, + disk_based_diagnostics_progress_token, + token, + cx, + ); } - }; + lsp::ProgressParamsValue::WorkspaceDiagnostic(report) => { + if let Some(LanguageServerState::Running { + workspace_refresh_task: Some(workspace_refresh_task), + .. + }) = self + .as_local_mut() + .and_then(|local| local.language_servers.get_mut(&language_server_id)) + { + workspace_refresh_task.progress_tx.try_send(()).ok(); + self.apply_workspace_diagnostic_report(language_server_id, report, cx) + } + } + } + } + fn handle_work_done_progress( + &mut self, + progress: lsp::WorkDoneProgress, + language_server_id: LanguageServerId, + disk_based_diagnostics_progress_token: Option, + token: String, + cx: &mut Context, + ) { let language_server_status = if let Some(status) = self.language_server_statuses.get_mut(&language_server_id) { status @@ -11297,13 +11326,13 @@ impl LspStore { pub fn pull_workspace_diagnostics(&mut self, server_id: LanguageServerId) { if let Some(LanguageServerState::Running { - workspace_refresh_task: Some((tx, _)), + workspace_refresh_task: Some(workspace_refresh_task), .. }) = self .as_local_mut() .and_then(|local| local.language_servers.get_mut(&server_id)) { - tx.try_send(()).ok(); + workspace_refresh_task.refresh_tx.try_send(()).ok(); } } @@ -11319,11 +11348,83 @@ impl LspStore { local.language_server_ids_for_buffer(buffer, cx) }) { if let Some(LanguageServerState::Running { - workspace_refresh_task: Some((tx, _)), + workspace_refresh_task: Some(workspace_refresh_task), .. }) = local.language_servers.get_mut(&server_id) { - tx.try_send(()).ok(); + workspace_refresh_task.refresh_tx.try_send(()).ok(); + } + } + } + + fn apply_workspace_diagnostic_report( + &mut self, + server_id: LanguageServerId, + report: lsp::WorkspaceDiagnosticReportResult, + cx: &mut Context, + ) { + let workspace_diagnostics = + GetDocumentDiagnostics::deserialize_workspace_diagnostics_report(report, server_id); + for workspace_diagnostics in workspace_diagnostics { + let LspPullDiagnostics::Response { + server_id, + uri, + diagnostics, + } = workspace_diagnostics.diagnostics + else { + continue; + }; + + let adapter = self.language_server_adapter_for_id(server_id); + let disk_based_sources = adapter + .as_ref() + .map(|adapter| adapter.disk_based_diagnostic_sources.as_slice()) + .unwrap_or(&[]); + + match diagnostics { + PulledDiagnostics::Unchanged { result_id } => { + self.merge_diagnostics( + server_id, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics: Vec::new(), + version: None, + }, + Some(result_id), + DiagnosticSourceKind::Pulled, + disk_based_sources, + |_, _, _| true, + cx, + ) + .log_err(); + } + PulledDiagnostics::Changed { + diagnostics, + result_id, + } => { + self.merge_diagnostics( + server_id, + lsp::PublishDiagnosticsParams { + uri: uri.clone(), + diagnostics, + version: workspace_diagnostics.version, + }, + result_id, + DiagnosticSourceKind::Pulled, + disk_based_sources, + |buffer, old_diagnostic, cx| match old_diagnostic.source_kind { + DiagnosticSourceKind::Pulled => { + let buffer_url = File::from_dyn(buffer.file()) + .map(|f| f.abs_path(cx)) + .and_then(|abs_path| file_path_to_lsp_url(&abs_path).ok()); + buffer_url.is_none_or(|buffer_url| buffer_url != uri) + } + DiagnosticSourceKind::Other | DiagnosticSourceKind::Pushed => true, + }, + cx, + ) + .log_err(); + } } } } @@ -11379,7 +11480,7 @@ fn subscribe_to_binary_statuses( fn lsp_workspace_diagnostics_refresh( server: Arc, cx: &mut Context<'_, LspStore>, -) -> Option<(mpsc::Sender<()>, Task<()>)> { +) -> Option { let identifier = match server.capabilities().diagnostic_provider? { lsp::DiagnosticServerCapabilities::Options(diagnostic_options) => { if !diagnostic_options.workspace_diagnostics { @@ -11396,19 +11497,22 @@ fn lsp_workspace_diagnostics_refresh( } }; - let (mut tx, mut rx) = mpsc::channel(1); - tx.try_send(()).ok(); + let (progress_tx, mut progress_rx) = mpsc::channel(1); + let (mut refresh_tx, mut refresh_rx) = mpsc::channel(1); + refresh_tx.try_send(()).ok(); let workspace_query_language_server = cx.spawn(async move |lsp_store, cx| { let mut attempts = 0; let max_attempts = 50; + let mut requests = 0; loop { - let Some(()) = rx.recv().await else { + let Some(()) = refresh_rx.recv().await else { return; }; 'request: loop { + requests += 1; if attempts > max_attempts { log::error!( "Failed to pull workspace diagnostics {max_attempts} times, aborting" @@ -11437,14 +11541,29 @@ fn lsp_workspace_diagnostics_refresh( return; }; + let token = format!("workspace/diagnostic-{}-{}", server.server_id(), requests); + + progress_rx.try_recv().ok(); + let timer = + LanguageServer::default_request_timer(cx.background_executor().clone()).fuse(); + let progress = pin!(progress_rx.recv().fuse()); let response_result = server - .request::(lsp::WorkspaceDiagnosticParams { - previous_result_ids, - identifier: identifier.clone(), - work_done_progress_params: Default::default(), - partial_result_params: Default::default(), - }) + .request_with_timer::( + lsp::WorkspaceDiagnosticParams { + previous_result_ids, + identifier: identifier.clone(), + work_done_progress_params: Default::default(), + partial_result_params: lsp::PartialResultParams { + partial_result_token: Some(lsp::ProgressToken::String(token)), + }, + }, + select(timer, progress).then(|either| match either { + Either::Left((message, ..)) => ready(message).left_future(), + Either::Right(..) => pending::().right_future(), + }), + ) .await; + // https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#diagnostic_refresh // > If a server closes a workspace diagnostic pull request the client should re-trigger the request. match response_result { @@ -11464,72 +11583,11 @@ fn lsp_workspace_diagnostics_refresh( attempts = 0; if lsp_store .update(cx, |lsp_store, cx| { - let workspace_diagnostics = - GetDocumentDiagnostics::deserialize_workspace_diagnostics_report(pulled_diagnostics, server.server_id()); - for workspace_diagnostics in workspace_diagnostics { - let LspPullDiagnostics::Response { - server_id, - uri, - diagnostics, - } = workspace_diagnostics.diagnostics - else { - continue; - }; - - let adapter = lsp_store.language_server_adapter_for_id(server_id); - let disk_based_sources = adapter - .as_ref() - .map(|adapter| adapter.disk_based_diagnostic_sources.as_slice()) - .unwrap_or(&[]); - - match diagnostics { - PulledDiagnostics::Unchanged { result_id } => { - lsp_store - .merge_diagnostics( - server_id, - lsp::PublishDiagnosticsParams { - uri: uri.clone(), - diagnostics: Vec::new(), - version: None, - }, - Some(result_id), - DiagnosticSourceKind::Pulled, - disk_based_sources, - |_, _, _| true, - cx, - ) - .log_err(); - } - PulledDiagnostics::Changed { - diagnostics, - result_id, - } => { - lsp_store - .merge_diagnostics( - server_id, - lsp::PublishDiagnosticsParams { - uri: uri.clone(), - diagnostics, - version: workspace_diagnostics.version, - }, - result_id, - DiagnosticSourceKind::Pulled, - disk_based_sources, - |buffer, old_diagnostic, cx| match old_diagnostic.source_kind { - DiagnosticSourceKind::Pulled => { - let buffer_url = File::from_dyn(buffer.file()).map(|f| f.abs_path(cx)) - .and_then(|abs_path| file_path_to_lsp_url(&abs_path).ok()); - buffer_url.is_none_or(|buffer_url| buffer_url != uri) - }, - DiagnosticSourceKind::Other - | DiagnosticSourceKind::Pushed => true, - }, - cx, - ) - .log_err(); - } - } - } + lsp_store.apply_workspace_diagnostic_report( + server.server_id(), + pulled_diagnostics, + cx, + ) }) .is_err() { @@ -11542,7 +11600,11 @@ fn lsp_workspace_diagnostics_refresh( } }); - Some((tx, workspace_query_language_server)) + Some(WorkspaceRefreshTask { + refresh_tx, + progress_tx, + task: workspace_query_language_server, + }) } fn resolve_word_completion(snapshot: &BufferSnapshot, completion: &mut Completion) { @@ -11912,6 +11974,13 @@ impl LanguageServerLogType { } } +pub struct WorkspaceRefreshTask { + refresh_tx: mpsc::Sender<()>, + progress_tx: mpsc::Sender<()>, + #[allow(dead_code)] + task: Task<()>, +} + pub enum LanguageServerState { Starting { startup: Task>>, @@ -11923,7 +11992,7 @@ pub enum LanguageServerState { adapter: Arc, server: Arc, simulate_disk_based_diagnostics_completion: Option>, - workspace_refresh_task: Option<(mpsc::Sender<()>, Task<()>)>, + workspace_refresh_task: Option, }, } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 8f4aa12354d34245003219b9fa385631a4838c25..e1d360cd976386d2e24e63b9eda05afe123dc411 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -108,6 +108,7 @@ pub struct ProjectPanel { hide_scrollbar_task: Option>, diagnostics: HashMap<(WorktreeId, PathBuf), DiagnosticSeverity>, max_width_item_index: Option, + diagnostic_summary_update: Task<()>, // We keep track of the mouse down state on entries so we don't flash the UI // in case a user clicks to open a file. mouse_down: bool, @@ -420,8 +421,16 @@ impl ProjectPanel { | project::Event::DiagnosticsUpdated { .. } => { if ProjectPanelSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off { - this.update_diagnostics(cx); - cx.notify(); + this.diagnostic_summary_update = cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + this.update(cx, |this, cx| { + this.update_diagnostics(cx); + cx.notify(); + }) + .log_err(); + }); } } project::Event::WorktreeRemoved(id) => { @@ -564,6 +573,7 @@ impl ProjectPanel { .parent_entity(&cx.entity()), max_width_item_index: None, diagnostics: Default::default(), + diagnostic_summary_update: Task::ready(()), scroll_handle, mouse_down: false, hover_expand_task: None, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 4d3f6823b36e58c898f192ebb95e4ee274133580..19afd49848db3f63a227a2660486b4f0a9f19d1d 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -40,6 +40,7 @@ use std::{ Arc, atomic::{AtomicUsize, Ordering}, }, + time::Duration, }; use theme::ThemeSettings; use ui::{ @@ -364,6 +365,7 @@ pub struct Pane { pinned_tab_count: usize, diagnostics: HashMap, zoom_out_on_close: bool, + diagnostic_summary_update: Task<()>, /// If a certain project item wants to get recreated with specific data, it can persist its data before the recreation here. pub project_item_restoration_data: HashMap>, } @@ -505,6 +507,7 @@ impl Pane { pinned_tab_count: 0, diagnostics: Default::default(), zoom_out_on_close: true, + diagnostic_summary_update: Task::ready(()), project_item_restoration_data: HashMap::default(), } } @@ -616,8 +619,16 @@ impl Pane { project::Event::DiskBasedDiagnosticsFinished { .. } | project::Event::DiagnosticsUpdated { .. } => { if ItemSettings::get_global(cx).show_diagnostics != ShowDiagnostics::Off { - self.update_diagnostics(cx); - cx.notify(); + self.diagnostic_summary_update = cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + this.update(cx, |this, cx| { + this.update_diagnostics(cx); + cx.notify(); + }) + .log_err(); + }); } } _ => {} From 95de2bfc74657241b209e42d7e73f3cdde5e6f23 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 15 Jul 2025 11:03:16 -0500 Subject: [PATCH 0003/1056] keymap_ui: Limit length of keystroke input and hook up actions (#34464) Closes #ISSUE Changes direction on the design of the keystroke input. Due to MacOS limitations, it was decided that the complex repeat keystroke logic could be avoided by limiting the number of keystrokes so that accidental repeats were less damaging to ux. This PR follows up on the design pass in #34437 that assumed these changes would be made, hooking up actions and greatly improving the keyboard navigability of the keystroke input. Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 9 + assets/keymaps/default-macos.json | 9 + crates/settings_ui/src/keybindings.rs | 329 ++++++++++++++++++-------- crates/zed/src/zed.rs | 1 + 4 files changed, 247 insertions(+), 101 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 02d08347fee1c6d4c29db76f93206f7ed45b884f..562afea85454995a1e32ef46bf82fb46220b8e47 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1120,5 +1120,14 @@ "alt-ctrl-f": "keymap_editor::ToggleKeystrokeSearch", "alt-c": "keymap_editor::ToggleConflictFilter" } + }, + { + "context": "KeystrokeInput", + "use_key_equivalents": true, + "bindings": { + "enter": "keystroke_input::StartRecording", + "escape escape escape": "keystroke_input::StopRecording", + "delete": "keystroke_input::ClearKeystrokes" + } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ecb8648978bd677d526e5bdf921383ab6e4c8753..fa9fce4555319f62ff1e3ed359e5165acdcbdb49 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1217,5 +1217,14 @@ "cmd-alt-f": "keymap_editor::ToggleKeystrokeSearch", "cmd-alt-c": "keymap_editor::ToggleConflictFilter" } + }, + { + "context": "KeystrokeInput", + "use_key_equivalents": true, + "bindings": { + "enter": "keystroke_input::StartRecording", + "escape escape escape": "keystroke_input::StopRecording", + "delete": "keystroke_input::ClearKeystrokes" + } } ] diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index a5008e17a0e9d892a8b9f8589dbb2a61063b4527..bf9e72297f2c3807f182e02e0f3de92701949064 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -12,7 +12,7 @@ use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ Action, Animation, AnimationExt, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, KeyContext, - KeyDownEvent, Keystroke, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, + Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, StyledText, Subscription, WeakEntity, actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; @@ -68,6 +68,18 @@ actions!( ] ); +actions!( + keystroke_input, + [ + /// Starts recording keystrokes + StartRecording, + /// Stops recording keystrokes + StopRecording, + /// Clears the recorded keystrokes + ClearKeystrokes, + ] +); + pub fn init(cx: &mut App) { let keymap_event_channel = KeymapEventChannel::new(); cx.set_global(keymap_event_channel); @@ -1883,6 +1895,13 @@ async fn remove_keybinding( Ok(()) } +#[derive(PartialEq, Eq, Debug, Copy, Clone)] +enum CloseKeystrokeResult { + Partial, + Close, + None, +} + struct KeystrokeInput { keystrokes: Vec, placeholder_keystrokes: Option>, @@ -1892,9 +1911,13 @@ struct KeystrokeInput { intercept_subscription: Option, _focus_subscriptions: [Subscription; 2], search: bool, + close_keystrokes: Option>, + close_keystrokes_start: Option, } impl KeystrokeInput { + const KEYSTROKE_COUNT_MAX: usize = 3; + fn new( placeholder_keystrokes: Option>, window: &mut Window, @@ -1915,7 +1938,81 @@ impl KeystrokeInput { intercept_subscription: None, _focus_subscriptions, search: false, + close_keystrokes: None, + close_keystrokes_start: None, + } + } + + fn dummy(modifiers: Modifiers) -> Keystroke { + return Keystroke { + modifiers, + key: "".to_string(), + key_char: None, + }; + } + + fn keystrokes_changed(&self, cx: &mut Context) { + cx.emit(()); + cx.notify(); + } + + fn key_context() -> KeyContext { + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("KeystrokeInput"); + key_context + } + + fn handle_possible_close_keystroke( + &mut self, + keystroke: &Keystroke, + window: &mut Window, + cx: &mut Context, + ) -> CloseKeystrokeResult { + let Some(keybind_for_close_action) = window + .highest_precedence_binding_for_action_in_context(&StopRecording, Self::key_context()) + else { + log::trace!("No keybinding to stop recording keystrokes in keystroke input"); + self.close_keystrokes.take(); + return CloseKeystrokeResult::None; + }; + let action_keystrokes = keybind_for_close_action.keystrokes(); + + if let Some(mut close_keystrokes) = self.close_keystrokes.take() { + let mut index = 0; + + while index < action_keystrokes.len() && index < close_keystrokes.len() { + if !close_keystrokes[index].should_match(&action_keystrokes[index]) { + break; + } + index += 1; + } + if index == close_keystrokes.len() { + if index >= action_keystrokes.len() { + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; + } + if keystroke.should_match(&action_keystrokes[index]) { + if action_keystrokes.len() >= 1 && index == action_keystrokes.len() - 1 { + self.stop_recording(&StopRecording, window, cx); + return CloseKeystrokeResult::Close; + } else { + close_keystrokes.push(keystroke.clone()); + self.close_keystrokes = Some(close_keystrokes); + return CloseKeystrokeResult::Partial; + } + } else { + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; + } + } + } else if let Some(first_action_keystroke) = action_keystrokes.first() + && keystroke.should_match(first_action_keystroke) + { + self.close_keystrokes = Some(vec![keystroke.clone()]); + return CloseKeystrokeResult::Partial; } + self.close_keystrokes_start.take(); + return CloseKeystrokeResult::None; } fn on_modifiers_changed( @@ -1924,65 +2021,60 @@ impl KeystrokeInput { _window: &mut Window, cx: &mut Context, ) { + let keystrokes_len = self.keystrokes.len(); + if let Some(last) = self.keystrokes.last_mut() && last.key.is_empty() + && keystrokes_len <= Self::KEYSTROKE_COUNT_MAX { if !event.modifiers.modified() { self.keystrokes.pop(); - cx.emit(()); } else { last.modifiers = event.modifiers; } - } else { - self.keystrokes.push(Keystroke { - modifiers: event.modifiers, - key: "".to_string(), - key_char: None, - }); - cx.emit(()); + self.keystrokes_changed(cx); + } else if keystrokes_len < Self::KEYSTROKE_COUNT_MAX { + self.keystrokes.push(Self::dummy(event.modifiers)); + self.keystrokes_changed(cx); } cx.stop_propagation(); - cx.notify(); } - fn handle_keystroke(&mut self, keystroke: &Keystroke, cx: &mut Context) { - if let Some(last) = self.keystrokes.last_mut() - && last.key.is_empty() - { - *last = keystroke.clone(); - } else if Some(keystroke) != self.keystrokes.last() { - self.keystrokes.push(keystroke.clone()); - } - cx.emit(()); - cx.stop_propagation(); - cx.notify(); - } - - fn on_key_up( + fn handle_keystroke( &mut self, - event: &gpui::KeyUpEvent, - _window: &mut Window, + keystroke: &Keystroke, + window: &mut Window, cx: &mut Context, ) { - if let Some(last) = self.keystrokes.last_mut() - && !last.key.is_empty() - && last.modifiers == event.keystroke.modifiers + let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); + if close_keystroke_result == CloseKeystrokeResult::Close { + return; + } + if let Some(last) = self.keystrokes.last() + && last.key.is_empty() + && self.keystrokes.len() <= Self::KEYSTROKE_COUNT_MAX { - cx.emit(()); - self.keystrokes.push(Keystroke { - modifiers: event.keystroke.modifiers, - key: "".to_string(), - key_char: None, - }); + self.keystrokes.pop(); + } + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { + if close_keystroke_result == CloseKeystrokeResult::Partial + && self.close_keystrokes_start.is_none() + { + self.close_keystrokes_start = Some(self.keystrokes.len()); + } + self.keystrokes.push(keystroke.clone()); + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { + self.keystrokes.push(Self::dummy(keystroke.modifiers)); + } } + self.keystrokes_changed(cx); cx.stop_propagation(); - cx.notify(); } fn on_inner_focus_in(&mut self, _window: &mut Window, cx: &mut Context) { if self.intercept_subscription.is_none() { - let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, _window, cx| { - this.handle_keystroke(&event.keystroke, cx); + let listener = cx.listener(|this, event: &gpui::KeystrokeEvent, window, cx| { + this.handle_keystroke(&event.keystroke, window, cx); }); self.intercept_subscription = Some(cx.intercept_keystrokes(listener)) } @@ -2014,18 +2106,22 @@ impl KeystrokeInput { return &self.keystrokes; } - fn render_keystrokes(&self) -> impl Iterator { - let (keystrokes, color) = if let Some(placeholders) = self.placeholder_keystrokes.as_ref() + fn render_keystrokes(&self, is_recording: bool) -> impl Iterator { + let keystrokes = if let Some(placeholders) = self.placeholder_keystrokes.as_ref() && self.keystrokes.is_empty() { - (placeholders, Color::Placeholder) + if is_recording { + &[] + } else { + placeholders.as_slice() + } } else { - (&self.keystrokes, Color::Default) + &self.keystrokes }; keystrokes.iter().map(move |keystroke| { h_flex().children(ui::render_keystroke( keystroke, - Some(color), + Some(Color::Default), Some(rems(0.875).into()), ui::PlatformStyle::platform(), false, @@ -2040,6 +2136,40 @@ impl KeystrokeInput { fn set_search_mode(&mut self, search: bool) { self.search = search; } + + fn start_recording(&mut self, _: &StartRecording, window: &mut Window, cx: &mut Context) { + if !self.outer_focus_handle.is_focused(window) { + return; + } + self.clear_keystrokes(&ClearKeystrokes, window, cx); + window.focus(&self.inner_focus_handle); + cx.notify(); + } + + fn stop_recording(&mut self, _: &StopRecording, window: &mut Window, cx: &mut Context) { + if !self.inner_focus_handle.is_focused(window) { + return; + } + window.focus(&self.outer_focus_handle); + if let Some(close_keystrokes_start) = self.close_keystrokes_start.take() { + self.keystrokes.drain(close_keystrokes_start..); + } + self.close_keystrokes.take(); + cx.notify(); + } + + fn clear_keystrokes( + &mut self, + _: &ClearKeystrokes, + window: &mut Window, + cx: &mut Context, + ) { + if !self.outer_focus_handle.is_focused(window) { + return; + } + self.keystrokes.clear(); + cx.notify(); + } } impl EventEmitter<()> for KeystrokeInput {} @@ -2062,6 +2192,22 @@ impl Render for KeystrokeInput { .editor_background .blend(colors.text_accent.opacity(0.1)); + let recording_pulse = || { + Icon::new(IconName::Circle) + .size(IconSize::Small) + .color(Color::Error) + .with_animation( + "recording-pulse", + Animation::new(std::time::Duration::from_secs(2)) + .repeat() + .with_easing(gpui::pulsating_between(0.4, 0.8)), + { + let color = Color::Error.color(cx); + move |this, delta| this.color(Color::Custom(color.opacity(delta))) + }, + ) + }; + let recording_indicator = h_flex() .h_4() .pr_1() @@ -2072,21 +2218,7 @@ impl Render for KeystrokeInput { .editor_background .blend(colors.text_accent.opacity(0.1))) .rounded_sm() - .child( - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(Color::Error) - .with_animation( - "recording-pulse", - Animation::new(std::time::Duration::from_secs(2)) - .repeat() - .with_easing(gpui::pulsating_between(0.4, 0.8)), - { - let color = Color::Error.color(cx); - move |this, delta| this.color(Color::Custom(color.opacity(delta))) - }, - ), - ) + .child(recording_pulse()) .child( Label::new("REC") .size(LabelSize::XSmall) @@ -2104,21 +2236,7 @@ impl Render for KeystrokeInput { .editor_background .blend(colors.text_accent.opacity(0.1))) .rounded_sm() - .child( - Icon::new(IconName::Circle) - .size(IconSize::Small) - .color(Color::Accent) - .with_animation( - "recording-pulse", - Animation::new(std::time::Duration::from_secs(2)) - .repeat() - .with_easing(gpui::pulsating_between(0.4, 0.8)), - { - let color = Color::Accent.color(cx); - move |this, delta| this.color(Color::Custom(color.opacity(delta))) - }, - ), - ) + .child(recording_pulse()) .child( Label::new("SEARCH") .size(LabelSize::XSmall) @@ -2156,13 +2274,9 @@ impl Render for KeystrokeInput { .when(is_focused, |parent| { parent.border_color(colors.border_focused) }) - .on_key_down(cx.listener(|this, event: &KeyDownEvent, window, cx| { - // TODO: replace with action - if !event.keystroke.modifiers.modified() && event.keystroke.key == "enter" { - window.focus(&this.inner_focus_handle); - cx.notify(); - } - })) + .key_context(Self::key_context()) + .on_action(cx.listener(Self::start_recording)) + .on_action(cx.listener(Self::stop_recording)) .child( h_flex() .w(horizontal_padding) @@ -2184,13 +2298,19 @@ impl Render for KeystrokeInput { .id("keystroke-input-inner") .track_focus(&self.inner_focus_handle) .on_modifiers_changed(cx.listener(Self::on_modifiers_changed)) - .on_key_up(cx.listener(Self::on_key_up)) .size_full() + .when(self.highlight_on_focus, |this| { + this.focus(|mut style| { + style.border_color = Some(colors.border_focused); + style + }) + }) + .w_full() .min_w_0() .justify_center() .flex_wrap() .gap(ui::DynamicSpacing::Base04.rems(cx)) - .children(self.render_keystrokes()), + .children(self.render_keystrokes(is_recording)), ) .child( h_flex() @@ -2204,15 +2324,18 @@ impl Render for KeystrokeInput { IconButton::new("stop-record-btn", IconName::StopFilled) .shape(ui::IconButtonShape::Square) .map(|this| { - if self.search { - this.tooltip(Tooltip::text("Stop Searching")) - } else { - this.tooltip(Tooltip::text("Stop Recording")) - } + this.tooltip(Tooltip::for_action_title( + if self.search { + "Stop Searching" + } else { + "Stop Recording" + }, + &StopRecording, + )) }) .icon_color(Color::Error) - .on_click(cx.listener(|this, _event, window, _cx| { - this.outer_focus_handle.focus(window); + .on_click(cx.listener(|this, _event, window, cx| { + this.stop_recording(&StopRecording, window, cx); })), ) } else { @@ -2220,15 +2343,18 @@ impl Render for KeystrokeInput { IconButton::new("record-btn", record_icon) .shape(ui::IconButtonShape::Square) .map(|this| { - if self.search { - this.tooltip(Tooltip::text("Start Searching")) - } else { - this.tooltip(Tooltip::text("Start Recording")) - } + this.tooltip(Tooltip::for_action_title( + if self.search { + "Start Searching" + } else { + "Start Recording" + }, + &StartRecording, + )) }) .when(!is_focused, |this| this.icon_color(Color::Muted)) - .on_click(cx.listener(|this, _event, window, _cx| { - this.inner_focus_handle.focus(window); + .on_click(cx.listener(|this, _event, window, cx| { + this.start_recording(&StartRecording, window, cx); })), ) } @@ -2236,14 +2362,15 @@ impl Render for KeystrokeInput { .child( IconButton::new("clear-btn", IconName::Delete) .shape(ui::IconButtonShape::Square) - .tooltip(Tooltip::text("Clear Keystrokes")) + .tooltip(Tooltip::for_action_title( + "Clear Keystrokes", + &ClearKeystrokes, + )) .when(!is_recording || !is_focused, |this| { this.icon_color(Color::Muted) }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.keystrokes.clear(); - cx.emit(()); - cx.notify(); + .on_click(cx.listener(|this, _event, window, cx| { + this.clear_keystrokes(&ClearKeystrokes, window, cx); })), ), ); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index dc094a6c12fb1ba11642cc988f5d06d2cce01078..cc3906af4d957463f18e19a0e0756d21a2b1d022 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -4327,6 +4327,7 @@ mod tests { "jj", "journal", "keymap_editor", + "keystroke_input", "language_selector", "lsp_tool", "markdown", From b3747d9a216a6332754effd53c05ddac444f99d1 Mon Sep 17 00:00:00 2001 From: Finn Evers Date: Tue, 15 Jul 2025 18:52:21 +0200 Subject: [PATCH 0004/1056] keymap_ui: Add column for conflict indicator and edit button (#34423) This PR adds a column to the keymap editor to highlight warnings as well as add the possibility to click the edit icon there for editing the corresponding entry in the list. Release Notes: - N/A --------- Co-authored-by: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Co-authored-by: Danilo Leal --- crates/settings_ui/src/keybindings.rs | 104 +++++++++++++++++++++----- 1 file changed, 87 insertions(+), 17 deletions(-) diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index bf9e72297f2c3807f182e02e0f3de92701949064..f246e9498c3503c9b95326a235b72708b51d24c3 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -22,8 +22,9 @@ use settings::{BaseKeymap, KeybindSource, KeymapFile, SettingsAssets}; use util::ResultExt; use ui::{ - ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, Modal, ModalFooter, ModalHeader, - ParentElement as _, Render, Section, SharedString, Styled as _, Tooltip, Window, prelude::*, + ActiveTheme as _, App, Banner, BorrowAppContext, ContextMenu, IconButtonShape, Modal, + ModalFooter, ModalHeader, ParentElement as _, Render, Section, SharedString, Styled as _, + Tooltip, Window, prelude::*, }; use ui_input::SingleLineInput; use workspace::{ @@ -450,6 +451,13 @@ impl KeymapEditor { }) } + fn has_conflict(&self, row_index: usize) -> bool { + self.matches + .get(row_index) + .map(|candidate| candidate.candidate_id) + .is_some_and(|id| self.keybinding_conflict_state.has_conflict(&id)) + } + fn process_bindings( json_language: Arc, rust_language: Arc, @@ -847,8 +855,14 @@ impl KeymapEditor { _: &mut Window, cx: &mut Context, ) { - self.filter_state = self.filter_state.invert(); - self.update_matches(cx); + self.set_filter_state(self.filter_state.invert(), cx); + } + + fn set_filter_state(&mut self, filter_state: FilterState, cx: &mut Context) { + if self.filter_state != filter_state { + self.filter_state = filter_state; + self.update_matches(cx); + } } fn toggle_keystroke_search( @@ -1078,8 +1092,15 @@ impl Render for KeymapEditor { Table::new() .interactable(&self.table_interaction_state) .striped() - .column_widths([rems(16.), rems(16.), rems(16.), rems(32.), rems(8.)]) - .header(["Action", "Arguments", "Keystrokes", "Context", "Source"]) + .column_widths([ + rems(2.5), + rems(16.), + rems(16.), + rems(16.), + rems(32.), + rems(8.), + ]) + .header(["", "Action", "Arguments", "Keystrokes", "Context", "Source"]) .uniform_list( "keymap-editor-table", row_count, @@ -1091,6 +1112,49 @@ impl Render for KeymapEditor { let binding = &this.keybindings[candidate_id]; let action_name = binding.action_name.clone(); + let icon = (this.filter_state != FilterState::Conflicts + && this.has_conflict(index)) + .then(|| { + base_button_style(index, IconName::Warning) + .icon_color(Color::Warning) + .tooltip(|window, cx| { + Tooltip::with_meta( + "Edit Keybinding", + None, + "Use alt+click to show conflicts", + window, + cx, + ) + }) + .on_click(cx.listener( + move |this, click: &ClickEvent, window, cx| { + if click.modifiers().alt { + this.set_filter_state( + FilterState::Conflicts, + cx, + ); + } else { + this.select_index(index, cx); + this.open_edit_keybinding_modal( + false, window, cx, + ); + cx.stop_propagation(); + } + }, + )) + }) + .unwrap_or_else(|| { + base_button_style(index, IconName::Pencil) + .visible_on_hover(row_group_id(index)) + .tooltip(Tooltip::text("Edit Keybinding")) + .on_click(cx.listener(move |this, _, window, cx| { + this.select_index(index, cx); + this.open_edit_keybinding_modal(false, window, cx); + cx.stop_propagation(); + })) + }) + .into_any_element(); + let action = div() .id(("keymap action", index)) .child(command_palette::humanize_action_name(&action_name)) @@ -1148,32 +1212,26 @@ impl Render for KeymapEditor { .map(|(_source, name)| name) .unwrap_or_default() .into_any_element(); - Some([action, action_input, keystrokes, context, source]) + Some([icon, action, action_input, keystrokes, context, source]) }) .collect() }), ) .map_row( cx.processor(|this, (row_index, row): (usize, Div), _window, cx| { - let is_conflict = this - .matches - .get(row_index) - .map(|candidate| candidate.candidate_id) - .is_some_and(|id| this.keybinding_conflict_state.has_conflict(&id)); + let is_conflict = this.has_conflict(row_index); let is_selected = this.selected_index == Some(row_index); + let row_id = row_group_id(row_index); + let row = row - .id(("keymap-table-row", row_index)) + .id(row_id.clone()) .on_any_mouse_down(cx.listener( move |this, mouse_down_event: &gpui::MouseDownEvent, window, cx| { match mouse_down_event.button { - MouseButton::Left => { - this.select_index(row_index, cx); - } - MouseButton::Right => { this.select_index(row_index, cx); this.create_context_menu( @@ -1188,11 +1246,13 @@ impl Render for KeymapEditor { )) .on_click(cx.listener( move |this, event: &ClickEvent, window, cx| { + this.select_index(row_index, cx); if event.up.click_count == 2 { this.open_edit_keybinding_modal(false, window, cx); } }, )) + .group(row_id) .border_2() .when(is_conflict, |row| { row.bg(cx.theme().status().error_background) @@ -1225,6 +1285,16 @@ impl Render for KeymapEditor { } } +fn row_group_id(row_index: usize) -> SharedString { + SharedString::new(format!("keymap-table-row-{}", row_index)) +} + +fn base_button_style(row_index: usize, icon: IconName) -> IconButton { + IconButton::new(("keymap-icon", row_index), icon) + .shape(IconButtonShape::Square) + .size(ButtonSize::Compact) +} + #[derive(Debug, Clone, IntoElement)] struct SyntaxHighlightedText { text: SharedString, From f9561da673213ad24878460cfe22d984478243c7 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 15 Jul 2025 13:16:29 -0400 Subject: [PATCH 0005/1056] Maintain keymap editor position when deleting or modifying a binding (#34440) When a key binding is deleted we keep the exact same scroll bar position. When a keybinding is modified we select that keybinding in it's new position and scroll to it. I also changed save/modified keybinding to use fs.write istead of fs.atomic_write. Atomic write was creating two FS events that some scrollbar bugs when refreshing the keymap editor. Co-authored-by: Ben \ Release Notes: - N/A --- crates/settings_ui/src/keybindings.rs | 136 +++++++++++++++--- crates/settings_ui/src/ui_components/table.rs | 26 +++- 2 files changed, 137 insertions(+), 25 deletions(-) diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index f246e9498c3503c9b95326a235b72708b51d24c3..46a428038cce3e5b8498ec6215f764912aa44b47 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -10,9 +10,9 @@ use feature_flags::FeatureFlagViewExt; use fs::Fs; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ - Action, Animation, AnimationExt, AppContext as _, AsyncApp, ClickEvent, Context, DismissEvent, - Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, KeyContext, - Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, + Action, Animation, AnimationExt, AppContext as _, AsyncApp, Axis, ClickEvent, Context, + DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, FontWeight, Global, IsZero, + KeyContext, Keystroke, Modifiers, ModifiersChangedEvent, MouseButton, Point, ScrollStrategy, ScrollWheelEvent, StyledText, Subscription, WeakEntity, actions, anchored, deferred, div, }; use language::{Language, LanguageConfig, ToOffset as _}; @@ -282,6 +282,25 @@ struct KeymapEditor { keystroke_editor: Entity, selected_index: Option, context_menu: Option<(Entity, Point, Subscription)>, + previous_edit: Option, +} + +enum PreviousEdit { + /// When deleting, we want to maintain the same scroll position + ScrollBarOffset(Point), + /// When editing or creating, because the new keybinding could be in a different position in the sort order + /// we store metadata about the new binding (either the modified version or newly created one) + /// and upon reload, we search for this binding in the list of keybindings, and if we find the one that matches + /// this metadata, we set the selected index to it and scroll to it, + /// and if we don't find it, we scroll to 0 and don't set a selected index + Keybinding { + action_mapping: ActionMapping, + action_name: SharedString, + /// The scrollbar position to fallback to if we don't find the keybinding during a refresh + /// this can happen if there's a filter applied to the search and the keybinding modification + /// filters the binding from the search results + fallback: Point, + }, } impl EventEmitter<()> for KeymapEditor {} @@ -294,8 +313,7 @@ impl Focusable for KeymapEditor { impl KeymapEditor { fn new(workspace: WeakEntity, window: &mut Window, cx: &mut Context) -> Self { - let _keymap_subscription = - cx.observe_global::(Self::update_keybindings); + let _keymap_subscription = cx.observe_global::(Self::on_keymap_changed); let table_interaction_state = TableInteractionState::new(window, cx); let keystroke_editor = cx.new(|cx| { @@ -315,7 +333,7 @@ impl KeymapEditor { return; } - this.update_matches(cx); + this.on_query_changed(cx); }) .detach(); @@ -324,7 +342,7 @@ impl KeymapEditor { return; } - this.update_matches(cx); + this.on_query_changed(cx); }) .detach(); @@ -343,9 +361,10 @@ impl KeymapEditor { keystroke_editor, selected_index: None, context_menu: None, + previous_edit: None, }; - this.update_keybindings(cx); + this.on_keymap_changed(cx); this } @@ -367,17 +386,20 @@ impl KeymapEditor { } } - fn update_matches(&self, cx: &mut Context) { + fn on_query_changed(&self, cx: &mut Context) { let action_query = self.current_action_query(cx); let keystroke_query = self.current_keystroke_query(cx); cx.spawn(async move |this, cx| { - Self::process_query(this, action_query, keystroke_query, cx).await + Self::update_matches(this.clone(), action_query, keystroke_query, cx).await?; + this.update(cx, |this, cx| { + this.scroll_to_item(0, ScrollStrategy::Top, cx) + }) }) .detach(); } - async fn process_query( + async fn update_matches( this: WeakEntity, action_query: String, keystroke_query: Vec, @@ -445,7 +467,6 @@ impl KeymapEditor { }); } this.selected_index.take(); - this.scroll_to_item(0, ScrollStrategy::Top, cx); this.matches = matches; cx.notify(); }) @@ -539,7 +560,7 @@ impl KeymapEditor { (processed_bindings, string_match_candidates) } - fn update_keybindings(&mut self, cx: &mut Context) { + fn on_keymap_changed(&mut self, cx: &mut Context) { let workspace = self.workspace.clone(); cx.spawn(async move |this, cx| { let json_language = load_json_language(workspace.clone(), cx).await; @@ -574,7 +595,47 @@ impl KeymapEditor { ) })?; // calls cx.notify - Self::process_query(this, action_query, keystroke_query, cx).await + Self::update_matches(this.clone(), action_query, keystroke_query, cx).await?; + this.update(cx, |this, cx| { + if let Some(previous_edit) = this.previous_edit.take() { + match previous_edit { + // should remove scroll from process_query + PreviousEdit::ScrollBarOffset(offset) => { + this.table_interaction_state.update(cx, |table, _| { + table.set_scrollbar_offset(Axis::Vertical, offset) + }) + // set selected index and scroll + } + PreviousEdit::Keybinding { + action_mapping, + action_name, + fallback, + } => { + let scroll_position = + this.matches.iter().enumerate().find_map(|(index, item)| { + let binding = &this.keybindings[item.candidate_id]; + if binding.get_action_mapping() == action_mapping + && binding.action_name == action_name + { + Some(index) + } else { + None + } + }); + + if let Some(scroll_position) = scroll_position { + this.scroll_to_item(scroll_position, ScrollStrategy::Top, cx); + this.selected_index = Some(scroll_position); + } else { + this.table_interaction_state.update(cx, |table, _| { + table.set_scrollbar_offset(Axis::Vertical, fallback) + }); + } + cx.notify(); + } + } + } + }) }) .detach_and_log_err(cx); } @@ -806,6 +867,7 @@ impl KeymapEditor { let Some(to_remove) = self.selected_binding().cloned() else { return; }; + let Ok(fs) = self .workspace .read_with(cx, |workspace, _| workspace.app_state().fs.clone()) @@ -813,6 +875,11 @@ impl KeymapEditor { return; }; let tab_size = cx.global::().json_tab_size(); + self.previous_edit = Some(PreviousEdit::ScrollBarOffset( + self.table_interaction_state + .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); } @@ -861,7 +928,7 @@ impl KeymapEditor { fn set_filter_state(&mut self, filter_state: FilterState, cx: &mut Context) { if self.filter_state != filter_state { self.filter_state = filter_state; - self.update_matches(cx); + self.on_query_changed(cx); } } @@ -872,7 +939,7 @@ impl KeymapEditor { cx: &mut Context, ) { self.search_mode = self.search_mode.invert(); - self.update_matches(cx); + self.on_query_changed(cx); // Update the keystroke editor to turn the `search` bool on self.keystroke_editor.update(cx, |keystroke_editor, cx| { @@ -1623,6 +1690,8 @@ impl KeybindingEditorModal { .log_err(); cx.spawn(async move |this, cx| { + let action_name = existing_keybind.action_name.clone(); + if let Err(err) = save_keybinding_update( create, existing_keybind, @@ -1639,7 +1708,22 @@ impl KeybindingEditorModal { }) .log_err(); } else { - this.update(cx, |_this, cx| { + this.update(cx, |this, cx| { + let action_mapping = ( + ui::text_for_keystrokes(new_keystrokes.as_slice(), cx).into(), + new_context.map(SharedString::from), + ); + + this.keymap_editor.update(cx, |keymap, cx| { + keymap.previous_edit = Some(PreviousEdit::Keybinding { + action_mapping, + action_name, + fallback: keymap + .table_interaction_state + .read(cx) + .get_scrollbar_offset(Axis::Vertical), + }) + }); cx.emit(DismissEvent); }) .ok(); @@ -1917,9 +2001,12 @@ async fn save_keybinding_update( let updated_keymap_contents = settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) .context("Failed to update keybinding")?; - fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents) - .await - .context("Failed to write keymap file")?; + fs.write( + paths::keymap_file().as_path(), + updated_keymap_contents.as_bytes(), + ) + .await + .context("Failed to write keymap file")?; Ok(()) } @@ -1959,9 +2046,12 @@ async fn remove_keybinding( let updated_keymap_contents = settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) .context("Failed to update keybinding")?; - fs.atomic_write(paths::keymap_file().clone(), updated_keymap_contents) - .await - .context("Failed to write keymap file")?; + fs.write( + paths::keymap_file().as_path(), + updated_keymap_contents.as_bytes(), + ) + .await + .context("Failed to write keymap file")?; Ok(()) } diff --git a/crates/settings_ui/src/ui_components/table.rs b/crates/settings_ui/src/ui_components/table.rs index c3b70d7d4f166ff3b34cd2b52146e4dc7408badc..98dd7387659a0ed9afdff8a7b4280c2a03685174 100644 --- a/crates/settings_ui/src/ui_components/table.rs +++ b/crates/settings_ui/src/ui_components/table.rs @@ -3,8 +3,8 @@ use std::{ops::Range, rc::Rc, time::Duration}; use editor::{EditorSettings, ShowScrollbar, scroll::ScrollbarAutoHide}; use gpui::{ AppContext, Axis, Context, Entity, FocusHandle, Length, ListHorizontalSizingBehavior, - ListSizingBehavior, MouseButton, Task, UniformListScrollHandle, WeakEntity, transparent_black, - uniform_list, + ListSizingBehavior, MouseButton, Point, Task, UniformListScrollHandle, WeakEntity, + transparent_black, uniform_list, }; use settings::Settings as _; use ui::{ @@ -90,6 +90,28 @@ impl TableInteractionState { }) } + pub fn get_scrollbar_offset(&self, axis: Axis) -> Point { + match axis { + Axis::Vertical => self.vertical_scrollbar.state.scroll_handle().offset(), + Axis::Horizontal => self.horizontal_scrollbar.state.scroll_handle().offset(), + } + } + + pub fn set_scrollbar_offset(&self, axis: Axis, offset: Point) { + match axis { + Axis::Vertical => self + .vertical_scrollbar + .state + .scroll_handle() + .set_offset(offset), + Axis::Horizontal => self + .horizontal_scrollbar + .state + .scroll_handle() + .set_offset(offset), + } + } + fn update_scrollbar_visibility(&mut self, cx: &mut Context) { let show_setting = EditorSettings::get_global(cx).scrollbar.show; From 3ecdfc9b5adbe0932cc780fb1183f2de1eea911b Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 15 Jul 2025 13:36:09 -0400 Subject: [PATCH 0006/1056] Remove auto-width editor type (#34438) Closes #34044 `EditorMode::SingleLine { auto_width: true }` was only used for the title editor in the rules library, and following https://github.com/zed-industries/zed/pull/31994 we can replace that with a normal single-line editor without problems. The auto-width editor was interacting badly with the recently-added newline visualization code, causing a panic during layout---by switching it to `Editor::single_line` the newline visualization works there too. Release Notes: - Fixed a panic that could occur when opening the rules library. --------- Co-authored-by: Finn --- crates/editor/src/editor.rs | 24 +----------- crates/editor/src/element.rs | 45 +++-------------------- crates/rules_library/src/rules_library.rs | 2 +- 3 files changed, 9 insertions(+), 62 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 72470c0a7d13cc75f83102d2c61c23c478063053..acd9c23c97682f648fe3389e8c0466be4d7b9667 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -482,9 +482,7 @@ pub enum SelectMode { #[derive(Clone, PartialEq, Eq, Debug)] pub enum EditorMode { - SingleLine { - auto_width: bool, - }, + SingleLine, AutoHeight { min_lines: usize, max_lines: Option, @@ -1662,13 +1660,7 @@ impl Editor { pub fn single_line(window: &mut Window, cx: &mut Context) -> Self { let buffer = cx.new(|cx| Buffer::local("", cx)); let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new( - EditorMode::SingleLine { auto_width: false }, - buffer, - None, - window, - cx, - ) + Self::new(EditorMode::SingleLine, buffer, None, window, cx) } pub fn multi_line(window: &mut Window, cx: &mut Context) -> Self { @@ -1677,18 +1669,6 @@ impl Editor { Self::new(EditorMode::full(), buffer, None, window, cx) } - pub fn auto_width(window: &mut Window, cx: &mut Context) -> Self { - let buffer = cx.new(|cx| Buffer::local("", cx)); - let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx)); - Self::new( - EditorMode::SingleLine { auto_width: true }, - buffer, - None, - window, - cx, - ) - } - pub fn auto_height( min_lines: usize, max_lines: usize, diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 06fb52cdb3a63baa16f932ae0062b290016657ef..e77be3398ca0fcef9edf65a0a318f94bd21a4fc8 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -7787,46 +7787,13 @@ impl Element for EditorElement { editor.set_style(self.style.clone(), window, cx); let layout_id = match editor.mode { - EditorMode::SingleLine { auto_width } => { + EditorMode::SingleLine => { let rem_size = window.rem_size(); - let height = self.style.text.line_height_in_pixels(rem_size); - if auto_width { - let editor_handle = cx.entity().clone(); - let style = self.style.clone(); - window.request_measured_layout( - Style::default(), - move |_, _, window, cx| { - let editor_snapshot = editor_handle - .update(cx, |editor, cx| editor.snapshot(window, cx)); - let line = Self::layout_lines( - DisplayRow(0)..DisplayRow(1), - &editor_snapshot, - &style, - px(f32::MAX), - |_| false, // Single lines never soft wrap - window, - cx, - ) - .pop() - .unwrap(); - - let font_id = - window.text_system().resolve_font(&style.text.font()); - let font_size = - style.text.font_size.to_pixels(window.rem_size()); - let em_width = - window.text_system().em_width(font_id, font_size).unwrap(); - - size(line.width + em_width, height) - }, - ) - } else { - let mut style = Style::default(); - style.size.height = height.into(); - style.size.width = relative(1.).into(); - window.request_layout(style, None, cx) - } + let mut style = Style::default(); + style.size.height = height.into(); + style.size.width = relative(1.).into(); + window.request_layout(style, None, cx) } EditorMode::AutoHeight { min_lines, @@ -10390,7 +10357,7 @@ mod tests { }); for editor_mode_without_invisibles in [ - EditorMode::SingleLine { auto_width: false }, + EditorMode::SingleLine, EditorMode::AutoHeight { min_lines: 1, max_lines: Some(100), diff --git a/crates/rules_library/src/rules_library.rs b/crates/rules_library/src/rules_library.rs index f871416f391d844d324ee3a11d9c41465ea0dccd..be6a69c23bef20571fbdb54854f768c46f3678b7 100644 --- a/crates/rules_library/src/rules_library.rs +++ b/crates/rules_library/src/rules_library.rs @@ -611,7 +611,7 @@ impl RulesLibrary { this.update_in(cx, |this, window, cx| match rule { Ok(rule) => { let title_editor = cx.new(|cx| { - let mut editor = Editor::auto_width(window, cx); + let mut editor = Editor::single_line(window, cx); editor.set_placeholder_text("Untitled", cx); editor.set_text(rule_metadata.title.unwrap_or_default(), window, cx); if prompt_id.is_built_in() { From ebbf02e25b94b05e641ffe420a077ad3768d8107 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Tue, 15 Jul 2025 13:03:19 -0500 Subject: [PATCH 0007/1056] keymap_ui: Keyboard navigation for keybind edit modal (#34482) Adds keyboard navigation to the keybind edit modal. Using up/down arrows to select the previous/next input editor, and `cmd-enter` to save + `escape` to exit Release Notes: - N/A *or* Added/Fixed/Improved ... --- assets/keymaps/default-linux.json | 16 ++ assets/keymaps/default-macos.json | 16 ++ crates/settings_ui/src/keybindings.rs | 293 +++++++++++++++++--------- 3 files changed, 228 insertions(+), 97 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 562afea85454995a1e32ef46bf82fb46220b8e47..9ca7d8589a29b988a181f69f65310220d380d7e4 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -1129,5 +1129,21 @@ "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" + } } ] diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index fa9fce4555319f62ff1e3ed359e5165acdcbdb49..7af79bdeea1b461e6b0f6fb665ccc9f8cef2138f 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1226,5 +1226,21 @@ "escape escape escape": "keystroke_input::StopRecording", "delete": "keystroke_input::ClearKeystrokes" } + }, + { + "context": "KeybindEditorModal", + "use_key_equivalents": true, + "bindings": { + "cmd-enter": "menu::Confirm", + "escape": "menu::Cancel" + } + }, + { + "context": "KeybindEditorModal > Editor", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrevious", + "down": "menu::SelectNext" + } } ] diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 46a428038cce3e5b8498ec6215f764912aa44b47..3567439d2b3c85202d5fe0ceea5a481bab6be482 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -1451,6 +1451,7 @@ struct KeybindingEditorModal { error: Option, keymap_editor: Entity, workspace: WeakEntity, + focus_state: KeybindingEditorModalFocusState, } impl ModalView for KeybindingEditorModal {} @@ -1539,6 +1540,14 @@ impl KeybindingEditorModal { }) }); + let focus_state = KeybindingEditorModalFocusState::new( + keybind_editor.read_with(cx, |keybind_editor, cx| keybind_editor.focus_handle(cx)), + input_editor.as_ref().map(|input_editor| { + input_editor.read_with(cx, |input_editor, cx| input_editor.focus_handle(cx)) + }), + context_editor.read_with(cx, |context_editor, cx| context_editor.focus_handle(cx)), + ); + Self { creating: create, editing_keybind, @@ -1550,6 +1559,7 @@ impl KeybindingEditorModal { error: None, keymap_editor, workspace, + focus_state, } } @@ -1731,6 +1741,33 @@ impl KeybindingEditorModal { }) .detach(); } + + fn key_context(&self) -> KeyContext { + let mut key_context = KeyContext::new_with_defaults(); + key_context.add("KeybindEditorModal"); + key_context + } + + fn focus_next(&mut self, _: &menu::SelectNext, window: &mut Window, cx: &mut Context) { + self.focus_state.focus_next(window, cx); + } + + fn focus_prev( + &mut self, + _: &menu::SelectPrevious, + window: &mut Window, + cx: &mut Context, + ) { + self.focus_state.focus_previous(window, cx); + } + + fn confirm(&mut self, _: &menu::Confirm, _window: &mut Window, cx: &mut Context) { + self.save(cx); + } + + fn cancel(&mut self, _: &menu::Cancel, _window: &mut Window, cx: &mut Context) { + cx.emit(DismissEvent) + } } impl Render for KeybindingEditorModal { @@ -1739,93 +1776,156 @@ impl Render for KeybindingEditorModal { let action_name = command_palette::humanize_action_name(&self.editing_keybind.action_name).to_string(); - v_flex().w(rems(34.)).elevation_3(cx).child( - Modal::new("keybinding_editor_modal", None) - .header( - ModalHeader::new().child( - v_flex() - .pb_1p5() - .mb_1() - .gap_0p5() - .border_b_1() - .border_color(theme.border_variant) - .child(Label::new(action_name)) - .when_some(self.editing_keybind.action_docs, |this, docs| { - this.child( - Label::new(docs).size(LabelSize::Small).color(Color::Muted), - ) - }), - ), - ) - .section( - Section::new().child( - v_flex() - .gap_2() - .child( - v_flex() - .child(Label::new("Edit Keystroke")) - .gap_1() - .child(self.keybind_editor.clone()), - ) - .when_some(self.input_editor.clone(), |this, editor| { - this.child( + v_flex() + .w(rems(34.)) + .elevation_3(cx) + .key_context(self.key_context()) + .on_action(cx.listener(Self::focus_next)) + .on_action(cx.listener(Self::focus_prev)) + .on_action(cx.listener(Self::confirm)) + .on_action(cx.listener(Self::cancel)) + .child( + Modal::new("keybinding_editor_modal", None) + .header( + ModalHeader::new().child( + v_flex() + .pb_1p5() + .mb_1() + .gap_0p5() + .border_b_1() + .border_color(theme.border_variant) + .child(Label::new(action_name)) + .when_some(self.editing_keybind.action_docs, |this, docs| { + this.child( + Label::new(docs).size(LabelSize::Small).color(Color::Muted), + ) + }), + ), + ) + .section( + Section::new().child( + v_flex() + .gap_2() + .child( v_flex() - .mt_1p5() + .child(Label::new("Edit Keystroke")) .gap_1() - .child(Label::new("Edit Arguments")) - .child( - div() - .w_full() - .py_1() - .px_1p5() - .rounded_lg() - .bg(theme.editor_background) - .border_1() - .border_color(theme.border_variant) - .child(editor), - ), + .child(self.keybind_editor.clone()), ) - }) - .child(self.context_editor.clone()) - .when_some(self.error.as_ref(), |this, error| { - this.child( - Banner::new() - .map(|banner| match error { - InputError::Error(_) => { - banner.severity(ui::Severity::Error) - } - InputError::Warning(_) => { - banner.severity(ui::Severity::Warning) - } - }) - // For some reason, the div overflows its container to the - //right. The padding accounts for that. - .child( - div() - .size_full() - .pr_2() - .child(Label::new(error.content())), - ), + .when_some(self.input_editor.clone(), |this, editor| { + this.child( + v_flex() + .mt_1p5() + .gap_1() + .child(Label::new("Edit Arguments")) + .child( + div() + .w_full() + .py_1() + .px_1p5() + .rounded_lg() + .bg(theme.editor_background) + .border_1() + .border_color(theme.border_variant) + .child(editor), + ), + ) + }) + .child(self.context_editor.clone()) + .when_some(self.error.as_ref(), |this, error| { + this.child( + Banner::new() + .map(|banner| match error { + InputError::Error(_) => { + banner.severity(ui::Severity::Error) + } + InputError::Warning(_) => { + banner.severity(ui::Severity::Warning) + } + }) + // For some reason, the div overflows its container to the + //right. The padding accounts for that. + .child( + div() + .size_full() + .pr_2() + .child(Label::new(error.content())), + ), + ) + }), + ), + ) + .footer( + ModalFooter::new().end_slot( + h_flex() + .gap_1() + .child( + Button::new("cancel", "Cancel") + .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), ) - }), - ), - ) - .footer( - ModalFooter::new().end_slot( - h_flex() - .gap_1() - .child( - Button::new("cancel", "Cancel") - .on_click(cx.listener(|_, _, _, cx| cx.emit(DismissEvent))), - ) - .child(Button::new("save-btn", "Save").on_click(cx.listener( - |this, _event, _window, cx| { - this.save(cx); - }, - ))), + .child(Button::new("save-btn", "Save").on_click(cx.listener( + |this, _event, _window, cx| { + this.save(cx); + }, + ))), + ), ), - ), - ) + ) + } +} + +struct KeybindingEditorModalFocusState { + handles: Vec, +} + +impl KeybindingEditorModalFocusState { + fn new( + keystrokes: FocusHandle, + action_input: Option, + context: FocusHandle, + ) -> Self { + Self { + handles: Vec::from_iter( + [Some(keystrokes), action_input, Some(context)] + .into_iter() + .flatten(), + ), + } + } + + fn focused_index(&self, window: &Window, cx: &App) -> Option { + self.handles + .iter() + .position(|handle| handle.contains_focused(window, cx)) + .map(|i| i as i32) + } + + fn focus_index(&self, mut index: i32, window: &mut Window) { + if index < 0 { + index = self.handles.len() as i32 - 1; + } + if index >= self.handles.len() as i32 { + index = 0; + } + window.focus(&self.handles[index as usize]); + } + + fn focus_next(&self, window: &mut Window, cx: &App) { + let index_to_focus = if let Some(index) = self.focused_index(window, cx) { + index + 1 + } else { + 0 + }; + self.focus_index(index_to_focus, window); + } + + fn focus_previous(&self, window: &mut Window, cx: &App) { + let index_to_focus = if let Some(index) = self.focused_index(window, cx) { + index - 1 + } else { + self.handles.len() as i32 - 1 + }; + self.focus_index(index_to_focus, window); } } @@ -2207,24 +2307,23 @@ impl KeystrokeInput { cx: &mut Context, ) { let close_keystroke_result = self.handle_possible_close_keystroke(keystroke, window, cx); - if close_keystroke_result == CloseKeystrokeResult::Close { - return; - } - if let Some(last) = self.keystrokes.last() - && last.key.is_empty() - && self.keystrokes.len() <= Self::KEYSTROKE_COUNT_MAX - { - self.keystrokes.pop(); - } - if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { - if close_keystroke_result == CloseKeystrokeResult::Partial - && self.close_keystrokes_start.is_none() + if close_keystroke_result != CloseKeystrokeResult::Close { + if let Some(last) = self.keystrokes.last() + && last.key.is_empty() + && self.keystrokes.len() <= Self::KEYSTROKE_COUNT_MAX { - self.close_keystrokes_start = Some(self.keystrokes.len()); + self.keystrokes.pop(); } - self.keystrokes.push(keystroke.clone()); if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { - self.keystrokes.push(Self::dummy(keystroke.modifiers)); + if close_keystroke_result == CloseKeystrokeResult::Partial + && self.close_keystrokes_start.is_none() + { + self.close_keystrokes_start = Some(self.keystrokes.len()); + } + self.keystrokes.push(keystroke.clone()); + if self.keystrokes.len() < Self::KEYSTROKE_COUNT_MAX { + self.keystrokes.push(Self::dummy(keystroke.modifiers)); + } } } self.keystrokes_changed(cx); From 729cde33f14b595856703603d016a75b1387dc91 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Tue, 15 Jul 2025 23:41:53 +0530 Subject: [PATCH 0008/1056] project_panel: Add rename, delete and duplicate actions to workspace (#34478) Release Notes: - Added `project panel: rename`, `project panel: delete` and `project panel: duplicate` actions to workspace. Co-authored-by: Danilo Leal --- crates/project_panel/src/project_panel.rs | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index e1d360cd976386d2e24e63b9eda05afe123dc411..b6fdcd6fa5bac837df5cab8aad3b9c69cd1613d8 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -320,6 +320,33 @@ pub fn init(cx: &mut App) { }); } }); + + workspace.register_action(|workspace, action: &Rename, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + if let Some(first_marked) = panel.marked_entries.first() { + let first_marked = *first_marked; + panel.marked_entries.clear(); + panel.selection = Some(first_marked); + } + panel.rename(action, window, cx); + }); + } + }); + + workspace.register_action(|workspace, action: &Duplicate, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| { + panel.duplicate(action, window, cx); + }); + } + }); + + workspace.register_action(|workspace, action: &Delete, window, cx| { + if let Some(panel) = workspace.panel::(cx) { + panel.update(cx, |panel, cx| panel.delete(action, window, cx)); + } + }); }) .detach(); } From 57e8f5c5b9878c3a33e0e1b3452bb8a078ec794b Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Tue, 15 Jul 2025 14:22:13 -0400 Subject: [PATCH 0009/1056] Automatically retry in more situations (#34473) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In #33275 I was very conservative about when to retry when there are errors in language completions in the Agent panel. Now we retry in more scenarios (e.g. HTTP 5xx and 4xx errors that aren't in the specific list of ones that we handle differently, such as 429s), and also we show a notification if the thread halts for any reason. Screenshot 2025-07-15 at 12 51 30 PM Screenshot 2025-07-15 at 12 44 15 PM Release Notes: - Automatic retry for more Agent errors - Whenever the Agent stops, play a sound (if configured) and show a notification (if configured) if the Zed window was in the background. --- crates/agent/src/thread.rs | 425 ++++++++++----------------- crates/agent_ui/src/active_thread.rs | 81 +++-- crates/agent_ui/src/agent_diff.rs | 1 - crates/eval/src/example.rs | 3 - 4 files changed, 215 insertions(+), 295 deletions(-) diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 6a20ad8f83dd984c74a001fb86ccd564b110ce24..8e66e526deedc6db1bec7912f48008bd6b36782c 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -21,6 +21,7 @@ use gpui::{ AnyWindowHandle, App, AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity, Window, }; +use http_client::StatusCode; use language_model::{ ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelExt as _, LanguageModelId, LanguageModelRegistry, LanguageModelRequest, @@ -51,7 +52,19 @@ use uuid::Uuid; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; const MAX_RETRY_ATTEMPTS: u8 = 3; -const BASE_RETRY_DELAY_SECS: u64 = 5; +const BASE_RETRY_DELAY: Duration = Duration::from_secs(5); + +#[derive(Debug, Clone)] +enum RetryStrategy { + ExponentialBackoff { + initial_delay: Duration, + max_attempts: u8, + }, + Fixed { + delay: Duration, + max_attempts: u8, + }, +} #[derive( Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Serialize, Deserialize, JsonSchema, @@ -1933,18 +1946,6 @@ impl Thread { project.set_agent_location(None, cx); }); - fn emit_generic_error(error: &anyhow::Error, cx: &mut Context) { - let error_message = error - .chain() - .map(|err| err.to_string()) - .collect::>() - .join("\n"); - cx.emit(ThreadEvent::ShowError(ThreadError::Message { - header: "Error interacting with language model".into(), - message: SharedString::from(error_message.clone()), - })); - } - if error.is::() { cx.emit(ThreadEvent::ShowError(ThreadError::PaymentRequired)); } else if let Some(error) = @@ -1956,9 +1957,10 @@ impl Thread { } else if let Some(completion_error) = error.downcast_ref::() { - use LanguageModelCompletionError::*; match &completion_error { - PromptTooLarge { tokens, .. } => { + LanguageModelCompletionError::PromptTooLarge { + tokens, .. + } => { let tokens = tokens.unwrap_or_else(|| { // We didn't get an exact token count from the API, so fall back on our estimate. thread @@ -1979,63 +1981,22 @@ impl Thread { }); cx.notify(); } - RateLimitExceeded { - retry_after: Some(retry_after), - .. - } - | ServerOverloaded { - retry_after: Some(retry_after), - .. - } => { - thread.handle_rate_limit_error( - &completion_error, - *retry_after, - model.clone(), - intent, - window, - cx, - ); - retry_scheduled = true; - } - RateLimitExceeded { .. } | ServerOverloaded { .. } => { - retry_scheduled = thread.handle_retryable_error( - &completion_error, - model.clone(), - intent, - window, - cx, - ); - if !retry_scheduled { - emit_generic_error(error, cx); - } - } - ApiInternalServerError { .. } - | ApiReadResponseError { .. } - | HttpSend { .. } => { - retry_scheduled = thread.handle_retryable_error( - &completion_error, - model.clone(), - intent, - window, - cx, - ); - if !retry_scheduled { - emit_generic_error(error, cx); + _ => { + if let Some(retry_strategy) = + Thread::get_retry_strategy(completion_error) + { + retry_scheduled = thread + .handle_retryable_error_with_delay( + &completion_error, + Some(retry_strategy), + model.clone(), + intent, + window, + cx, + ); } } - NoApiKey { .. } - | HttpResponseError { .. } - | BadRequestFormat { .. } - | AuthenticationError { .. } - | PermissionError { .. } - | ApiEndpointNotFound { .. } - | SerializeRequest { .. } - | BuildRequestBody { .. } - | DeserializeResponse { .. } - | Other { .. } => emit_generic_error(error, cx), } - } else { - emit_generic_error(error, cx); } if !retry_scheduled { @@ -2162,73 +2123,86 @@ impl Thread { }); } - fn handle_rate_limit_error( - &mut self, - error: &LanguageModelCompletionError, - retry_after: Duration, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) { - // For rate limit errors, we only retry once with the specified duration - let retry_message = format!("{error}. Retrying in {} seconds…", retry_after.as_secs()); - log::warn!( - "Retrying completion request in {} seconds: {error:?}", - retry_after.as_secs(), - ); - - // Add a UI-only message instead of a regular message - let id = self.next_message_id.post_inc(); - self.messages.push(Message { - id, - role: Role::System, - segments: vec![MessageSegment::Text(retry_message)], - loaded_context: LoadedContext::default(), - creases: Vec::new(), - is_hidden: false, - ui_only: true, - }); - cx.emit(ThreadEvent::MessageAdded(id)); - // Schedule the retry - let thread_handle = cx.entity().downgrade(); - - cx.spawn(async move |_thread, cx| { - cx.background_executor().timer(retry_after).await; + fn get_retry_strategy(error: &LanguageModelCompletionError) -> Option { + use LanguageModelCompletionError::*; - thread_handle - .update(cx, |thread, cx| { - // Retry the completion - thread.send_to_model(model, intent, window, cx); + // General strategy here: + // - If retrying won't help (e.g. invalid API key or payload too large), return None so we don't retry at all. + // - If it's a time-based issue (e.g. server overloaded, rate limit exceeded), try multiple times with exponential backoff. + // - If it's an issue that *might* be fixed by retrying (e.g. internal server error), just retry once. + match error { + HttpResponseError { + status_code: StatusCode::TOO_MANY_REQUESTS, + .. + } => Some(RetryStrategy::ExponentialBackoff { + initial_delay: BASE_RETRY_DELAY, + max_attempts: MAX_RETRY_ATTEMPTS, + }), + ServerOverloaded { retry_after, .. } | RateLimitExceeded { retry_after, .. } => { + Some(RetryStrategy::Fixed { + delay: retry_after.unwrap_or(BASE_RETRY_DELAY), + max_attempts: MAX_RETRY_ATTEMPTS, }) - .log_err(); - }) - .detach(); - } - - fn handle_retryable_error( - &mut self, - error: &LanguageModelCompletionError, - model: Arc, - intent: CompletionIntent, - window: Option, - cx: &mut Context, - ) -> bool { - self.handle_retryable_error_with_delay(error, None, model, intent, window, cx) + } + ApiInternalServerError { .. } => Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 1, + }), + ApiReadResponseError { .. } + | HttpSend { .. } + | DeserializeResponse { .. } + | BadRequestFormat { .. } => Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 1, + }), + // Retrying these errors definitely shouldn't help. + HttpResponseError { + status_code: + StatusCode::PAYLOAD_TOO_LARGE | StatusCode::FORBIDDEN | StatusCode::UNAUTHORIZED, + .. + } + | SerializeRequest { .. } + | BuildRequestBody { .. } + | PromptTooLarge { .. } + | AuthenticationError { .. } + | PermissionError { .. } + | ApiEndpointNotFound { .. } + | NoApiKey { .. } => None, + // Retry all other 4xx and 5xx errors once. + HttpResponseError { status_code, .. } + if status_code.is_client_error() || status_code.is_server_error() => + { + Some(RetryStrategy::Fixed { + delay: BASE_RETRY_DELAY, + max_attempts: 1, + }) + } + // Conservatively assume that any other errors are non-retryable + HttpResponseError { .. } | Other(..) => None, + } } fn handle_retryable_error_with_delay( &mut self, error: &LanguageModelCompletionError, - custom_delay: Option, + strategy: Option, model: Arc, intent: CompletionIntent, window: Option, cx: &mut Context, ) -> bool { + let Some(strategy) = strategy.or_else(|| Self::get_retry_strategy(error)) else { + return false; + }; + + let max_attempts = match &strategy { + RetryStrategy::ExponentialBackoff { max_attempts, .. } => *max_attempts, + RetryStrategy::Fixed { max_attempts, .. } => *max_attempts, + }; + let retry_state = self.retry_state.get_or_insert(RetryState { attempt: 0, - max_attempts: MAX_RETRY_ATTEMPTS, + max_attempts, intent, }); @@ -2238,20 +2212,24 @@ impl Thread { let intent = retry_state.intent; if attempt <= max_attempts { - // Use custom delay if provided (e.g., from rate limit), otherwise exponential backoff - let delay = if let Some(custom_delay) = custom_delay { - custom_delay - } else { - let delay_secs = BASE_RETRY_DELAY_SECS * 2u64.pow((attempt - 1) as u32); - Duration::from_secs(delay_secs) + 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, }; // Add a transient message to inform the user let delay_secs = delay.as_secs(); - let retry_message = format!( - "{error}. Retrying (attempt {attempt} of {max_attempts}) \ - in {delay_secs} seconds..." - ); + let retry_message = if max_attempts == 1 { + format!("{error}. Retrying in {delay_secs} seconds...") + } else { + format!( + "{error}. Retrying (attempt {attempt} of {max_attempts}) \ + in {delay_secs} seconds..." + ) + }; log::warn!( "Retrying completion request (attempt {attempt} of {max_attempts}) \ in {delay_secs} seconds: {error:?}", @@ -2290,19 +2268,9 @@ impl Thread { // Max retries exceeded self.retry_state = None; - let notification_text = if max_attempts == 1 { - "Failed after retrying.".into() - } else { - format!("Failed after retrying {} times.", max_attempts).into() - }; - // Stop generating since we're giving up on retrying. self.pending_completions.clear(); - cx.emit(ThreadEvent::RetriesFailed { - message: notification_text, - }); - false } } @@ -3258,9 +3226,6 @@ pub enum ThreadEvent { CancelEditing, CompletionCanceled, ProfileChanged, - RetriesFailed { - message: SharedString, - }, } impl EventEmitter for Thread {} @@ -4192,7 +4157,7 @@ fn main() {{ assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); assert_eq!( retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Should have default max attempts" + "Should retry MAX_RETRY_ATTEMPTS times for overloaded errors" ); }); @@ -4265,7 +4230,7 @@ fn main() {{ let retry_state = thread.retry_state.as_ref().unwrap(); assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, + retry_state.max_attempts, 1, "Should have correct max attempts" ); }); @@ -4281,8 +4246,8 @@ fn main() {{ if let MessageSegment::Text(text) = seg { text.contains("internal") && text.contains("Fake") - && text - .contains(&format!("attempt 1 of {}", MAX_RETRY_ATTEMPTS)) + && text.contains("Retrying in") + && !text.contains("attempt") } else { false } @@ -4320,8 +4285,8 @@ fn main() {{ let project = create_test_project(cx, json!({})).await; let (_, _, thread, _, _base_model) = setup_test_environment(cx, project.clone()).await; - // Create model that returns overloaded error - let model = Arc::new(ErrorInjector::new(TestError::Overloaded)); + // Create model that returns internal server error + let model = Arc::new(ErrorInjector::new(TestError::InternalServerError)); // Insert a user message thread.update(cx, |thread, cx| { @@ -4371,50 +4336,17 @@ fn main() {{ assert!(thread.retry_state.is_some(), "Should have retry state"); let retry_state = thread.retry_state.as_ref().unwrap(); assert_eq!(retry_state.attempt, 1, "Should be first retry attempt"); - }); - - // Advance clock for first retry - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS)); - cx.run_until_parked(); - - // Should have scheduled second retry - count retry messages - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - assert_eq!(retry_count, 2, "Should have scheduled second retry"); - - // Check retry state updated - thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!(retry_state.attempt, 2, "Should be second retry attempt"); assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Should have correct max attempts" + retry_state.max_attempts, 1, + "Internal server errors should only retry once" ); }); - // Advance clock for second retry (exponential backoff) - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS * 2)); + // Advance clock for first retry + cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); - // Should have scheduled third retry - // Count all retry messages now + // Should have scheduled second retry - count retry messages let retry_count = thread.update(cx, |thread, _| { thread .messages @@ -4432,56 +4364,24 @@ fn main() {{ .count() }); assert_eq!( - retry_count, MAX_RETRY_ATTEMPTS as usize, - "Should have scheduled third retry" + retry_count, 1, + "Should have only one retry for internal server errors" ); - // Check retry state updated + // For internal server errors, we only retry once and then give up + // Check that retry_state is cleared after the single retry thread.read_with(cx, |thread, _| { - assert!(thread.retry_state.is_some(), "Should have retry state"); - let retry_state = thread.retry_state.as_ref().unwrap(); - assert_eq!( - retry_state.attempt, MAX_RETRY_ATTEMPTS, - "Should be at max retry attempt" - ); - assert_eq!( - retry_state.max_attempts, MAX_RETRY_ATTEMPTS, - "Should have correct max attempts" + assert!( + thread.retry_state.is_none(), + "Retry state should be cleared after single retry" ); }); - // Advance clock for third retry (exponential backoff) - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS * 4)); - cx.run_until_parked(); - - // No more retries should be scheduled after clock was advanced. - let retry_count = thread.update(cx, |thread, _| { - thread - .messages - .iter() - .filter(|m| { - m.ui_only - && m.segments.iter().any(|s| { - if let MessageSegment::Text(text) = s { - text.contains("Retrying") && text.contains("seconds") - } else { - false - } - }) - }) - .count() - }); - assert_eq!( - retry_count, MAX_RETRY_ATTEMPTS as usize, - "Should not exceed max retries" - ); - - // Final completion count should be initial + max retries + // Verify total attempts (1 initial + 1 retry) assert_eq!( *completion_count.lock(), - (MAX_RETRY_ATTEMPTS + 1) as usize, - "Should have made initial + max retry attempts" + 2, + "Should have attempted once plus 1 retry" ); } @@ -4501,13 +4401,13 @@ fn main() {{ }); // Track events - let retries_failed = Arc::new(Mutex::new(false)); - let retries_failed_clone = retries_failed.clone(); + let stopped_with_error = Arc::new(Mutex::new(false)); + let stopped_with_error_clone = stopped_with_error.clone(); let _subscription = thread.update(cx, |_, cx| { cx.subscribe(&thread, move |_, _, event: &ThreadEvent, _| { - if let ThreadEvent::RetriesFailed { .. } = event { - *retries_failed_clone.lock() = true; + if let ThreadEvent::Stopped(Err(_)) = event { + *stopped_with_error_clone.lock() = true; } }) }); @@ -4519,23 +4419,11 @@ fn main() {{ cx.run_until_parked(); // Advance through all retries - for i in 0..MAX_RETRY_ATTEMPTS { - let delay = if i == 0 { - BASE_RETRY_DELAY_SECS - } else { - BASE_RETRY_DELAY_SECS * 2u64.pow(i as u32 - 1) - }; - cx.executor().advance_clock(Duration::from_secs(delay)); + for _ in 0..MAX_RETRY_ATTEMPTS { + cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); } - // After the 3rd retry is scheduled, we need to wait for it to execute and fail - // The 3rd retry has a delay of BASE_RETRY_DELAY_SECS * 4 (20 seconds) - let final_delay = BASE_RETRY_DELAY_SECS * 2u64.pow((MAX_RETRY_ATTEMPTS - 1) as u32); - cx.executor() - .advance_clock(Duration::from_secs(final_delay)); - cx.run_until_parked(); - let retry_count = thread.update(cx, |thread, _| { thread .messages @@ -4553,14 +4441,14 @@ fn main() {{ .count() }); - // After max retries, should emit RetriesFailed event + // After max retries, should emit Stopped(Err(...)) event assert_eq!( retry_count, MAX_RETRY_ATTEMPTS as usize, - "Should have attempted max retries" + "Should have attempted MAX_RETRY_ATTEMPTS retries for overloaded errors" ); assert!( - *retries_failed.lock(), - "Should emit RetriesFailed event after max retries exceeded" + *stopped_with_error.lock(), + "Should emit Stopped(Err(...)) event after max retries exceeded" ); // Retry state should be cleared @@ -4578,7 +4466,7 @@ fn main() {{ .count(); assert_eq!( retry_messages, MAX_RETRY_ATTEMPTS as usize, - "Should have one retry message per attempt" + "Should have MAX_RETRY_ATTEMPTS retry messages for overloaded errors" ); }); } @@ -4716,8 +4604,7 @@ fn main() {{ }); // Wait for retry - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS)); + cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); // Stream some successful content @@ -4879,8 +4766,7 @@ fn main() {{ }); // Wait for retry delay - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS)); + cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); // The retry should now use our FailOnceModel which should succeed @@ -5039,9 +4925,15 @@ fn main() {{ thread.read_with(cx, |thread, _| { assert!( - thread.retry_state.is_none(), - "Rate limit errors should not set retry_state" + thread.retry_state.is_some(), + "Rate limit errors should set retry_state" ); + if let Some(retry_state) = &thread.retry_state { + assert_eq!( + retry_state.max_attempts, MAX_RETRY_ATTEMPTS, + "Rate limit errors should use MAX_RETRY_ATTEMPTS" + ); + } }); // Verify we have one retry message @@ -5074,18 +4966,15 @@ fn main() {{ .find(|msg| msg.role == Role::System && msg.ui_only) .expect("Should have a retry message"); - // Check that the message doesn't contain attempt count + // Check that the message contains attempt count since we use retry_state if let Some(MessageSegment::Text(text)) = retry_message.segments.first() { assert!( - !text.contains("attempt"), - "Rate limit retry message should not contain attempt count" + text.contains(&format!("attempt 1 of {}", MAX_RETRY_ATTEMPTS)), + "Rate limit retry message should contain attempt count with MAX_RETRY_ATTEMPTS" ); assert!( - text.contains(&format!( - "Retrying in {} seconds", - TEST_RATE_LIMIT_RETRY_SECS - )), - "Rate limit retry message should contain retry delay" + text.contains("Retrying"), + "Rate limit retry message should contain retry text" ); } }); diff --git a/crates/agent_ui/src/active_thread.rs b/crates/agent_ui/src/active_thread.rs index 383729017a1635e4301fa50d587f70940543130f..3cf68b887ddf032b9b9f3ea8092bdbd97f31f90f 100644 --- a/crates/agent_ui/src/active_thread.rs +++ b/crates/agent_ui/src/active_thread.rs @@ -996,30 +996,57 @@ impl ActiveThread { | ThreadEvent::SummaryChanged => { self.save_thread(cx); } - ThreadEvent::Stopped(reason) => match reason { - Ok(StopReason::EndTurn | StopReason::MaxTokens) => { - let used_tools = self.thread.read(cx).used_tools_since_last_user_message(); - self.play_notification_sound(window, cx); - self.show_notification( - if used_tools { - "Finished running tools" - } else { - "New message" - }, - IconName::ZedAssistant, - window, - cx, - ); + ThreadEvent::Stopped(reason) => { + match reason { + Ok(StopReason::EndTurn | StopReason::MaxTokens) => { + let used_tools = self.thread.read(cx).used_tools_since_last_user_message(); + self.notify_with_sound( + if used_tools { + "Finished running tools" + } else { + "New message" + }, + IconName::ZedAssistant, + window, + cx, + ); + } + Ok(StopReason::ToolUse) => { + // Don't notify for intermediate tool use + } + Ok(StopReason::Refusal) => { + self.notify_with_sound( + "Language model refused to respond", + IconName::Warning, + window, + cx, + ); + } + Err(error) => { + self.notify_with_sound( + "Agent stopped due to an error", + IconName::Warning, + window, + cx, + ); + + let error_message = error + .chain() + .map(|err| err.to_string()) + .collect::>() + .join("\n"); + self.last_error = Some(ThreadError::Message { + header: "Error interacting with language model".into(), + message: error_message.into(), + }); + } } - _ => {} - }, + } ThreadEvent::ToolConfirmationNeeded => { - self.play_notification_sound(window, cx); - self.show_notification("Waiting for tool confirmation", IconName::Info, window, cx); + self.notify_with_sound("Waiting for tool confirmation", IconName::Info, window, cx); } ThreadEvent::ToolUseLimitReached => { - self.play_notification_sound(window, cx); - self.show_notification( + self.notify_with_sound( "Consecutive tool use limit reached.", IconName::Warning, window, @@ -1162,9 +1189,6 @@ impl ActiveThread { self.save_thread(cx); cx.notify(); } - ThreadEvent::RetriesFailed { message } => { - self.show_notification(message, ui::IconName::Warning, window, cx); - } } } @@ -1219,6 +1243,17 @@ impl ActiveThread { } } + fn notify_with_sound( + &mut self, + caption: impl Into, + icon: IconName, + window: &mut Window, + cx: &mut Context, + ) { + self.play_notification_sound(window, cx); + self.show_notification(caption, icon, window, cx); + } + fn pop_up( &mut self, icon: IconName, diff --git a/crates/agent_ui/src/agent_diff.rs b/crates/agent_ui/src/agent_diff.rs index 31fb0dd69fbbd133888eb26d14643d816c810554..000e27032202d56f1fa98cdb82d73ef757a84766 100644 --- a/crates/agent_ui/src/agent_diff.rs +++ b/crates/agent_ui/src/agent_diff.rs @@ -1488,7 +1488,6 @@ impl AgentDiff { | ThreadEvent::ToolConfirmationNeeded | ThreadEvent::ToolUseLimitReached | ThreadEvent::CancelEditing - | ThreadEvent::RetriesFailed { .. } | ThreadEvent::ProfileChanged => {} } } diff --git a/crates/eval/src/example.rs b/crates/eval/src/example.rs index 904eca83e609dc8766fb3a5a69ed9040c82f0168..09770364cb6b460a4ce8d61d76bcc833cb466129 100644 --- a/crates/eval/src/example.rs +++ b/crates/eval/src/example.rs @@ -221,9 +221,6 @@ impl ExampleContext { ThreadEvent::ShowError(thread_error) => { tx.try_send(Err(anyhow!(thread_error.clone()))).ok(); } - ThreadEvent::RetriesFailed { .. } => { - // Ignore retries failed events - } ThreadEvent::Stopped(reason) => match reason { Ok(StopReason::EndTurn) => { tx.close_channel(); From 78b77373685ed018f7f30fe7e4f1a805ded18f2a Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Tue, 15 Jul 2025 13:07:01 -0600 Subject: [PATCH 0010/1056] Remove scap from workspace-hack (#34490) Regression in #34251 which broke remote_server build Release Notes: - N/A --- .config/hakari.toml | 2 ++ Cargo.lock | 3 --- Cargo.toml | 1 + tooling/workspace-hack/Cargo.toml | 12 ------------ 4 files changed, 3 insertions(+), 15 deletions(-) diff --git a/.config/hakari.toml b/.config/hakari.toml index 982542ca397e072d83af67608ea31a3415360a8e..5168887581c8a1fdae0478e74b0f01225c7a1465 100644 --- a/.config/hakari.toml +++ b/.config/hakari.toml @@ -23,6 +23,8 @@ workspace-members = [ ] third-party = [ { name = "reqwest", version = "0.11.27" }, + # build of remote_server should not include scap / its x11 dependency + { name = "scap", git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318" }, ] [final-excludes] diff --git a/Cargo.lock b/Cargo.lock index de808ff263088b952c60befb84d6c6ce786a19e0..e2d86576c372b538943b92cd548590ed655ede2d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -19687,7 +19687,6 @@ dependencies = [ "rustix 1.0.7", "rustls 0.23.26", "rustls-webpki 0.103.1", - "scap", "schemars", "scopeguard", "sea-orm", @@ -19735,9 +19734,7 @@ dependencies = [ "wasmtime-cranelift", "wasmtime-environ", "winapi", - "windows 0.61.1", "windows-core 0.61.0", - "windows-future", "windows-numerics", "windows-sys 0.48.0", "windows-sys 0.52.0", diff --git a/Cargo.toml b/Cargo.toml index 5403f279c80666f37e3d837e200ba0ba0b100a9f..0e4cd1504ff76a065e0ce557acc216b5c6c830bd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -547,6 +547,7 @@ rustc-demangle = "0.1.23" rustc-hash = "2.1.0" rustls = { version = "0.23.26" } rustls-platform-verifier = "0.5.0" +# When updating scap rev, also update it in .config/hakari.toml scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false } schemars = { version = "1.0", features = ["indexmap2"] } semver = "1.0" diff --git a/tooling/workspace-hack/Cargo.toml b/tooling/workspace-hack/Cargo.toml index 530c2cf925017cb4a1d833290567e08b07a408ed..10264540262bfd021577a954bf8933a2554ca222 100644 --- a/tooling/workspace-hack/Cargo.toml +++ b/tooling/workspace-hack/Cargo.toml @@ -429,7 +429,6 @@ rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "param", "pipe", "process", "pty", "shm", "stdio", "system", "termios", "time"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } @@ -469,7 +468,6 @@ rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "param", "pipe", "process", "pty", "shm", "stdio", "system", "termios", "time"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } @@ -509,7 +507,6 @@ rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "param", "pipe", "process", "pty", "shm", "stdio", "system", "termios", "time"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } @@ -549,7 +546,6 @@ rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "param", "pipe", "process", "pty", "shm", "stdio", "system", "termios", "time"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } @@ -571,7 +567,6 @@ itertools-5ef9efb8ec2df382 = { package = "itertools", version = "0.12" } naga = { version = "25", features = ["spv-out", "wgsl-in"] } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } @@ -579,9 +574,7 @@ tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } winapi = { version = "0.3", default-features = false, features = ["cfg", "commapi", "consoleapi", "errhandlingapi", "evntrace", "fileapi", "handleapi", "impl-debug", "impl-default", "in6addr", "inaddr", "ioapiset", "knownfolders", "minwinbase", "minwindef", "namedpipeapi", "ntsecapi", "objbase", "processenv", "processthreadsapi", "shlobj", "std", "synchapi", "sysinfoapi", "timezoneapi", "winbase", "windef", "winerror", "winioctl", "winnt", "winreg", "winsock2", "winuser"] } -windows = { version = "0.61", features = ["Foundation_Metadata", "Foundation_Numerics", "Graphics_Capture", "Graphics_DirectX_Direct3D11", "Graphics_Imaging", "Media_Core", "Media_MediaProperties", "Media_Transcoding", "Security_Cryptography", "Storage_Search", "Storage_Streams", "System_Threading", "UI_ViewManagement", "Wdk_System_SystemServices", "Win32_Devices_Display", "Win32_Globalization", "Win32_Graphics_Direct2D_Common", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_Graphics_DirectWrite", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_Imaging_D2D", "Win32_Networking_WinSock", "Win32_Security_Credentials", "Win32_Storage_FileSystem", "Win32_System_Com_StructuredStorage", "Win32_System_Console", "Win32_System_DataExchange", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Win32_UI_Controls", "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging"] } windows-core = { version = "0.61" } -windows-future = { version = "0.2" } windows-numerics = { version = "0.2" } windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Globalization", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } @@ -599,7 +592,6 @@ naga = { version = "25", features = ["spv-out", "wgsl-in"] } proc-macro2 = { version = "1", default-features = false, features = ["span-locations"] } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } @@ -607,9 +599,7 @@ tokio-socks = { version = "0.5", features = ["futures-io"] } tokio-stream = { version = "0.1", features = ["fs"] } tower = { version = "0.5", default-features = false, features = ["timeout", "util"] } winapi = { version = "0.3", default-features = false, features = ["cfg", "commapi", "consoleapi", "errhandlingapi", "evntrace", "fileapi", "handleapi", "impl-debug", "impl-default", "in6addr", "inaddr", "ioapiset", "knownfolders", "minwinbase", "minwindef", "namedpipeapi", "ntsecapi", "objbase", "processenv", "processthreadsapi", "shlobj", "std", "synchapi", "sysinfoapi", "timezoneapi", "winbase", "windef", "winerror", "winioctl", "winnt", "winreg", "winsock2", "winuser"] } -windows = { version = "0.61", features = ["Foundation_Metadata", "Foundation_Numerics", "Graphics_Capture", "Graphics_DirectX_Direct3D11", "Graphics_Imaging", "Media_Core", "Media_MediaProperties", "Media_Transcoding", "Security_Cryptography", "Storage_Search", "Storage_Streams", "System_Threading", "UI_ViewManagement", "Wdk_System_SystemServices", "Win32_Devices_Display", "Win32_Globalization", "Win32_Graphics_Direct2D_Common", "Win32_Graphics_Direct3D", "Win32_Graphics_Direct3D11", "Win32_Graphics_DirectWrite", "Win32_Graphics_Dwm", "Win32_Graphics_Dxgi_Common", "Win32_Graphics_Gdi", "Win32_Graphics_Imaging_D2D", "Win32_Networking_WinSock", "Win32_Security_Credentials", "Win32_Storage_FileSystem", "Win32_System_Com_StructuredStorage", "Win32_System_Console", "Win32_System_DataExchange", "Win32_System_IO", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Ole", "Win32_System_Pipes", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Variant", "Win32_System_WinRT_Direct3D11", "Win32_System_WinRT_Graphics_Capture", "Win32_UI_Controls", "Win32_UI_HiDpi", "Win32_UI_Input_Ime", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell_Common", "Win32_UI_Shell_PropertiesSystem", "Win32_UI_WindowsAndMessaging"] } windows-core = { version = "0.61" } -windows-future = { version = "0.2" } windows-numerics = { version = "0.2" } windows-sys-73dcd821b1037cfd = { package = "windows-sys", version = "0.59", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Win32_Globalization", "Win32_NetworkManagement_IpHelper", "Win32_Networking_WinSock", "Win32_Security_Authentication_Identity", "Win32_Security_Credentials", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Diagnostics_Debug", "Win32_System_IO", "Win32_System_Ioctl", "Win32_System_Kernel", "Win32_System_LibraryLoader", "Win32_System_Memory", "Win32_System_Performance", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_Time", "Win32_System_WindowsProgramming", "Win32_UI_Input_KeyboardAndMouse", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } windows-sys-b21d60becc0929df = { package = "windows-sys", version = "0.52", features = ["Wdk_Foundation", "Wdk_Storage_FileSystem", "Wdk_System_IO", "Win32_Foundation", "Win32_Networking_WinSock", "Win32_Security_Authorization", "Win32_Storage_FileSystem", "Win32_System_Console", "Win32_System_IO", "Win32_System_Memory", "Win32_System_Pipes", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming"] } @@ -644,7 +634,6 @@ rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "param", "pipe", "process", "pty", "shm", "stdio", "system", "termios", "time"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } syn-f595c2ba2a3f28df = { package = "syn", version = "2", features = ["extra-traits", "fold", "full", "visit", "visit-mut"] } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } @@ -684,7 +673,6 @@ rand-274715c4dabd11b0 = { package = "rand", version = "0.9" } ring = { version = "0.17", features = ["std"] } rustix-d585fab2519d2d1 = { package = "rustix", version = "0.38", features = ["event", "mm", "param", "pipe", "process", "pty", "shm", "stdio", "system", "termios", "time"] } rustix-dff4ba8e3ae991db = { package = "rustix", version = "1", features = ["fs", "net", "process", "termios", "time"] } -scap = { git = "https://github.com/zed-industries/scap", rev = "270538dc780f5240723233ff901e1054641ed318", default-features = false, features = ["x11"] } scopeguard = { version = "1" } sync_wrapper = { version = "1", default-features = false, features = ["futures"] } tokio-rustls = { version = "0.26", default-features = false, features = ["ring"] } From b39893508102653c7487a3d137235ef740435805 Mon Sep 17 00:00:00 2001 From: Ariel Rzezak Date: Tue, 15 Jul 2025 16:07:39 -0300 Subject: [PATCH 0011/1056] Fix comment in default.json (#34481) Update line to properly reference the intended setting. Release Notes: - N/A --- assets/settings/default.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/assets/settings/default.json b/assets/settings/default.json index edf07fdbf98e745998f3fac553de2b0a5d78cefd..aa6e4399c387227dd557f9e30fb76006a75f4c2c 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -84,7 +84,7 @@ "bottom_dock_layout": "contained", // The direction that you want to split panes horizontally. Defaults to "up" "pane_split_direction_horizontal": "up", - // The direction that you want to split panes horizontally. Defaults to "left" + // The direction that you want to split panes vertically. Defaults to "left" "pane_split_direction_vertical": "left", // Centered layout related settings. "centered_layout": { From af0031ae8bef3ecde88a2dd73aa2692a1ab06af2 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 15 Jul 2025 15:16:48 -0400 Subject: [PATCH 0012/1056] Fix positioning of terminal inline assist after clearing the screen (#34465) Closes #33945. Here's my attempt to describe what's going on in that issue and what this fix is doing: We always render the terminal inline assistant starting on the line after the cursor, with a height of 4 lines. When deploying it, we scroll the viewport to the bottom of the terminal so that the assistant will be in view. When scrolling while the assistant is deployed (including in that case), we need to make an adjustment that "pushes up" the terminal content by the height of the assistant, so that we can scroll to see all the normal content plus the assistant itself. That quantity is `scroll_top`, which represents _how much height in the current viewport is occupied by the assistant that would otherwise be occupied by terminal content_. So when you scroll up and a line of the assistant's height goes out of view, `scroll_top` decreases by 1, etc. When we scroll to the bottom after deploying the assistant, we set `scroll_top` to the result of calling `max_scroll_top`, which computes it this way: ``` block.height.saturating_sub(viewport_lines.saturating_sub(terminal_lines)) ``` Which, being interpreted, is "the height of the assistant, minus any viewport lines that are not occupied by terminal content", i.e. the assistant is allowed to eat up vertical space below the last line of terminal content without increasing `scroll_top`. The problem comes when we clear the screen---this adds a full screen to `terminal_lines`, but the cursor is positioned at the top of the viewport with blank lines below, just like at the beginning of a session when `terminal_lines == 1`. Those blank lines should be available to the assistant, but the `scroll_top` calculation doesn't reflect that. I've tried to fix this by basing the `max_scroll_top` calculation on the position of the cursor instead of the raw `terminal_lines` value. There was also a special case for `viewport_lines == terminal_lines` that I think can now be removed. Release Notes: - Fixed the positioning of the terminal inline assistant when it's deployed after clearing the terminal. --- crates/terminal_view/src/terminal_view.rs | 27 +++++++---------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index bad3ebd479fbf2deeaeeef08974a03d900c35389..1cc1fbcf6f671c8968975b807f080bfdce04317f 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -25,11 +25,11 @@ use terminal::{ TaskStatus, Terminal, TerminalBounds, ToggleViMode, alacritty_terminal::{ index::Point, - term::{TermMode, search::RegexSearch}, + term::{TermMode, point_to_viewport, search::RegexSearch}, }, terminal_settings::{self, CursorShape, TerminalBlink, TerminalSettings, WorkingDirectory}, }; -use terminal_element::{TerminalElement, is_blank}; +use terminal_element::TerminalElement; use terminal_panel::TerminalPanel; use terminal_scrollbar::TerminalScrollHandle; use terminal_slash_command::TerminalSlashCommand; @@ -497,25 +497,14 @@ impl TerminalView { }; let line_height = terminal.last_content().terminal_bounds.line_height; - let mut terminal_lines = terminal.total_lines(); let viewport_lines = terminal.viewport_lines(); - if terminal.total_lines() == terminal.viewport_lines() { - let mut last_line = None; - for cell in terminal.last_content.cells.iter().rev() { - if !is_blank(cell) { - break; - } - - let last_line = last_line.get_or_insert(cell.point.line); - if *last_line != cell.point.line { - terminal_lines -= 1; - } - *last_line = cell.point.line; - } - } - + let cursor = point_to_viewport( + terminal.last_content.display_offset, + terminal.last_content.cursor.point, + ) + .unwrap_or_default(); let max_scroll_top_in_lines = - (block.height as usize).saturating_sub(viewport_lines.saturating_sub(terminal_lines)); + (block.height as usize).saturating_sub(viewport_lines.saturating_sub(cursor.line + 1)); max_scroll_top_in_lines as f32 * line_height } From ec52e9281aacffc376b4747405a593b95b6851ca Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:05:50 +0530 Subject: [PATCH 0013/1056] Add xAI language model provider (#33593) Closes #30010 Release Notes: - Add support for xAI language model provider --- Cargo.lock | 12 + Cargo.toml | 2 + assets/icons/ai_x_ai.svg | 3 + crates/icons/src/icons.rs | 1 + crates/language_models/Cargo.toml | 1 + crates/language_models/src/language_models.rs | 2 + crates/language_models/src/provider.rs | 1 + .../src/provider/open_router.rs | 2 +- crates/language_models/src/provider/x_ai.rs | 571 ++++++++++++++++++ crates/language_models/src/settings.rs | 47 +- crates/x_ai/Cargo.toml | 23 + crates/x_ai/LICENSE-GPL | 1 + crates/x_ai/src/x_ai.rs | 126 ++++ docs/src/ai/configuration.md | 74 ++- 14 files changed, 839 insertions(+), 27 deletions(-) create mode 100644 assets/icons/ai_x_ai.svg create mode 100644 crates/language_models/src/provider/x_ai.rs create mode 100644 crates/x_ai/Cargo.toml create mode 120000 crates/x_ai/LICENSE-GPL create mode 100644 crates/x_ai/src/x_ai.rs diff --git a/Cargo.lock b/Cargo.lock index e2d86576c372b538943b92cd548590ed655ede2d..15a28016c6d2f168168657a07443ea40080b07bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9094,6 +9094,7 @@ dependencies = [ "util", "vercel", "workspace-hack", + "x_ai", "zed_llm_client", ] @@ -19840,6 +19841,17 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +[[package]] +name = "x_ai" +version = "0.1.0" +dependencies = [ + "anyhow", + "schemars", + "serde", + "strum 0.27.1", + "workspace-hack", +] + [[package]] name = "xattr" version = "0.2.3" diff --git a/Cargo.toml b/Cargo.toml index 0e4cd1504ff76a065e0ce557acc216b5c6c830bd..afb47c006e58d24fc9a6557ab437dfbf1db98e65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -179,6 +179,7 @@ members = [ "crates/welcome", "crates/workspace", "crates/worktree", + "crates/x_ai", "crates/zed", "crates/zed_actions", "crates/zeta", @@ -394,6 +395,7 @@ web_search_providers = { path = "crates/web_search_providers" } welcome = { path = "crates/welcome" } workspace = { path = "crates/workspace" } worktree = { path = "crates/worktree" } +x_ai = { path = "crates/x_ai" } zed = { path = "crates/zed" } zed_actions = { path = "crates/zed_actions" } zeta = { path = "crates/zeta" } diff --git a/assets/icons/ai_x_ai.svg b/assets/icons/ai_x_ai.svg new file mode 100644 index 0000000000000000000000000000000000000000..289525c8ef72a26b92db3c6ee98b2717a679fbc7 --- /dev/null +++ b/assets/icons/ai_x_ai.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index 3c24ee59f6edf99b6f8ccf3093e83cf3ebea86c5..b2ec7684355c27280ea7d4a056bfb30ff31ea79b 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -21,6 +21,7 @@ pub enum IconName { AiOpenAi, AiOpenRouter, AiVZero, + AiXAi, AiZed, ArrowCircle, ArrowDown, diff --git a/crates/language_models/Cargo.toml b/crates/language_models/Cargo.toml index 0f248edd574819aee9ac1311ed23de30be48b21e..5d158e84f4fb072d40e43a43cd53b5b996274351 100644 --- a/crates/language_models/Cargo.toml +++ b/crates/language_models/Cargo.toml @@ -43,6 +43,7 @@ ollama = { workspace = true, features = ["schemars"] } open_ai = { workspace = true, features = ["schemars"] } open_router = { workspace = true, features = ["schemars"] } vercel = { workspace = true, features = ["schemars"] } +x_ai = { workspace = true, features = ["schemars"] } partial-json-fixer.workspace = true proto.workspace = true release_channel.workspace = true diff --git a/crates/language_models/src/language_models.rs b/crates/language_models/src/language_models.rs index c7324732c9bbf88698a1a7280ff80cea077a1d2f..192f5a5fae214693fab8b1b166e907478ce307f2 100644 --- a/crates/language_models/src/language_models.rs +++ b/crates/language_models/src/language_models.rs @@ -20,6 +20,7 @@ use crate::provider::ollama::OllamaLanguageModelProvider; use crate::provider::open_ai::OpenAiLanguageModelProvider; use crate::provider::open_router::OpenRouterLanguageModelProvider; use crate::provider::vercel::VercelLanguageModelProvider; +use crate::provider::x_ai::XAiLanguageModelProvider; pub use crate::settings::*; pub fn init(user_store: Entity, client: Arc, cx: &mut App) { @@ -81,5 +82,6 @@ fn register_language_model_providers( VercelLanguageModelProvider::new(client.http_client(), cx), cx, ); + registry.register_provider(XAiLanguageModelProvider::new(client.http_client(), cx), cx); registry.register_provider(CopilotChatLanguageModelProvider::new(cx), cx); } diff --git a/crates/language_models/src/provider.rs b/crates/language_models/src/provider.rs index 6bc93bd3661e86fc2c8f9bacafaf2d4121e0f7a6..c717be7c907cebf7427c67c748b689cb40b0ed9d 100644 --- a/crates/language_models/src/provider.rs +++ b/crates/language_models/src/provider.rs @@ -10,3 +10,4 @@ pub mod ollama; pub mod open_ai; pub mod open_router; pub mod vercel; +pub mod x_ai; diff --git a/crates/language_models/src/provider/open_router.rs b/crates/language_models/src/provider/open_router.rs index c46135ff3eae704f5d54027457d8f86fbef4820a..5a6acc432993d74ad4fc0077bc2550bda76e1171 100644 --- a/crates/language_models/src/provider/open_router.rs +++ b/crates/language_models/src/provider/open_router.rs @@ -376,7 +376,7 @@ impl LanguageModel for OpenRouterLanguageModel { fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { let model_id = self.model.id().trim().to_lowercase(); - if model_id.contains("gemini") { + if model_id.contains("gemini") || model_id.contains("grok-4") { LanguageModelToolSchemaFormat::JsonSchemaSubset } else { LanguageModelToolSchemaFormat::JsonSchema diff --git a/crates/language_models/src/provider/x_ai.rs b/crates/language_models/src/provider/x_ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..5f6034571b54fd30baa6769881f5d27bbcaf162f --- /dev/null +++ b/crates/language_models/src/provider/x_ai.rs @@ -0,0 +1,571 @@ +use anyhow::{Context as _, Result, anyhow}; +use collections::BTreeMap; +use credentials_provider::CredentialsProvider; +use futures::{FutureExt, StreamExt, future::BoxFuture}; +use gpui::{AnyView, App, AsyncApp, Context, Entity, Subscription, Task, Window}; +use http_client::HttpClient; +use language_model::{ + AuthenticateError, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, + LanguageModelId, LanguageModelName, LanguageModelProvider, LanguageModelProviderId, + LanguageModelProviderName, LanguageModelProviderState, LanguageModelRequest, + LanguageModelToolChoice, LanguageModelToolSchemaFormat, RateLimiter, Role, +}; +use menu; +use open_ai::ResponseStreamEvent; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsStore}; +use std::sync::Arc; +use strum::IntoEnumIterator; +use x_ai::Model; + +use ui::{ElevationIndex, List, Tooltip, prelude::*}; +use ui_input::SingleLineInput; +use util::ResultExt; + +use crate::{AllLanguageModelSettings, ui::InstructionListItem}; + +const PROVIDER_ID: &str = "x_ai"; +const PROVIDER_NAME: &str = "xAI"; + +#[derive(Default, Clone, Debug, PartialEq)] +pub struct XAiSettings { + pub api_url: String, + pub available_models: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AvailableModel { + pub name: String, + pub display_name: Option, + pub max_tokens: u64, + pub max_output_tokens: Option, + pub max_completion_tokens: Option, +} + +pub struct XAiLanguageModelProvider { + http_client: Arc, + state: gpui::Entity, +} + +pub struct State { + api_key: Option, + api_key_from_env: bool, + _subscription: Subscription, +} + +const XAI_API_KEY_VAR: &str = "XAI_API_KEY"; + +impl State { + fn is_authenticated(&self) -> bool { + self.api_key.is_some() + } + + fn reset_api_key(&self, cx: &mut Context) -> Task> { + let credentials_provider = ::global(cx); + let settings = &AllLanguageModelSettings::get_global(cx).x_ai; + let api_url = if settings.api_url.is_empty() { + x_ai::XAI_API_URL.to_string() + } else { + settings.api_url.clone() + }; + cx.spawn(async move |this, cx| { + credentials_provider + .delete_credentials(&api_url, &cx) + .await + .log_err(); + this.update(cx, |this, cx| { + this.api_key = None; + this.api_key_from_env = false; + cx.notify(); + }) + }) + } + + fn set_api_key(&mut self, api_key: String, cx: &mut Context) -> Task> { + let credentials_provider = ::global(cx); + let settings = &AllLanguageModelSettings::get_global(cx).x_ai; + let api_url = if settings.api_url.is_empty() { + x_ai::XAI_API_URL.to_string() + } else { + settings.api_url.clone() + }; + cx.spawn(async move |this, cx| { + credentials_provider + .write_credentials(&api_url, "Bearer", api_key.as_bytes(), &cx) + .await + .log_err(); + this.update(cx, |this, cx| { + this.api_key = Some(api_key); + cx.notify(); + }) + }) + } + + fn authenticate(&self, cx: &mut Context) -> Task> { + if self.is_authenticated() { + return Task::ready(Ok(())); + } + + let credentials_provider = ::global(cx); + let settings = &AllLanguageModelSettings::get_global(cx).x_ai; + let api_url = if settings.api_url.is_empty() { + x_ai::XAI_API_URL.to_string() + } else { + settings.api_url.clone() + }; + cx.spawn(async move |this, cx| { + let (api_key, from_env) = if let Ok(api_key) = std::env::var(XAI_API_KEY_VAR) { + (api_key, true) + } else { + let (_, api_key) = credentials_provider + .read_credentials(&api_url, &cx) + .await? + .ok_or(AuthenticateError::CredentialsNotFound)?; + ( + String::from_utf8(api_key).context("invalid {PROVIDER_NAME} API key")?, + false, + ) + }; + this.update(cx, |this, cx| { + this.api_key = Some(api_key); + this.api_key_from_env = from_env; + cx.notify(); + })?; + + Ok(()) + }) + } +} + +impl XAiLanguageModelProvider { + pub fn new(http_client: Arc, cx: &mut App) -> Self { + let state = cx.new(|cx| State { + api_key: None, + api_key_from_env: false, + _subscription: cx.observe_global::(|_this: &mut State, cx| { + cx.notify(); + }), + }); + + Self { http_client, state } + } + + fn create_language_model(&self, model: x_ai::Model) -> Arc { + Arc::new(XAiLanguageModel { + id: LanguageModelId::from(model.id().to_string()), + model, + state: self.state.clone(), + http_client: self.http_client.clone(), + request_limiter: RateLimiter::new(4), + }) + } +} + +impl LanguageModelProviderState for XAiLanguageModelProvider { + type ObservableEntity = State; + + fn observable_entity(&self) -> Option> { + Some(self.state.clone()) + } +} + +impl LanguageModelProvider for XAiLanguageModelProvider { + fn id(&self) -> LanguageModelProviderId { + LanguageModelProviderId(PROVIDER_ID.into()) + } + + fn name(&self) -> LanguageModelProviderName { + LanguageModelProviderName(PROVIDER_NAME.into()) + } + + fn icon(&self) -> IconName { + IconName::AiXAi + } + + fn default_model(&self, _cx: &App) -> Option> { + Some(self.create_language_model(x_ai::Model::default())) + } + + fn default_fast_model(&self, _cx: &App) -> Option> { + Some(self.create_language_model(x_ai::Model::default_fast())) + } + + fn provided_models(&self, cx: &App) -> Vec> { + let mut models = BTreeMap::default(); + + for model in x_ai::Model::iter() { + if !matches!(model, x_ai::Model::Custom { .. }) { + models.insert(model.id().to_string(), model); + } + } + + for model in &AllLanguageModelSettings::get_global(cx) + .x_ai + .available_models + { + models.insert( + model.name.clone(), + x_ai::Model::Custom { + name: model.name.clone(), + display_name: model.display_name.clone(), + max_tokens: model.max_tokens, + max_output_tokens: model.max_output_tokens, + max_completion_tokens: model.max_completion_tokens, + }, + ); + } + + models + .into_values() + .map(|model| self.create_language_model(model)) + .collect() + } + + fn is_authenticated(&self, cx: &App) -> bool { + self.state.read(cx).is_authenticated() + } + + fn authenticate(&self, cx: &mut App) -> Task> { + self.state.update(cx, |state, cx| state.authenticate(cx)) + } + + fn configuration_view(&self, window: &mut Window, cx: &mut App) -> AnyView { + cx.new(|cx| ConfigurationView::new(self.state.clone(), window, cx)) + .into() + } + + fn reset_credentials(&self, cx: &mut App) -> Task> { + self.state.update(cx, |state, cx| state.reset_api_key(cx)) + } +} + +pub struct XAiLanguageModel { + id: LanguageModelId, + model: x_ai::Model, + state: gpui::Entity, + http_client: Arc, + request_limiter: RateLimiter, +} + +impl XAiLanguageModel { + fn stream_completion( + &self, + request: open_ai::Request, + cx: &AsyncApp, + ) -> BoxFuture<'static, Result>>> + { + let http_client = self.http_client.clone(); + let Ok((api_key, api_url)) = cx.read_entity(&self.state, |state, cx| { + let settings = &AllLanguageModelSettings::get_global(cx).x_ai; + let api_url = if settings.api_url.is_empty() { + x_ai::XAI_API_URL.to_string() + } else { + settings.api_url.clone() + }; + (state.api_key.clone(), api_url) + }) else { + return futures::future::ready(Err(anyhow!("App state dropped"))).boxed(); + }; + + let future = self.request_limiter.stream(async move { + let api_key = api_key.context("Missing xAI API Key")?; + let request = + open_ai::stream_completion(http_client.as_ref(), &api_url, &api_key, request); + let response = request.await?; + Ok(response) + }); + + async move { Ok(future.await?.boxed()) }.boxed() + } +} + +impl LanguageModel for XAiLanguageModel { + fn id(&self) -> LanguageModelId { + self.id.clone() + } + + fn name(&self) -> LanguageModelName { + LanguageModelName::from(self.model.display_name().to_string()) + } + + fn provider_id(&self) -> LanguageModelProviderId { + LanguageModelProviderId(PROVIDER_ID.into()) + } + + fn provider_name(&self) -> LanguageModelProviderName { + LanguageModelProviderName(PROVIDER_NAME.into()) + } + + fn supports_tools(&self) -> bool { + self.model.supports_tool() + } + + fn supports_images(&self) -> bool { + self.model.supports_images() + } + + fn supports_tool_choice(&self, choice: LanguageModelToolChoice) -> bool { + match choice { + LanguageModelToolChoice::Auto + | LanguageModelToolChoice::Any + | LanguageModelToolChoice::None => true, + } + } + fn tool_input_format(&self) -> LanguageModelToolSchemaFormat { + let model_id = self.model.id().trim().to_lowercase(); + if model_id.eq(x_ai::Model::Grok4.id()) { + LanguageModelToolSchemaFormat::JsonSchemaSubset + } else { + LanguageModelToolSchemaFormat::JsonSchema + } + } + + fn telemetry_id(&self) -> String { + format!("x_ai/{}", self.model.id()) + } + + fn max_token_count(&self) -> u64 { + self.model.max_token_count() + } + + fn max_output_tokens(&self) -> Option { + self.model.max_output_tokens() + } + + fn count_tokens( + &self, + request: LanguageModelRequest, + cx: &App, + ) -> BoxFuture<'static, Result> { + count_xai_tokens(request, self.model.clone(), cx) + } + + fn stream_completion( + &self, + request: LanguageModelRequest, + cx: &AsyncApp, + ) -> BoxFuture< + 'static, + Result< + futures::stream::BoxStream< + 'static, + Result, + >, + LanguageModelCompletionError, + >, + > { + let request = crate::provider::open_ai::into_open_ai( + request, + self.model.id(), + self.model.supports_parallel_tool_calls(), + self.max_output_tokens(), + ); + let completions = self.stream_completion(request, cx); + async move { + let mapper = crate::provider::open_ai::OpenAiEventMapper::new(); + Ok(mapper.map_stream(completions.await?).boxed()) + } + .boxed() + } +} + +pub fn count_xai_tokens( + request: LanguageModelRequest, + model: Model, + cx: &App, +) -> BoxFuture<'static, Result> { + cx.background_spawn(async move { + let messages = request + .messages + .into_iter() + .map(|message| tiktoken_rs::ChatCompletionRequestMessage { + role: match message.role { + Role::User => "user".into(), + Role::Assistant => "assistant".into(), + Role::System => "system".into(), + }, + content: Some(message.string_contents()), + name: None, + function_call: None, + }) + .collect::>(); + + let model_name = if model.max_token_count() >= 100_000 { + "gpt-4o" + } else { + "gpt-4" + }; + tiktoken_rs::num_tokens_from_messages(model_name, &messages).map(|tokens| tokens as u64) + }) + .boxed() +} + +struct ConfigurationView { + api_key_editor: Entity, + state: gpui::Entity, + load_credentials_task: Option>, +} + +impl ConfigurationView { + fn new(state: gpui::Entity, window: &mut Window, cx: &mut Context) -> Self { + let api_key_editor = cx.new(|cx| { + SingleLineInput::new( + window, + cx, + "xai-0000000000000000000000000000000000000000000000000", + ) + .label("API key") + }); + + cx.observe(&state, |_, _, cx| { + cx.notify(); + }) + .detach(); + + let load_credentials_task = Some(cx.spawn_in(window, { + let state = state.clone(); + async move |this, cx| { + if let Some(task) = state + .update(cx, |state, cx| state.authenticate(cx)) + .log_err() + { + // We don't log an error, because "not signed in" is also an error. + let _ = task.await; + } + this.update(cx, |this, cx| { + this.load_credentials_task = None; + cx.notify(); + }) + .log_err(); + } + })); + + Self { + api_key_editor, + state, + load_credentials_task, + } + } + + fn save_api_key(&mut self, _: &menu::Confirm, window: &mut Window, cx: &mut Context) { + let api_key = self + .api_key_editor + .read(cx) + .editor() + .read(cx) + .text(cx) + .trim() + .to_string(); + + // Don't proceed if no API key is provided and we're not authenticated + if api_key.is_empty() && !self.state.read(cx).is_authenticated() { + return; + } + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state + .update(cx, |state, cx| state.set_api_key(api_key, cx))? + .await + }) + .detach_and_log_err(cx); + + cx.notify(); + } + + fn reset_api_key(&mut self, window: &mut Window, cx: &mut Context) { + self.api_key_editor.update(cx, |input, cx| { + input.editor.update(cx, |editor, cx| { + editor.set_text("", window, cx); + }); + }); + + let state = self.state.clone(); + cx.spawn_in(window, async move |_, cx| { + state.update(cx, |state, cx| state.reset_api_key(cx))?.await + }) + .detach_and_log_err(cx); + + cx.notify(); + } + + fn should_render_editor(&self, cx: &mut Context) -> bool { + !self.state.read(cx).is_authenticated() + } +} + +impl Render for ConfigurationView { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + let env_var_set = self.state.read(cx).api_key_from_env; + + let api_key_section = if self.should_render_editor(cx) { + v_flex() + .on_action(cx.listener(Self::save_api_key)) + .child(Label::new("To use Zed's agent with xAI, you need to add an API key. Follow these steps:")) + .child( + List::new() + .child(InstructionListItem::new( + "Create one by visiting", + Some("xAI console"), + Some("https://console.x.ai/team/default/api-keys"), + )) + .child(InstructionListItem::text_only( + "Paste your API key below and hit enter to start using the agent", + )), + ) + .child(self.api_key_editor.clone()) + .child( + Label::new(format!( + "You can also assign the {XAI_API_KEY_VAR} environment variable and restart Zed." + )) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new("Note that xAI is a custom OpenAI-compatible provider.") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any() + } else { + h_flex() + .mt_1() + .p_1() + .justify_between() + .rounded_md() + .border_1() + .border_color(cx.theme().colors().border) + .bg(cx.theme().colors().background) + .child( + h_flex() + .gap_1() + .child(Icon::new(IconName::Check).color(Color::Success)) + .child(Label::new(if env_var_set { + format!("API key set in {XAI_API_KEY_VAR} environment variable.") + } else { + "API key configured.".to_string() + })), + ) + .child( + Button::new("reset-api-key", "Reset API Key") + .label_size(LabelSize::Small) + .icon(IconName::Undo) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .layer(ElevationIndex::ModalSurface) + .when(env_var_set, |this| { + this.tooltip(Tooltip::text(format!("To reset your API key, unset the {XAI_API_KEY_VAR} environment variable."))) + }) + .on_click(cx.listener(|this, _, window, cx| this.reset_api_key(window, cx))), + ) + .into_any() + }; + + if self.load_credentials_task.is_some() { + div().child(Label::new("Loading credentials…")).into_any() + } else { + v_flex().size_full().child(api_key_section).into_any() + } + } +} diff --git a/crates/language_models/src/settings.rs b/crates/language_models/src/settings.rs index f96a2c0a66cfe698738deec177b5f82cde274df7..dafbb629100469e6a8dd77850eece139a3bed267 100644 --- a/crates/language_models/src/settings.rs +++ b/crates/language_models/src/settings.rs @@ -17,6 +17,7 @@ use crate::provider::{ open_ai::OpenAiSettings, open_router::OpenRouterSettings, vercel::VercelSettings, + x_ai::XAiSettings, }; /// Initializes the language model settings. @@ -28,33 +29,33 @@ pub fn init(cx: &mut App) { pub struct AllLanguageModelSettings { pub anthropic: AnthropicSettings, pub bedrock: AmazonBedrockSettings, - pub ollama: OllamaSettings, - pub openai: OpenAiSettings, - pub open_router: OpenRouterSettings, - pub zed_dot_dev: ZedDotDevSettings, + pub deepseek: DeepSeekSettings, pub google: GoogleSettings, - pub vercel: VercelSettings, - pub lmstudio: LmStudioSettings, - pub deepseek: DeepSeekSettings, pub mistral: MistralSettings, + pub ollama: OllamaSettings, + pub open_router: OpenRouterSettings, + pub openai: OpenAiSettings, + pub vercel: VercelSettings, + pub x_ai: XAiSettings, + pub zed_dot_dev: ZedDotDevSettings, } #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct AllLanguageModelSettingsContent { pub anthropic: Option, pub bedrock: Option, - pub ollama: Option, + pub deepseek: Option, + pub google: Option, pub lmstudio: Option, - pub openai: Option, + pub mistral: Option, + pub ollama: Option, pub open_router: Option, + pub openai: Option, + pub vercel: Option, + pub x_ai: Option, #[serde(rename = "zed.dev")] pub zed_dot_dev: Option, - pub google: Option, - pub deepseek: Option, - pub vercel: Option, - - pub mistral: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] @@ -114,6 +115,12 @@ pub struct GoogleSettingsContent { pub available_models: Option>, } +#[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] +pub struct XAiSettingsContent { + pub api_url: Option, + pub available_models: Option>, +} + #[derive(Default, Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] pub struct ZedDotDevSettingsContent { available_models: Option>, @@ -230,6 +237,18 @@ impl settings::Settings for AllLanguageModelSettings { vercel.as_ref().and_then(|s| s.available_models.clone()), ); + // XAI + let x_ai = value.x_ai.clone(); + merge( + &mut settings.x_ai.api_url, + x_ai.as_ref().and_then(|s| s.api_url.clone()), + ); + merge( + &mut settings.x_ai.available_models, + x_ai.as_ref().and_then(|s| s.available_models.clone()), + ); + + // ZedDotDev merge( &mut settings.zed_dot_dev.available_models, value diff --git a/crates/x_ai/Cargo.toml b/crates/x_ai/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..7ca0ca09397111404a59dff85d1ccf0659c0ea45 --- /dev/null +++ b/crates/x_ai/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "x_ai" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +path = "src/x_ai.rs" + +[features] +default = [] +schemars = ["dep:schemars"] + +[dependencies] +anyhow.workspace = true +schemars = { workspace = true, optional = true } +serde.workspace = true +strum.workspace = true +workspace-hack.workspace = true diff --git a/crates/x_ai/LICENSE-GPL b/crates/x_ai/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/x_ai/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/x_ai/src/x_ai.rs b/crates/x_ai/src/x_ai.rs new file mode 100644 index 0000000000000000000000000000000000000000..ac116b2f8f610614b4d1efd380169739bbdbc9f2 --- /dev/null +++ b/crates/x_ai/src/x_ai.rs @@ -0,0 +1,126 @@ +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use strum::EnumIter; + +pub const XAI_API_URL: &str = "https://api.x.ai/v1"; + +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, EnumIter)] +pub enum Model { + #[serde(rename = "grok-2-vision-latest")] + Grok2Vision, + #[default] + #[serde(rename = "grok-3-latest")] + Grok3, + #[serde(rename = "grok-3-mini-latest")] + Grok3Mini, + #[serde(rename = "grok-3-fast-latest")] + Grok3Fast, + #[serde(rename = "grok-3-mini-fast-latest")] + Grok3MiniFast, + #[serde(rename = "grok-4-latest")] + Grok4, + #[serde(rename = "custom")] + Custom { + name: String, + /// The name displayed in the UI, such as in the assistant panel model dropdown menu. + display_name: Option, + max_tokens: u64, + max_output_tokens: Option, + max_completion_tokens: Option, + }, +} + +impl Model { + pub fn default_fast() -> Self { + Self::Grok3Fast + } + + pub fn from_id(id: &str) -> Result { + match id { + "grok-2-vision" => Ok(Self::Grok2Vision), + "grok-3" => Ok(Self::Grok3), + "grok-3-mini" => Ok(Self::Grok3Mini), + "grok-3-fast" => Ok(Self::Grok3Fast), + "grok-3-mini-fast" => Ok(Self::Grok3MiniFast), + _ => anyhow::bail!("invalid model id '{id}'"), + } + } + + pub fn id(&self) -> &str { + match self { + Self::Grok2Vision => "grok-2-vision", + Self::Grok3 => "grok-3", + Self::Grok3Mini => "grok-3-mini", + Self::Grok3Fast => "grok-3-fast", + Self::Grok3MiniFast => "grok-3-mini-fast", + Self::Grok4 => "grok-4", + Self::Custom { name, .. } => name, + } + } + + pub fn display_name(&self) -> &str { + match self { + Self::Grok2Vision => "Grok 2 Vision", + Self::Grok3 => "Grok 3", + Self::Grok3Mini => "Grok 3 Mini", + Self::Grok3Fast => "Grok 3 Fast", + Self::Grok3MiniFast => "Grok 3 Mini Fast", + Self::Grok4 => "Grok 4", + Self::Custom { + name, display_name, .. + } => display_name.as_ref().unwrap_or(name), + } + } + + pub fn max_token_count(&self) -> u64 { + match self { + Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => 131_072, + Self::Grok4 => 256_000, + Self::Grok2Vision => 8_192, + Self::Custom { max_tokens, .. } => *max_tokens, + } + } + + pub fn max_output_tokens(&self) -> Option { + match self { + Self::Grok3 | Self::Grok3Mini | Self::Grok3Fast | Self::Grok3MiniFast => Some(8_192), + Self::Grok4 => Some(64_000), + Self::Grok2Vision => Some(4_096), + Self::Custom { + max_output_tokens, .. + } => *max_output_tokens, + } + } + + pub fn supports_parallel_tool_calls(&self) -> bool { + match self { + Self::Grok2Vision + | Self::Grok3 + | Self::Grok3Mini + | Self::Grok3Fast + | Self::Grok3MiniFast + | Self::Grok4 => true, + Model::Custom { .. } => false, + } + } + + pub fn supports_tool(&self) -> bool { + match self { + Self::Grok2Vision + | Self::Grok3 + | Self::Grok3Mini + | Self::Grok3Fast + | Self::Grok3MiniFast + | Self::Grok4 => true, + Model::Custom { .. } => false, + } + } + + pub fn supports_images(&self) -> bool { + match self { + Self::Grok2Vision => true, + _ => false, + } + } +} diff --git a/docs/src/ai/configuration.md b/docs/src/ai/configuration.md index ade1ae672f51944949c47e9f098c60a9a8198423..56eb4ab76cd990871d62ced2cccb019f2d607cd6 100644 --- a/docs/src/ai/configuration.md +++ b/docs/src/ai/configuration.md @@ -23,6 +23,8 @@ Here's an overview of the supported providers and tool call support: | [OpenAI](#openai) | ✅ | | [OpenAI API Compatible](#openai-api-compatible) | 🚫 | | [OpenRouter](#openrouter) | ✅ | +| [Vercel](#vercel-v0) | ✅ | +| [xAI](#xai) | ✅ | ## Use Your Own Keys {#use-your-own-keys} @@ -444,27 +446,30 @@ Custom models will be listed in the model dropdown in the Agent Panel. Zed supports using OpenAI compatible APIs by specifying a custom `endpoint` and `available_models` for the OpenAI provider. -You can add a custom API URL for OpenAI either via the UI or by editing your `settings.json`. -Here are a few model examples you can plug in by using this feature: +Zed supports using OpenAI compatible APIs by specifying a custom `api_url` and `available_models` for the OpenAI provider. This is useful for connecting to other hosted services (like Together AI, Anyscale, etc.) or local models. -#### X.ai Grok +To configure a compatible API, you can add a custom API URL for OpenAI either via the UI or by editing your `settings.json`. For example, to connect to [Together AI](https://www.together.ai/): -Example configuration for using X.ai Grok with Zed: +1. Get an API key from your [Together AI account](https://api.together.ai/settings/api-keys). +2. Add the following to your `settings.json`: ```json +{ "language_models": { "openai": { - "api_url": "https://api.x.ai/v1", + "api_url": "https://api.together.xyz/v1", + "api_key": "YOUR_TOGETHER_AI_API_KEY", "available_models": [ { - "name": "grok-beta", - "display_name": "X.ai Grok (Beta)", - "max_tokens": 131072 + "name": "mistralai/Mixtral-8x7B-Instruct-v0.1", + "display_name": "Together Mixtral 8x7B", + "max_tokens": 32768, + "supports_tools": true } - ], - "version": "1" - }, + ] + } } +} ``` ### OpenRouter {#openrouter} @@ -525,7 +530,9 @@ You can find available models and their specifications on the [OpenRouter models Custom models will be listed in the model dropdown in the Agent Panel. -### Vercel v0 +### Vercel v0 {#vercel-v0} + +> ✅ Supports tool use [Vercel v0](https://vercel.com/docs/v0/api) is an expert model for generating full-stack apps, with framework-aware completions optimized for modern stacks like Next.js and Vercel. It supports text and image inputs and provides fast streaming responses. @@ -537,6 +544,49 @@ Once you have it, paste it directly into the Vercel provider section in the pane You should then find it as `v0-1.5-md` in the model dropdown in the Agent Panel. +### xAI {#xai} + +> ✅ Supports tool use + +Zed has first-class support for [xAI](https://x.ai/) models. You can use your own API key to access Grok models. + +1. [Create an API key in the xAI Console](https://console.x.ai/team/default/api-keys) +2. Open the settings view (`agent: open configuration`) and go to the **xAI** section +3. Enter your xAI API key + +The xAI API key will be saved in your keychain. Zed will also use the `XAI_API_KEY` environment variable if it's defined. + +> **Note:** While the xAI API is OpenAI-compatible, Zed has first-class support for it as a dedicated provider. For the best experience, we recommend using the dedicated `x_ai` provider configuration instead of the [OpenAI API Compatible](#openai-api-compatible) method. + +#### Custom Models {#xai-custom-models} + +The Zed agent comes pre-configured with common Grok models. If you wish to use alternate models or customize their parameters, you can do so by adding the following to your Zed `settings.json`: + +```json +{ + "language_models": { + "x_ai": { + "api_url": "https://api.x.ai/v1", + "available_models": [ + { + "name": "grok-1.5", + "display_name": "Grok 1.5", + "max_tokens": 131072, + "max_output_tokens": 8192 + }, + { + "name": "grok-1.5v", + "display_name": "Grok 1.5V (Vision)", + "max_tokens": 131072, + "max_output_tokens": 8192, + "supports_images": true + } + ] + } + } +} +``` + ## Advanced Configuration {#advanced-configuration} ### Custom Provider Endpoints {#custom-provider-endpoint} From 3751737621c7e0603344598fc3974637b0e30fbb Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 15 Jul 2025 13:42:25 -0600 Subject: [PATCH 0014/1056] Add zed://extension/{id} links (#34492) Release Notes: - Add zed://extension/{id} links to open the extensions UI with a specific extension --- crates/agent_ui/src/agent_configuration.rs | 1 + crates/agent_ui/src/agent_panel.rs | 1 + crates/debugger_ui/src/debugger_panel.rs | 1 + crates/extensions_ui/src/extensions_ui.rs | 54 +++++++++++++++---- .../theme_selector/src/icon_theme_selector.rs | 1 + crates/theme_selector/src/theme_selector.rs | 1 + crates/zed/src/main.rs | 17 ++++++ crates/zed/src/zed/open_listener.rs | 3 ++ crates/zed_actions/src/lib.rs | 3 ++ 9 files changed, 72 insertions(+), 10 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 8bfdd507611112b2930fd07270667050796533e3..579331c9acd07d1e41a1ceaee7fe2452bb0c1591 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -491,6 +491,7 @@ impl AgentConfiguration { category_filter: Some( ExtensionCategoryFilter::ContextServers, ), + id: None, } .boxed_clone(), cx, diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 18e43dd51eaca2699bf6feeddd20386b145da628..ded26b189642c1eb9e6c79ec958a18ebb99ded68 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1921,6 +1921,7 @@ impl AgentPanel { category_filter: Some( zed_actions::ExtensionCategoryFilter::ContextServers, ), + id: None, }), ) .action("Add Custom Server…", Box::new(AddContextServer)) diff --git a/crates/debugger_ui/src/debugger_panel.rs b/crates/debugger_ui/src/debugger_panel.rs index bf5f31391885edf89beea3e8648df13f68258a77..d81c593484d7fbb46ca31fd1772117c1232c6752 100644 --- a/crates/debugger_ui/src/debugger_panel.rs +++ b/crates/debugger_ui/src/debugger_panel.rs @@ -1760,6 +1760,7 @@ impl Render for DebugPanel { category_filter: Some( zed_actions::ExtensionCategoryFilter::DebugAdapters, ), + id: None, } .boxed_clone(), cx, diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index 0d00deb10e64ec72e3bf64b1c8ce0929d944104a..b944b1ec505178b56d0894b9790040ac73ede639 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -6,6 +6,7 @@ use std::sync::OnceLock; use std::time::Duration; use std::{ops::Range, sync::Arc}; +use anyhow::Context as _; use client::{ExtensionMetadata, ExtensionProvides}; use collections::{BTreeMap, BTreeSet}; use editor::{Editor, EditorElement, EditorStyle}; @@ -80,16 +81,24 @@ pub fn init(cx: &mut App) { .find_map(|item| item.downcast::()); if let Some(existing) = existing { - if provides_filter.is_some() { - existing.update(cx, |extensions_page, cx| { + existing.update(cx, |extensions_page, cx| { + if provides_filter.is_some() { extensions_page.change_provides_filter(provides_filter, cx); - }); - } + } + if let Some(id) = action.id.as_ref() { + extensions_page.focus_extension(id, window, cx); + } + }); workspace.activate_item(&existing, true, true, window, cx); } else { - let extensions_page = - ExtensionsPage::new(workspace, provides_filter, window, cx); + let extensions_page = ExtensionsPage::new( + workspace, + provides_filter, + action.id.as_deref(), + window, + cx, + ); workspace.add_item_to_active_pane( Box::new(extensions_page), None, @@ -287,6 +296,7 @@ impl ExtensionsPage { pub fn new( workspace: &Workspace, provides_filter: Option, + focus_extension_id: Option<&str>, window: &mut Window, cx: &mut Context, ) -> Entity { @@ -317,6 +327,9 @@ impl ExtensionsPage { let query_editor = cx.new(|cx| { let mut input = Editor::single_line(window, cx); input.set_placeholder_text("Search extensions...", cx); + if let Some(id) = focus_extension_id { + input.set_text(format!("id:{id}"), window, cx); + } input }); cx.subscribe(&query_editor, Self::on_query_change).detach(); @@ -340,7 +353,7 @@ impl ExtensionsPage { scrollbar_state: ScrollbarState::new(scroll_handle), }; this.fetch_extensions( - None, + this.search_query(cx), Some(BTreeSet::from_iter(this.provides_filter)), None, cx, @@ -464,9 +477,23 @@ impl ExtensionsPage { .cloned() .collect::>(); - let remote_extensions = extension_store.update(cx, |store, cx| { - store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx) - }); + let remote_extensions = + if let Some(id) = search.as_ref().and_then(|s| s.strip_prefix("id:")) { + let versions = + extension_store.update(cx, |store, cx| store.fetch_extension_versions(id, cx)); + cx.foreground_executor().spawn(async move { + let versions = versions.await?; + let latest = versions + .into_iter() + .max_by_key(|v| v.published_at) + .context("no extension found")?; + Ok(vec![latest]) + }) + } else { + extension_store.update(cx, |store, cx| { + store.fetch_extensions(search.as_deref(), provides_filter.as_ref(), cx) + }) + }; cx.spawn(async move |this, cx| { let dev_extensions = if let Some(search) = search { @@ -1165,6 +1192,13 @@ impl ExtensionsPage { self.refresh_feature_upsells(cx); } + pub fn focus_extension(&mut self, id: &str, window: &mut Window, cx: &mut Context) { + self.query_editor.update(cx, |editor, cx| { + editor.set_text(format!("id:{id}"), window, cx) + }); + self.refresh_search(cx); + } + pub fn change_provides_filter( &mut self, provides_filter: Option, diff --git a/crates/theme_selector/src/icon_theme_selector.rs b/crates/theme_selector/src/icon_theme_selector.rs index 40ba7bd5a6e8381f3c11331d73aa9215f555ec8f..1adfc4b5d8183479c5c449d49596c397a6f02dfd 100644 --- a/crates/theme_selector/src/icon_theme_selector.rs +++ b/crates/theme_selector/src/icon_theme_selector.rs @@ -327,6 +327,7 @@ impl PickerDelegate for IconThemeSelectorDelegate { window.dispatch_action( Box::new(Extensions { category_filter: Some(ExtensionCategoryFilter::IconThemes), + id: None, }), cx, ); diff --git a/crates/theme_selector/src/theme_selector.rs b/crates/theme_selector/src/theme_selector.rs index 09d9877df874f192365a7bd595a62ee3cb108846..022daced7aaf02d095e4789e9caf690382e58753 100644 --- a/crates/theme_selector/src/theme_selector.rs +++ b/crates/theme_selector/src/theme_selector.rs @@ -385,6 +385,7 @@ impl PickerDelegate for ThemeSelectorDelegate { window.dispatch_action( Box::new(Extensions { category_filter: Some(ExtensionCategoryFilter::Themes), + id: None, }), cx, ); diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6309c3a1373b2ce30db1f7eac0d1449f52ed4f7d..5eb96f21a4b13e5316863af0f7a20703f5506ee2 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -746,6 +746,23 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut return; } + if let Some(extension) = request.extension_id { + cx.spawn(async move |cx| { + let workspace = workspace::get_any_active_workspace(app_state, cx.clone()).await?; + workspace.update(cx, |_, window, cx| { + window.dispatch_action( + Box::new(zed_actions::Extensions { + category_filter: None, + id: Some(extension), + }), + cx, + ); + }) + }) + .detach_and_log_err(cx); + return; + } + if let Some(connection_options) = request.ssh_connection { cx.spawn(async move |mut cx| { let paths: Vec = request.open_paths.into_iter().map(PathBuf::from).collect(); diff --git a/crates/zed/src/zed/open_listener.rs b/crates/zed/src/zed/open_listener.rs index 0fb08d1be5790557674ee91a08cd35d28ea0b062..42eb8198a4c091d0ce6dd4ecbae3f0ced7bdf7d3 100644 --- a/crates/zed/src/zed/open_listener.rs +++ b/crates/zed/src/zed/open_listener.rs @@ -37,6 +37,7 @@ pub struct OpenRequest { pub join_channel: Option, pub ssh_connection: Option, pub dock_menu_action: Option, + pub extension_id: Option, } impl OpenRequest { @@ -54,6 +55,8 @@ impl OpenRequest { } else if let Some(file) = url.strip_prefix("zed://ssh") { let ssh_url = "ssh:/".to_string() + file; this.parse_ssh_file_path(&ssh_url, cx)? + } else if let Some(file) = url.strip_prefix("zed://extension/") { + this.extension_id = Some(file.to_string()) } else if url.starts_with("ssh://") { this.parse_ssh_file_path(&url, cx)? } else if let Some(request_path) = parse_zed_link(&url, cx) { diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 06121a9de8e0b68316c8ffda1d4a393beedb217f..fc7d98178edfce397ae4600b17b7bbac4a1cb9c6 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -76,6 +76,9 @@ pub struct Extensions { /// Filters the extensions page down to extensions that are in the specified category. #[serde(default)] pub category_filter: Option, + /// Focuses just the extension with the specified ID. + #[serde(default)] + pub id: Option, } /// Decreases the font size in the editor buffer. From 572d3d637a68281acf8e848ec86e008da3efb194 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Tue, 15 Jul 2025 16:06:50 -0400 Subject: [PATCH 0015/1056] Rename `action_input` to `action_arguments` in keybinding contexts (#34480) Release Notes: - N/A --- crates/settings/src/keymap_file.rs | 52 ++++++++--------- crates/settings_ui/src/keybindings.rs | 83 +++++++++++++++------------ 2 files changed, 71 insertions(+), 64 deletions(-) diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 78e306ed632dde43c3671ce3f746a9d198893f3c..470c5faf78d7e7b41d8c4895e471b82b557a5c3a 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -623,7 +623,7 @@ impl KeymapFile { target_keybind_source, } if target_keybind_source != KeybindSource::User => { target.action_name = gpui::NoAction.name(); - target.input.take(); + target.action_arguments.take(); operation = KeybindUpdateOperation::Add(target); } _ => {} @@ -848,17 +848,17 @@ pub struct KeybindUpdateTarget<'a> { pub keystrokes: &'a [Keystroke], pub action_name: &'a str, pub use_key_equivalents: bool, - pub input: Option<&'a str>, + pub action_arguments: Option<&'a str>, } impl<'a> KeybindUpdateTarget<'a> { fn action_value(&self) -> Result { let action_name: Value = self.action_name.into(); - let value = match self.input { - Some(input) => { - let input = serde_json::from_str::(input) - .context("Failed to parse action input as JSON")?; - serde_json::json!([action_name, input]) + let value = match self.action_arguments { + Some(args) => { + let args = serde_json::from_str::(args) + .context("Failed to parse action arguments as JSON")?; + serde_json::json!([action_name, args]) } None => action_name, }; @@ -986,7 +986,7 @@ mod tests { action_name: "zed::SomeAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }), r#"[ { @@ -1012,7 +1012,7 @@ mod tests { action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }), r#"[ { @@ -1043,7 +1043,7 @@ mod tests { action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, - input: Some(r#"{"foo": "bar"}"#), + action_arguments: Some(r#"{"foo": "bar"}"#), }), r#"[ { @@ -1079,7 +1079,7 @@ mod tests { action_name: "zed::SomeOtherAction", context: Some("Zed > Editor && some_condition = true"), use_key_equivalents: true, - input: Some(r#"{"foo": "bar"}"#), + action_arguments: Some(r#"{"foo": "bar"}"#), }), r#"[ { @@ -1118,14 +1118,14 @@ mod tests { action_name: "zed::SomeAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, - input: Some(r#"{"foo": "bar"}"#), + action_arguments: Some(r#"{"foo": "bar"}"#), }, target_keybind_source: KeybindSource::Base, }, @@ -1164,14 +1164,14 @@ mod tests { action_name: "zed::SomeAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, - input: Some(r#"{"foo": "bar"}"#), + action_arguments: Some(r#"{"foo": "bar"}"#), }, target_keybind_source: KeybindSource::User, }, @@ -1205,14 +1205,14 @@ mod tests { action_name: "zed::SomeNonexistentAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }, target_keybind_source: KeybindSource::User, }, @@ -1248,14 +1248,14 @@ mod tests { action_name: "zed::SomeAction", context: None, use_key_equivalents: false, - input: None, + action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, use_key_equivalents: false, - input: Some(r#"{"foo": "bar"}"#), + action_arguments: Some(r#"{"foo": "bar"}"#), }, target_keybind_source: KeybindSource::User, }, @@ -1293,14 +1293,14 @@ mod tests { action_name: "foo::bar", context: Some("SomeContext"), use_key_equivalents: false, - input: None, + action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("c"), action_name: "foo::baz", context: Some("SomeOtherContext"), use_key_equivalents: false, - input: None, + action_arguments: None, }, target_keybind_source: KeybindSource::User, }, @@ -1337,14 +1337,14 @@ mod tests { action_name: "foo::bar", context: Some("SomeContext"), use_key_equivalents: false, - input: None, + action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("c"), action_name: "foo::baz", context: Some("SomeOtherContext"), use_key_equivalents: false, - input: None, + action_arguments: None, }, target_keybind_source: KeybindSource::User, }, @@ -1376,7 +1376,7 @@ mod tests { keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", use_key_equivalents: false, - input: None, + action_arguments: None, }, target_keybind_source: KeybindSource::User, }, @@ -1408,7 +1408,7 @@ mod tests { keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", use_key_equivalents: false, - input: Some("true"), + action_arguments: Some("true"), }, target_keybind_source: KeybindSource::User, }, @@ -1451,7 +1451,7 @@ mod tests { keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", use_key_equivalents: false, - input: Some("true"), + action_arguments: Some("true"), }, target_keybind_source: KeybindSource::User, }, diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 3567439d2b3c85202d5fe0ceea5a481bab6be482..5b2cca92bb3f5c60d2e8386aabd3f41c44c85e32 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -520,9 +520,9 @@ impl KeymapEditor { let action_name = key_binding.action().name(); unmapped_action_names.remove(&action_name); - let action_input = key_binding + let action_arguments = key_binding .action_input() - .map(|input| SyntaxHighlightedText::new(input, json_language.clone())); + .map(|arguments| SyntaxHighlightedText::new(arguments, json_language.clone())); let action_docs = action_documentation.get(action_name).copied(); let index = processed_bindings.len(); @@ -531,7 +531,7 @@ impl KeymapEditor { keystroke_text: keystroke_text.into(), ui_key_binding, action_name: action_name.into(), - action_input, + action_arguments, action_docs, action_schema: action_schema.get(action_name).cloned(), context: Some(context), @@ -548,7 +548,7 @@ impl KeymapEditor { keystroke_text: empty.clone(), ui_key_binding: None, action_name: action_name.into(), - action_input: None, + action_arguments: None, action_docs: action_documentation.get(action_name).copied(), action_schema: action_schema.get(action_name).cloned(), context: None, @@ -961,7 +961,7 @@ struct ProcessedKeybinding { keystroke_text: SharedString, ui_key_binding: Option, action_name: SharedString, - action_input: Option, + action_arguments: Option, action_docs: Option<&'static str>, action_schema: Option, context: Option, @@ -1244,8 +1244,8 @@ impl Render for KeymapEditor { binding.keystroke_text.clone().into_any_element(), IntoElement::into_any_element, ); - let action_input = match binding.action_input.clone() { - Some(input) => input.into_any_element(), + let action_arguments = match binding.action_arguments.clone() { + Some(arguments) => arguments.into_any_element(), None => { if binding.action_schema.is_some() { muted_styled_text(NO_ACTION_ARGUMENTS_TEXT, cx) @@ -1279,7 +1279,14 @@ impl Render for KeymapEditor { .map(|(_source, name)| name) .unwrap_or_default() .into_any_element(); - Some([icon, action, action_input, keystrokes, context, source]) + Some([ + icon, + action, + action_arguments, + keystrokes, + context, + source, + ]) }) .collect() }), @@ -1446,7 +1453,7 @@ struct KeybindingEditorModal { editing_keybind_idx: usize, keybind_editor: Entity, context_editor: Entity, - input_editor: Option>, + action_arguments_editor: Option>, fs: Arc, error: Option, keymap_editor: Entity, @@ -1512,16 +1519,16 @@ impl KeybindingEditorModal { input }); - let input_editor = editing_keybind.action_schema.clone().map(|_schema| { + let action_arguments_editor = editing_keybind.action_schema.clone().map(|_schema| { cx.new(|cx| { let mut editor = Editor::auto_height_unbounded(1, window, cx); let workspace = workspace.clone(); - if let Some(input) = editing_keybind.action_input.clone() { - editor.set_text(input.text, window, cx); + if let Some(arguments) = editing_keybind.action_arguments.clone() { + editor.set_text(arguments.text, window, cx); } else { // TODO: default value from schema? - editor.set_placeholder_text("Action Input", cx); + editor.set_placeholder_text("Action Arguments", cx); } cx.spawn(async |editor, cx| { let json_language = load_json_language(workspace, cx).await; @@ -1533,7 +1540,7 @@ impl KeybindingEditorModal { }); } }) - .context("Failed to load JSON language for editing keybinding action input") + .context("Failed to load JSON language for editing keybinding action arguments input") }) .detach_and_log_err(cx); editor @@ -1542,8 +1549,8 @@ impl KeybindingEditorModal { let focus_state = KeybindingEditorModalFocusState::new( keybind_editor.read_with(cx, |keybind_editor, cx| keybind_editor.focus_handle(cx)), - input_editor.as_ref().map(|input_editor| { - input_editor.read_with(cx, |input_editor, cx| input_editor.focus_handle(cx)) + action_arguments_editor.as_ref().map(|args_editor| { + args_editor.read_with(cx, |args_editor, cx| args_editor.focus_handle(cx)) }), context_editor.read_with(cx, |context_editor, cx| context_editor.focus_handle(cx)), ); @@ -1555,7 +1562,7 @@ impl KeybindingEditorModal { fs, keybind_editor, context_editor, - input_editor, + action_arguments_editor, error: None, keymap_editor, workspace, @@ -1577,22 +1584,22 @@ impl KeybindingEditorModal { } } - fn validate_action_input(&self, cx: &App) -> anyhow::Result> { - let input = self - .input_editor + fn validate_action_arguments(&self, cx: &App) -> anyhow::Result> { + let action_arguments = self + .action_arguments_editor .as_ref() .map(|editor| editor.read(cx).text(cx)); - let value = input + let value = action_arguments .as_ref() - .map(|input| { - serde_json::from_str(input).context("Failed to parse action input as JSON") + .map(|args| { + serde_json::from_str(args).context("Failed to parse action arguments as JSON") }) .transpose()?; cx.build_action(&self.editing_keybind.action_name, value) - .context("Failed to validate action input")?; - Ok(input) + .context("Failed to validate action arguments")?; + Ok(action_arguments) } fn save(&mut self, cx: &mut Context) { @@ -1622,7 +1629,7 @@ impl KeybindingEditorModal { return; } - let new_input = match self.validate_action_input(cx) { + let new_action_args = match self.validate_action_arguments(cx) { Err(input_err) => { self.set_error(InputError::error(input_err.to_string()), cx); return; @@ -1707,7 +1714,7 @@ impl KeybindingEditorModal { existing_keybind, &new_keystrokes, new_context.as_deref(), - new_input.as_deref(), + new_action_args.as_deref(), &fs, tab_size, ) @@ -1812,7 +1819,7 @@ impl Render for KeybindingEditorModal { .gap_1() .child(self.keybind_editor.clone()), ) - .when_some(self.input_editor.clone(), |this, editor| { + .when_some(self.action_arguments_editor.clone(), |this, editor| { this.child( v_flex() .mt_1p5() @@ -2049,7 +2056,7 @@ async fn save_keybinding_update( existing: ProcessedKeybinding, new_keystrokes: &[Keystroke], new_context: Option<&str>, - new_input: Option<&str>, + new_args: Option<&str>, fs: &Arc, tab_size: usize, ) -> anyhow::Result<()> { @@ -2063,10 +2070,10 @@ async fn save_keybinding_update( .context .as_ref() .and_then(KeybindContextString::local_str); - let existing_input = existing - .action_input + let existing_args = existing + .action_arguments .as_ref() - .map(|input| input.text.as_ref()); + .map(|args| args.text.as_ref()); settings::KeybindUpdateOperation::Replace { target: settings::KeybindUpdateTarget { @@ -2074,7 +2081,7 @@ async fn save_keybinding_update( keystrokes: existing_keystrokes, action_name: &existing.action_name, use_key_equivalents: false, - input: existing_input, + action_arguments: existing_args, }, target_keybind_source: existing .source @@ -2086,7 +2093,7 @@ async fn save_keybinding_update( keystrokes: new_keystrokes, action_name: &existing.action_name, use_key_equivalents: false, - input: new_input, + action_arguments: new_args, }, } } else { @@ -2095,7 +2102,7 @@ async fn save_keybinding_update( keystrokes: new_keystrokes, action_name: &existing.action_name, use_key_equivalents: false, - input: new_input, + action_arguments: new_args, }) }; let updated_keymap_contents = @@ -2131,10 +2138,10 @@ async fn remove_keybinding( keystrokes, action_name: &existing.action_name, use_key_equivalents: false, - input: existing - .action_input + action_arguments: existing + .action_arguments .as_ref() - .map(|input| input.text.as_ref()), + .map(|arguments| arguments.text.as_ref()), }, target_keybind_source: existing .source From 0ada4ce900a13af97c732cba7d3eae240749e4b8 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 16 Jul 2025 01:47:40 +0530 Subject: [PATCH 0016/1056] editor: Add ToggleFocus action (#34495) This PR adds action `editor: toggle focus` which focuses to last active editor pane item in workspace. Release Notes: - Added `editor: toggle focus` action, which focuses to last active editor pane item. --------- Co-authored-by: Danilo Leal --- crates/editor/src/actions.rs | 2 ++ crates/editor/src/editor.rs | 13 +++++++++++++ crates/workspace/src/workspace.rs | 21 +++++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index c4866179c1d98bc1a36001b469cabe875ea42806..87463d246d2aba96485247467b15025c58f9d5d5 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -425,6 +425,8 @@ actions!( FoldRecursive, /// Folds the selected ranges. FoldSelectedRanges, + /// Toggles focus back to the last active buffer. + ToggleFocus, /// Toggles folding at the current position. ToggleFold, /// Toggles recursive folding at the current position. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index acd9c23c97682f648fe3389e8c0466be4d7b9667..e5ff75561594746a17a228ee1c466f003097e37a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -356,6 +356,7 @@ pub fn init(cx: &mut App) { workspace.register_action(Editor::new_file_vertical); workspace.register_action(Editor::new_file_horizontal); workspace.register_action(Editor::cancel_language_server_work); + workspace.register_action(Editor::toggle_focus); }, ) .detach(); @@ -16954,6 +16955,18 @@ impl Editor { cx.notify(); } + pub fn toggle_focus( + workspace: &mut Workspace, + _: &actions::ToggleFocus, + window: &mut Window, + cx: &mut Context, + ) { + let Some(item) = workspace.recent_active_item_by_type::(cx) else { + return; + }; + workspace.activate_item(&item, true, true, window, cx); + } + pub fn toggle_fold( &mut self, _: &actions::ToggleFold, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index dc2c6516dd6892feb7749daf5bf654db94bfb11e..be5d693d356e3b328d1f3d0575a76df5c429f7dc 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -1711,6 +1711,27 @@ impl Workspace { history } + pub fn recent_active_item_by_type(&self, cx: &App) -> Option> { + let mut recent_item: Option> = None; + let mut recent_timestamp = 0; + for pane_handle in &self.panes { + let pane = pane_handle.read(cx); + let item_map: HashMap> = + pane.items().map(|item| (item.item_id(), item)).collect(); + for entry in pane.activation_history() { + if entry.timestamp > recent_timestamp { + if let Some(&item) = item_map.get(&entry.entity_id) { + if let Some(typed_item) = item.act_as::(cx) { + recent_timestamp = entry.timestamp; + recent_item = Some(typed_item); + } + } + } + } + } + recent_item + } + pub fn recent_navigation_history_iter( &self, cx: &App, From 0ebbeec11cadd93e0a2086fd3bfd4dfbfaaa20da Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 15 Jul 2025 17:06:46 -0400 Subject: [PATCH 0017/1056] debugger: Remove `Start` button from the attach modal (#34496) Right now it doesn't work at all (the PID doesn't get set in the generated scenario), and it's sort of redundant with the picker functionality. Release Notes: - N/A --- crates/debugger_ui/src/new_process_modal.rs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/crates/debugger_ui/src/new_process_modal.rs b/crates/debugger_ui/src/new_process_modal.rs index 6d7fa244a2e2bfaaaa82f1321d446627e2b0c343..42f77ab056889653e108bfbda05f9fe7a0b270ad 100644 --- a/crates/debugger_ui/src/new_process_modal.rs +++ b/crates/debugger_ui/src/new_process_modal.rs @@ -766,14 +766,7 @@ impl Render for NewProcessModal { )) .child( h_flex() - .child(div().child(self.adapter_drop_down_menu(window, cx))) - .child( - Button::new("debugger-spawn", "Start") - .on_click(cx.listener(|this, _, window, cx| { - this.start_new_session(window, cx) - })) - .disabled(disabled), - ), + .child(div().child(self.adapter_drop_down_menu(window, cx))), ) }), NewProcessMode::Debug => el, From 0a3ef40c2fb7e4162c78f4014e953325dd61f29e Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Tue, 15 Jul 2025 18:31:28 -0400 Subject: [PATCH 0018/1056] debugger: Interpret user-specified debug adapter binary paths in a more intuitive way for JS and Python (#33926) Previously we would append `js-debug/src/dapDebugServer.js` to the value of the `dap.JavaScript.binary` setting and `src/debugpy/adapter` to the value of the `dap.Debugpy.binary` setting, which isn't particularly intuitive. This PR fixes that. Release Notes: - debugger: Made the semantics of the `dap.$ADAPTER.binary` setting more intuitive for the `JavaScript` and `Debugpy` adapters. In the new semantics, this should be the path to `dapDebugServer.js` for `JavaScript` and the path to the `src/debugpy/adapter` directory for `Debugpy`. --------- Co-authored-by: Remco Smits --- crates/dap_adapters/src/javascript.rs | 42 +++++++++++---------------- crates/dap_adapters/src/python.rs | 11 ++----- 2 files changed, 20 insertions(+), 33 deletions(-) diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index a51377cd76dd7ab1702c263378d0bf2904f27a6f..2d19921a0f0c979fe53ede5860ac0c4d26b510c3 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -54,20 +54,6 @@ impl JsDebugAdapter { user_args: Option>, _: &mut AsyncApp, ) -> Result { - let adapter_path = if let Some(user_installed_path) = user_installed_path { - user_installed_path - } else { - let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref()); - - let file_name_prefix = format!("{}_", self.name()); - - util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { - file_name.starts_with(&file_name_prefix) - }) - .await - .context("Couldn't find JavaScript dap directory")? - }; - let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default(); let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; @@ -136,21 +122,27 @@ impl JsDebugAdapter { .or_insert(true.into()); } + let adapter_path = if let Some(user_installed_path) = user_installed_path { + user_installed_path + } else { + let adapter_path = paths::debug_adapters_dir().join(self.name().as_ref()); + + let file_name_prefix = format!("{}_", self.name()); + + util::fs::find_file_name_in_dir(adapter_path.as_path(), |file_name| { + file_name.starts_with(&file_name_prefix) + }) + .await + .context("Couldn't find JavaScript dap directory")? + .join(Self::ADAPTER_PATH) + }; + let arguments = if let Some(mut args) = user_args { - args.insert( - 0, - adapter_path - .join(Self::ADAPTER_PATH) - .to_string_lossy() - .to_string(), - ); + args.insert(0, adapter_path.to_string_lossy().to_string()); args } else { vec![ - adapter_path - .join(Self::ADAPTER_PATH) - .to_string_lossy() - .to_string(), + adapter_path.to_string_lossy().to_string(), port.to_string(), host.to_string(), ] diff --git a/crates/dap_adapters/src/python.rs b/crates/dap_adapters/src/python.rs index dc3d15e124578e183ba5ed09b80aee7d6dda54c8..eb541bde8e7df233c549656f7640ee45bb0f6c06 100644 --- a/crates/dap_adapters/src/python.rs +++ b/crates/dap_adapters/src/python.rs @@ -40,12 +40,7 @@ impl PythonDebugAdapter { "Using user-installed debugpy adapter from: {}", user_installed_path.display() ); - vec![ - user_installed_path - .join(Self::ADAPTER_PATH) - .to_string_lossy() - .to_string(), - ] + vec![user_installed_path.to_string_lossy().to_string()] } else if installed_in_venv { log::debug!("Using venv-installed debugpy"); vec!["-m".to_string(), "debugpy.adapter".to_string()] @@ -700,7 +695,7 @@ mod tests { let port = 5678; // Case 1: User-defined debugpy path (highest precedence) - let user_path = PathBuf::from("/custom/path/to/debugpy"); + let user_path = PathBuf::from("/custom/path/to/debugpy/src/debugpy/adapter"); let user_args = PythonDebugAdapter::generate_debugpy_arguments( &host, port, @@ -717,7 +712,7 @@ mod tests { .await .unwrap(); - assert!(user_args[0].ends_with("src/debugpy/adapter")); + assert_eq!(user_args[0], "/custom/path/to/debugpy/src/debugpy/adapter"); assert_eq!(user_args[1], "--host=127.0.0.1"); assert_eq!(user_args[2], "--port=5678"); From afbd2b760f6254e7de580f0923f65c128f74433b Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 15 Jul 2025 20:10:44 -0300 Subject: [PATCH 0019/1056] agent: Add plan chip in the Zed section within the settings view (#34503) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit | Free | Pro | |--------|--------| | CleanShot 2025-07-15 at 7  50
48@2x | CleanShot 2025-07-15 at 7  51
45@2x | Release Notes: - agent: Added a chip communicating which Zed plan you're subscribed to in the agent panel settings view. --- crates/agent_ui/src/agent_configuration.rs | 83 ++++++++++++++++++++-- 1 file changed, 76 insertions(+), 7 deletions(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 579331c9acd07d1e41a1ceaee7fe2452bb0c1591..699a776330b0fda13417e5ac3e5400345192fc94 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -24,6 +24,7 @@ use project::{ context_server_store::{ContextServerConfiguration, ContextServerStatus, ContextServerStore}, project_settings::{ContextServerSettings, ProjectSettings}, }; +use proto::Plan; use settings::{Settings, update_settings_file}; use ui::{ ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, @@ -171,6 +172,15 @@ impl AgentConfiguration { .copied() .unwrap_or(false); + let is_zed_provider = provider.id() == ZED_CLOUD_PROVIDER_ID; + let current_plan = if is_zed_provider { + self.workspace + .upgrade() + .and_then(|workspace| workspace.read(cx).user_store().read(cx).current_plan()) + } else { + None + }; + v_flex() .when(is_expanded, |this| this.mb_2()) .child( @@ -208,14 +218,31 @@ impl AgentConfiguration { .size(IconSize::Small) .color(Color::Muted), ) - .child(Label::new(provider_name.clone()).size(LabelSize::Large)) - .when( - provider.is_authenticated(cx) && !is_expanded, - |parent| { - parent.child( - Icon::new(IconName::Check).color(Color::Success), + .child( + h_flex() + .gap_1() + .child( + Label::new(provider_name.clone()) + .size(LabelSize::Large), ) - }, + .map(|this| { + if is_zed_provider { + this.child( + self.render_zed_plan_info(current_plan, cx), + ) + } else { + this.when( + provider.is_authenticated(cx) + && !is_expanded, + |parent| { + parent.child( + Icon::new(IconName::Check) + .color(Color::Success), + ) + }, + ) + } + }), ), ) .child( @@ -431,6 +458,48 @@ impl AgentConfiguration { .child(self.render_sound_notification(cx)) } + fn render_zed_plan_info(&self, plan: Option, cx: &mut Context) -> impl IntoElement { + if let Some(plan) = plan { + let free_chip_bg = cx + .theme() + .colors() + .editor_background + .opacity(0.5) + .blend(cx.theme().colors().text_accent.opacity(0.05)); + + let pro_chip_bg = cx + .theme() + .colors() + .editor_background + .opacity(0.5) + .blend(cx.theme().colors().text_accent.opacity(0.2)); + + let (plan_name, plan_color, bg_color) = match plan { + Plan::Free => ("Free", Color::Default, free_chip_bg), + Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), + Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), + }; + + h_flex() + .ml_1() + .px_1() + .rounded_sm() + .border_1() + .border_color(cx.theme().colors().border) + .bg(bg_color) + .overflow_hidden() + .child( + Label::new(plan_name.to_string()) + .color(plan_color) + .size(LabelSize::XSmall) + .buffer_font(cx), + ) + .into_any_element() + } else { + div().into_any_element() + } + } + fn render_context_servers_section( &mut self, window: &mut Window, From 7ca3d969e04c46d86b1263c428381e01b43c8c37 Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:32:48 +0200 Subject: [PATCH 0020/1056] debugger: Highlight the size of jumped-to memory (#34504) Closes #ISSUE Release Notes: - N/A --- .../src/session/running/memory_view.rs | 23 ++++++++++++++----- crates/project/src/debugger/session.rs | 6 +++-- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index 9d946449544ecfd1a9c91c11918aaf1becb3d4d0..eb77604bee1cfa0ce48d6054c3c7231a4964d23f 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -159,6 +159,11 @@ impl MemoryView { open_context_menu: None, }; this.change_query_bar_mode(false, window, cx); + cx.on_focus_out(&this.focus_handle, window, |this, _, window, cx| { + this.change_query_bar_mode(false, window, cx); + cx.notify(); + }) + .detach(); this } fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { @@ -583,16 +588,22 @@ impl MemoryView { else { return; }; + let expr = format!("?${{{expr}}}"); let reference = self.session.update(cx, |this, cx| { this.memory_reference_of_expr(selected_frame, expr, cx) }); cx.spawn(async move |this, cx| { - if let Some(reference) = reference.await { + if let Some((reference, typ)) = reference.await { _ = this.update(cx, |this, cx| { - let Ok(address) = parse_int::parse::(&reference) else { - return; + let sizeof_expr = if typ.as_ref().is_some_and(|t| { + t.chars() + .all(|c| c.is_whitespace() || c.is_alphabetic() || c == '*') + }) { + typ.as_deref() + } else { + None }; - this.jump_to_address(address, cx); + this.go_to_memory_reference(&reference, sizeof_expr, selected_frame, cx); }); } }) @@ -763,7 +774,7 @@ fn render_single_memory_view_line( this.when(selection.contains(base_address + cell_ix as u64), |this| { let weak = weak.clone(); - this.bg(Color::Accent.color(cx)).when( + this.bg(Color::Selected.color(cx).opacity(0.2)).when( !selection.is_dragging(), |this| { let selection = selection.drag().memory_range(); @@ -860,7 +871,7 @@ fn render_single_memory_view_line( .px_0p5() .when_some(view_state.selection.as_ref(), |this, selection| { this.when(selection.contains(base_address + ix as u64), |this| { - this.bg(Color::Accent.color(cx)) + this.bg(Color::Selected.color(cx).opacity(0.2)) }) }) .child( diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index cf157ce4f92173d3202be0470d01d049c1c5e87e..1e296ac2ac9b87a9fae4c0aaa8ae9fb474f64eb2 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -1787,7 +1787,7 @@ impl Session { frame_id: Option, expression: String, cx: &mut Context, - ) -> Task> { + ) -> Task)>> { let request = self.request( EvaluateCommand { expression, @@ -1801,7 +1801,9 @@ impl Session { ); cx.background_spawn(async move { let result = request.await?; - result.memory_reference + result + .memory_reference + .map(|reference| (reference, result.type_)) }) } From fc24102491c3a644e53792c1a318b00bfdfd6d6b Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Tue, 15 Jul 2025 17:52:50 -0600 Subject: [PATCH 0021/1056] Tweaks to ACP for the Gemini PR (#34506) - **Update to use --experimental-acp** - **Fix tool locations** Closes #ISSUE Release Notes: - N/A *or* Added/Fixed/Improved ... --------- Co-authored-by: mkorwel Co-authored-by: Agus Zubiaga --- crates/acp/src/acp.rs | 47 +++++++++++++++++++++-- crates/agent_servers/src/agent_servers.rs | 2 +- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/crates/acp/src/acp.rs b/crates/acp/src/acp.rs index 8351aeaee0ef1d12a6db938aa3949d7bd19ccb43..a7e72b0c2d59f8bfccb037b0e406308bcab947a0 100644 --- a/crates/acp/src/acp.rs +++ b/crates/acp/src/acp.rs @@ -1,10 +1,10 @@ pub use acp::ToolCallId; use agent_servers::AgentServer; -use agentic_coding_protocol::{self as acp, UserMessageChunk}; +use agentic_coding_protocol::{self as acp, ToolCallLocation, UserMessageChunk}; use anyhow::{Context as _, Result, anyhow}; use assistant_tool::ActionLog; use buffer_diff::BufferDiff; -use editor::{MultiBuffer, PathKey}; +use editor::{Bias, MultiBuffer, PathKey}; use futures::{FutureExt, channel::oneshot, future::BoxFuture}; use gpui::{AppContext, AsyncApp, Context, Entity, EventEmitter, SharedString, Task, WeakEntity}; use itertools::Itertools; @@ -769,6 +769,11 @@ impl AcpThread { status, }; + let location = call.locations.last().cloned(); + if let Some(location) = location { + self.set_project_location(location, cx) + } + self.push_entry(AgentThreadEntry::ToolCall(call), cx); id @@ -831,6 +836,11 @@ impl AcpThread { } } + let location = call.locations.last().cloned(); + if let Some(location) = location { + self.set_project_location(location, cx) + } + cx.emit(AcpThreadEvent::EntryUpdated(ix)); Ok(()) } @@ -852,6 +862,37 @@ impl AcpThread { } } + pub fn set_project_location(&self, location: ToolCallLocation, cx: &mut Context) { + self.project.update(cx, |project, cx| { + let Some(path) = project.project_path_for_absolute_path(&location.path, cx) else { + return; + }; + let buffer = project.open_buffer(path, cx); + cx.spawn(async move |project, cx| { + let buffer = buffer.await?; + + project.update(cx, |project, cx| { + let position = if let Some(line) = location.line { + let snapshot = buffer.read(cx).snapshot(); + let point = snapshot.clip_point(Point::new(line, 0), Bias::Left); + snapshot.anchor_before(point) + } else { + Anchor::MIN + }; + + project.set_agent_location( + Some(AgentLocation { + buffer: buffer.downgrade(), + position, + }), + cx, + ); + }) + }) + .detach_and_log_err(cx); + }); + } + /// Returns true if the last turn is awaiting tool authorization pub fn waiting_for_tool_confirmation(&self) -> bool { for entry in self.entries.iter().rev() { @@ -1780,7 +1821,7 @@ mod tests { Ok(AgentServerCommand { path: "node".into(), - args: vec![cli_path, "--acp".into()], + args: vec![cli_path, "--experimental-acp".into()], env: None, }) } diff --git a/crates/agent_servers/src/agent_servers.rs b/crates/agent_servers/src/agent_servers.rs index 5d588cd4aea0f863203201de82b0614cc210e615..ba43122570323f43398802583ed9d60c4adadf7f 100644 --- a/crates/agent_servers/src/agent_servers.rs +++ b/crates/agent_servers/src/agent_servers.rs @@ -56,7 +56,7 @@ pub trait AgentServer: Send { ) -> impl Future> + Send; } -const GEMINI_ACP_ARG: &str = "--acp"; +const GEMINI_ACP_ARG: &str = "--experimental-acp"; impl AgentServer for Gemini { async fn command( From ae65ff95a6b59ae52f0b401b9827b998cd792220 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Tue, 15 Jul 2025 21:24:35 -0400 Subject: [PATCH 0022/1056] ci: Disable FreeBSD builds (#34511) Recently FreeBSD zed-remote-server builds are failing 90%+ of the time for unknown reasons. Temporarily suspend them. Example failing builds: - [2025-07-15 16:15 Nightly Failure](https://github.com/zed-industries/zed/actions/runs/16302777887/job/46042358675) - [2025-07-15 12:20 Nightly Success](https://github.com/zed-industries/zed/actions/runs/16297907892/job/46025281518) - [2025-07-14 08:21 Nightly Failure](https://github.com/zed-industries/zed/actions/runs/16266193889/job/45923004940) - [2025-06-17 Nightly Failure](https://github.com/zed-industries/zed/actions/runs/15700462603/job/44234573761) Release Notes: - Temporarily disable FreeBSD zed-remote-server builds due to CI failures. --- .github/workflows/ci.yml | 4 +++- .github/workflows/release_nightly.yml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea352a9320827e25cfbf4f94dfcb28bdd9fba0d5..98b70ad834808e2814e3db359e0fe5e6458d2364 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -679,8 +679,10 @@ jobs: timeout-minutes: 60 runs-on: github-8vcpu-ubuntu-2404 if: | + false && ( startsWith(github.ref, 'refs/tags/v') || contains(github.event.pull_request.labels.*.name, 'run-bundling') + ) needs: [linux_tests] name: Build Zed on FreeBSD steps: @@ -798,7 +800,7 @@ jobs: if: | startsWith(github.ref, 'refs/tags/v') && endsWith(github.ref, '-pre') && !endsWith(github.ref, '.0-pre') - needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64, freebsd] + needs: [bundle-mac, bundle-linux-x86_x64, bundle-linux-aarch64, bundle-windows-x64] runs-on: - self-hosted - bundle diff --git a/.github/workflows/release_nightly.yml b/.github/workflows/release_nightly.yml index 1b9669c5d527f568ea8cc6b3918feae92d8b44e0..4be20525f97039bfa55f362aeffe9863f378df8d 100644 --- a/.github/workflows/release_nightly.yml +++ b/.github/workflows/release_nightly.yml @@ -187,7 +187,7 @@ jobs: freebsd: timeout-minutes: 60 - if: github.repository_owner == 'zed-industries' + if: false && github.repository_owner == 'zed-industries' runs-on: github-8vcpu-ubuntu-2404 needs: tests name: Build Zed on FreeBSD From ee4b9a27a2b14936cb28f8ec4a4d842174b6951a Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Tue, 15 Jul 2025 23:51:12 -0300 Subject: [PATCH 0023/1056] ui: Fix wrapping in the banner component (#34516) Also removing the `icon` field as the banner component always renders with an icon anyway. Hopefully, this fixes any weird text wrapping that was happening before. Release Notes: - N/A --- crates/ui/src/components/banner.rs | 51 ++++++++++++++---------------- 1 file changed, 23 insertions(+), 28 deletions(-) diff --git a/crates/ui/src/components/banner.rs b/crates/ui/src/components/banner.rs index 043791cdd86ccf6a94fb469356bd2aca7abaddf4..b16ca795b4b0c6f0ef4332d54f3db75ae8e42103 100644 --- a/crates/ui/src/components/banner.rs +++ b/crates/ui/src/components/banner.rs @@ -19,8 +19,8 @@ pub enum Severity { /// use ui::{Banner}; /// /// Banner::new() -/// .severity(Severity::Info) -/// .children(Label::new("This is an informational message")) +/// .severity(Severity::Success) +/// .children(Label::new("This is a success message")) /// .action_slot( /// Button::new("learn-more", "Learn More") /// .icon(IconName::ArrowUpRight) @@ -32,7 +32,6 @@ pub enum Severity { pub struct Banner { severity: Severity, children: Vec, - icon: Option<(IconName, Option)>, action_slot: Option, } @@ -42,7 +41,6 @@ impl Banner { Self { severity: Severity::Info, children: Vec::new(), - icon: None, action_slot: None, } } @@ -53,12 +51,6 @@ impl Banner { self } - /// Sets an icon to display in the banner with an optional color. - pub fn icon(mut self, icon: IconName, color: Option>) -> Self { - self.icon = Some((icon, color.map(|c| c.into()))); - self - } - /// A slot for actions, such as CTA or dismissal buttons. pub fn action_slot(mut self, element: impl IntoElement) -> Self { self.action_slot = Some(element.into_any_element()); @@ -73,12 +65,13 @@ impl ParentElement for Banner { } impl RenderOnce for Banner { - fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { - let base = h_flex() + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let banner = h_flex() .py_0p5() - .rounded_sm() + .gap_1p5() .flex_wrap() .justify_between() + .rounded_sm() .border_1(); let (icon, icon_color, bg_color, border_color) = match self.severity { @@ -108,29 +101,31 @@ impl RenderOnce for Banner { ), }; - let mut container = base.bg(bg_color).border_color(border_color); - - let mut content_area = h_flex().id("content_area").gap_1p5().overflow_x_scroll(); - - if self.icon.is_none() { - content_area = - content_area.child(Icon::new(icon).size(IconSize::XSmall).color(icon_color)); - } + let mut banner = banner.bg(bg_color).border_color(border_color); - content_area = content_area.children(self.children); + let icon_and_child = h_flex() + .items_start() + .min_w_0() + .gap_1p5() + .child( + h_flex() + .h(window.line_height()) + .flex_shrink_0() + .child(Icon::new(icon).size(IconSize::XSmall).color(icon_color)), + ) + .child(div().min_w_0().children(self.children)); if let Some(action_slot) = self.action_slot { - container = container + banner = banner .pl_2() - .pr_0p5() - .gap_2() - .child(content_area) + .pr_1() + .child(icon_and_child) .child(action_slot); } else { - container = container.px_2().child(div().w_full().child(content_area)); + banner = banner.px_2().child(icon_and_child); } - container + banner } } From 59d524427e1a4bf437b05dc5212ec36b393dabf9 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:15:45 -0300 Subject: [PATCH 0024/1056] ui: Add Chip component (#34521) Possibly the simplest component in our set, but a nice one to have so we can standardize how it looks across the app. Release Notes: - N/A --- crates/agent_ui/src/agent_configuration.rs | 23 ++--- crates/component/src/component_layout.rs | 12 +-- crates/extensions_ui/src/extensions_ui.rs | 17 +--- crates/ui/src/components.rs | 2 + crates/ui/src/components/chip.rs | 106 ++++++++++++++++++++ crates/ui/src/components/keybinding_hint.rs | 2 +- crates/ui/src/components/tooltip.rs | 2 +- 7 files changed, 124 insertions(+), 40 deletions(-) create mode 100644 crates/ui/src/components/chip.rs diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index 699a776330b0fda13417e5ac3e5400345192fc94..0697f5dee758f0a5c4d4f530e45394fb79e14f3a 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -27,7 +27,7 @@ use project::{ use proto::Plan; use settings::{Settings, update_settings_file}; use ui::{ - ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, + Chip, ContextMenu, Disclosure, Divider, DividerColor, ElevationIndex, Indicator, PopoverMenu, Scrollbar, ScrollbarState, Switch, SwitchColor, Tooltip, prelude::*, }; use util::ResultExt as _; @@ -227,7 +227,7 @@ impl AgentConfiguration { ) .map(|this| { if is_zed_provider { - this.child( + this.gap_2().child( self.render_zed_plan_info(current_plan, cx), ) } else { @@ -474,26 +474,15 @@ impl AgentConfiguration { .opacity(0.5) .blend(cx.theme().colors().text_accent.opacity(0.2)); - let (plan_name, plan_color, bg_color) = match plan { + let (plan_name, label_color, bg_color) = match plan { Plan::Free => ("Free", Color::Default, free_chip_bg), Plan::ZedProTrial => ("Pro Trial", Color::Accent, pro_chip_bg), Plan::ZedPro => ("Pro", Color::Accent, pro_chip_bg), }; - h_flex() - .ml_1() - .px_1() - .rounded_sm() - .border_1() - .border_color(cx.theme().colors().border) - .bg(bg_color) - .overflow_hidden() - .child( - Label::new(plan_name.to_string()) - .color(plan_color) - .size(LabelSize::XSmall) - .buffer_font(cx), - ) + Chip::new(plan_name.to_string()) + .bg_color(bg_color) + .label_color(label_color) .into_any_element() } else { div().into_any_element() diff --git a/crates/component/src/component_layout.rs b/crates/component/src/component_layout.rs index b749ea20eab8b347b83bf34e35c33ec4ef5c614f..9fe52507d8a27abd3ba739f89a375e455a80f520 100644 --- a/crates/component/src/component_layout.rs +++ b/crates/component/src/component_layout.rs @@ -48,20 +48,20 @@ impl RenderOnce for ComponentExample { ) .child( div() - .flex() - .w_full() - .rounded_xl() .min_h(px(100.)) - .justify_center() + .w_full() .p_8() + .flex() + .items_center() + .justify_center() + .rounded_xl() .border_1() .border_color(cx.theme().colors().border.opacity(0.5)) .bg(pattern_slash( - cx.theme().colors().surface_background.opacity(0.5), + cx.theme().colors().surface_background.opacity(0.25), 12.0, 12.0, )) - .shadow_xs() .child(self.element), ) .into_any_element() diff --git a/crates/extensions_ui/src/extensions_ui.rs b/crates/extensions_ui/src/extensions_ui.rs index b944b1ec505178b56d0894b9790040ac73ede639..fe3e94f5c20dc1a78ae01defc24e290c18a1a3e6 100644 --- a/crates/extensions_ui/src/extensions_ui.rs +++ b/crates/extensions_ui/src/extensions_ui.rs @@ -24,7 +24,7 @@ use settings::Settings; use strum::IntoEnumIterator as _; use theme::ThemeSettings; use ui::{ - CheckboxWithLabel, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState, + CheckboxWithLabel, Chip, ContextMenu, PopoverMenu, ScrollableHandle, Scrollbar, ScrollbarState, ToggleButton, Tooltip, prelude::*, }; use vim_mode_setting::VimModeSetting; @@ -759,20 +759,7 @@ impl ExtensionsPage { _ => {} } - Some( - div() - .px_1() - .border_1() - .rounded_sm() - .border_color(cx.theme().colors().border) - .bg(cx.theme().colors().element_background) - .child( - Label::new(extension_provides_label( - *provides, - )) - .size(LabelSize::XSmall), - ), - ) + Some(Chip::new(extension_provides_label(*provides))) }) .collect::>(), ), diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 88676e8a2bbe383538e91499a71ca908b2057203..9c2961c55f234f821e947bf3ee3254b2e1fbecab 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -2,6 +2,7 @@ mod avatar; mod banner; mod button; mod callout; +mod chip; mod content_group; mod context_menu; mod disclosure; @@ -43,6 +44,7 @@ pub use avatar::*; pub use banner::*; pub use button::*; pub use callout::*; +pub use chip::*; pub use content_group::*; pub use context_menu::*; pub use disclosure::*; diff --git a/crates/ui/src/components/chip.rs b/crates/ui/src/components/chip.rs new file mode 100644 index 0000000000000000000000000000000000000000..e1262875feae77b69e660c0e9da17e1e669137b7 --- /dev/null +++ b/crates/ui/src/components/chip.rs @@ -0,0 +1,106 @@ +use crate::prelude::*; +use gpui::{AnyElement, Hsla, IntoElement, ParentElement, Styled}; + +/// Chips provide a container for an informative label. +/// +/// # Usage Example +/// +/// ``` +/// use ui::{Chip}; +/// +/// Chip::new("This Chip") +/// ``` +#[derive(IntoElement, RegisterComponent)] +pub struct Chip { + label: SharedString, + label_color: Color, + label_size: LabelSize, + bg_color: Option, +} + +impl Chip { + /// Creates a new `Chip` component with the specified label. + pub fn new(label: impl Into) -> Self { + Self { + label: label.into(), + label_color: Color::Default, + label_size: LabelSize::XSmall, + bg_color: None, + } + } + + /// Sets the color of the label. + pub fn label_color(mut self, color: Color) -> Self { + self.label_color = color; + self + } + + /// Sets the size of the label. + pub fn label_size(mut self, size: LabelSize) -> Self { + self.label_size = size; + self + } + + /// Sets a custom background color for the callout content. + pub fn bg_color(mut self, color: Hsla) -> Self { + self.bg_color = Some(color); + self + } +} + +impl RenderOnce for Chip { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let bg_color = self + .bg_color + .unwrap_or(cx.theme().colors().element_background); + + h_flex() + .min_w_0() + .flex_initial() + .px_1() + .border_1() + .rounded_sm() + .border_color(cx.theme().colors().border) + .bg(bg_color) + .overflow_hidden() + .child( + Label::new(self.label) + .size(self.label_size) + .color(self.label_color) + .buffer_font(cx), + ) + } +} + +impl Component for Chip { + fn scope() -> ComponentScope { + ComponentScope::DataDisplay + } + + fn preview(_window: &mut Window, cx: &mut App) -> Option { + let chip_examples = vec![ + single_example("Default", Chip::new("Chip Example").into_any_element()), + single_example( + "Customized Label Color", + Chip::new("Chip Example") + .label_color(Color::Accent) + .into_any_element(), + ), + single_example( + "Customized Label Size", + Chip::new("Chip Example") + .label_size(LabelSize::Large) + .label_color(Color::Accent) + .into_any_element(), + ), + single_example( + "Customized Background Color", + Chip::new("Chip Example") + .bg_color(cx.theme().colors().text_accent.opacity(0.1)) + .into_any_element(), + ), + ]; + + Some(example_group(chip_examples).vertical().into_any_element()) + } +} diff --git a/crates/ui/src/components/keybinding_hint.rs b/crates/ui/src/components/keybinding_hint.rs index d6dc094d415bec9991b83dfc50a865a838c1bdf4..a34ca40ed8c413d2edd6278dd035b93329dc5339 100644 --- a/crates/ui/src/components/keybinding_hint.rs +++ b/crates/ui/src/components/keybinding_hint.rs @@ -206,7 +206,7 @@ impl RenderOnce for KeybindingHint { impl Component for KeybindingHint { fn scope() -> ComponentScope { - ComponentScope::None + ComponentScope::DataDisplay } fn description() -> Option<&'static str> { diff --git a/crates/ui/src/components/tooltip.rs b/crates/ui/src/components/tooltip.rs index 18c9decc59f6f68b92542fbd56b6fae916195bfd..ed0fdd0114137256273f420acd647228bf605218 100644 --- a/crates/ui/src/components/tooltip.rs +++ b/crates/ui/src/components/tooltip.rs @@ -274,7 +274,7 @@ impl Render for LinkPreview { impl Component for Tooltip { fn scope() -> ComponentScope { - ComponentScope::None + ComponentScope::DataDisplay } fn description() -> Option<&'static str> { From 1ed3f9eb42b37f8cb141d6d04813e6bad4ed2ab9 Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 16 Jul 2025 01:48:01 -0300 Subject: [PATCH 0025/1056] Add user handle and plan chip to the user menu (#34522) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A nicer way to visualize in which plan you're in and a bit of personalization by adding the GitHub handle you're signed with in the user menu, as a complement to the avatar photo itself. Taking advantage of the newly added Chip component. CleanShot 2025-07-16 at 1  33 08@2x Release Notes: - N/A --- crates/title_bar/src/title_bar.rs | 60 ++++++++++++++++++++++++------- 1 file changed, 47 insertions(+), 13 deletions(-) diff --git a/crates/title_bar/src/title_bar.rs b/crates/title_bar/src/title_bar.rs index 5c916254125672850b2d9a403554fcb8ff140567..4b8902d14e54bbfef86008d499caf9a5eb7e5027 100644 --- a/crates/title_bar/src/title_bar.rs +++ b/crates/title_bar/src/title_bar.rs @@ -34,7 +34,7 @@ use std::sync::Arc; use theme::ActiveTheme; use title_bar_settings::TitleBarSettings; use ui::{ - Avatar, Button, ButtonLike, ButtonStyle, ContextMenu, Icon, IconName, IconSize, + Avatar, Button, ButtonLike, ButtonStyle, Chip, ContextMenu, Icon, IconName, IconSize, IconWithIndicator, Indicator, PopoverMenu, Tooltip, h_flex, prelude::*, }; use util::ResultExt; @@ -631,21 +631,55 @@ impl TitleBar { // Since the user might be on the legacy free plan we filter based on whether we have a subscription period. has_subscription_period }); + + let user_avatar = user.avatar_uri.clone(); + let free_chip_bg = cx + .theme() + .colors() + .editor_background + .opacity(0.5) + .blend(cx.theme().colors().text_accent.opacity(0.05)); + + let pro_chip_bg = cx + .theme() + .colors() + .editor_background + .opacity(0.5) + .blend(cx.theme().colors().text_accent.opacity(0.2)); + PopoverMenu::new("user-menu") .anchor(Corner::TopRight) .menu(move |window, cx| { ContextMenu::build(window, cx, |menu, _, _cx| { - menu.link( - format!( - "Current Plan: {}", - match plan { - None => "None", - Some(proto::Plan::Free) => "Zed Free", - Some(proto::Plan::ZedPro) => "Zed Pro", - Some(proto::Plan::ZedProTrial) => "Zed Pro (Trial)", - } - ), - zed_actions::OpenAccountSettings.boxed_clone(), + let user_login = user.github_login.clone(); + + let (plan_name, label_color, bg_color) = match plan { + None => ("None", Color::Default, free_chip_bg), + Some(proto::Plan::Free) => ("Free", Color::Default, free_chip_bg), + Some(proto::Plan::ZedProTrial) => { + ("Pro Trial", Color::Accent, pro_chip_bg) + } + Some(proto::Plan::ZedPro) => ("Pro", Color::Accent, pro_chip_bg), + }; + + menu.custom_entry( + move |_window, _cx| { + let user_login = user_login.clone(); + + h_flex() + .w_full() + .justify_between() + .child(Label::new(user_login)) + .child( + Chip::new(plan_name.to_string()) + .bg_color(bg_color) + .label_color(label_color), + ) + .into_any_element() + }, + move |_, cx| { + cx.open_url("https://zed.dev/account"); + }, ) .separator() .action("Settings", zed_actions::OpenSettings.boxed_clone()) @@ -675,7 +709,7 @@ impl TitleBar { .children( TitleBarSettings::get_global(cx) .show_user_picture - .then(|| Avatar::new(user.avatar_uri.clone())), + .then(|| Avatar::new(user_avatar)), ) .child( Icon::new(IconName::ChevronDown) From a52910382522ade3e83554820d838aebb82a4615 Mon Sep 17 00:00:00 2001 From: someone13574 <81528246+someone13574@users.noreply.github.com> Date: Tue, 15 Jul 2025 23:08:16 -0700 Subject: [PATCH 0026/1056] Disable format-on-save for verilog (#34512) Disables format-on-save by default for the [verilog extension](https://github.com/someone13574/zed-verilog-extension), since there isn't a standard style. Release Notes: - N/A --- assets/settings/default.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/assets/settings/default.json b/assets/settings/default.json index aa6e4399c387227dd557f9e30fb76006a75f4c2c..32d4c496c10cf31d1ae4b5f43a2996cb00eea5d0 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1671,6 +1671,10 @@ "allowed": true } }, + "SystemVerilog": { + "format_on_save": "off", + "use_on_type_format": false + }, "Vue.js": { "language_servers": ["vue-language-server", "..."], "prettier": { From 42b2b65241feafa7bdccfdc81972fd9bd8ab2922 Mon Sep 17 00:00:00 2001 From: Stephen Samra Date: Wed, 16 Jul 2025 07:14:18 +0100 Subject: [PATCH 0027/1056] Document alternative method to providing intelephense license key (#34502) This PR updates the [Intelephense section in the docs](https://zed.dev/docs/languages/php#intelephense) to include an alternative way to provide the premium license key. Release Notes: - N/A --- docs/src/languages/php.md | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/docs/src/languages/php.md b/docs/src/languages/php.md index 2ddb93c8d5b9465f7a68fb4f59ce8cb8225410ac..9cb7c40762e7af0f0680cbbcf564d4d989e7f0e9 100644 --- a/docs/src/languages/php.md +++ b/docs/src/languages/php.md @@ -27,7 +27,7 @@ which php ## Intelephense -[Intelephense](https://intelephense.com/) is a [proprietary](https://github.com/bmewburn/vscode-intelephense/blob/master/LICENSE.txt#L29) language server for PHP operating under a freemium model. Certain features require purchase of a [premium license](https://intelephense.com/). To use these features you must place your [licence.txt file](https://intelephense.com/faq.html) at `~/intelephense/licence.txt` inside your home directory. +[Intelephense](https://intelephense.com/) is a [proprietary](https://github.com/bmewburn/vscode-intelephense/blob/master/LICENSE.txt#L29) language server for PHP operating under a freemium model. Certain features require purchase of a [premium license](https://intelephense.com/). To switch to `intelephense`, add the following to your `settings.json`: @@ -41,6 +41,20 @@ To switch to `intelephense`, add the following to your `settings.json`: } ``` +To use the premium features, you can place your [licence.txt file](https://intelephense.com/faq.html) at `~/intelephense/licence.txt` inside your home directory. Alternatively, you can pass the licence key or a path to a file containing the licence key as an initialization option for the `intelephense` language server. To do this, add the following to your `settings.json`: + +```json +{ + "lsp": { + "intelephense": { + "initialization_options": { + "licenceKey": "/path/to/licence.txt" + } + } + } +} +``` + ## PHPDoc Zed supports syntax highlighting for PHPDoc comments. From 312369c84f826643ff0e5ebb7dc66ff4fe367dce Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:00:36 +0200 Subject: [PATCH 0028/1056] debugger: Improve drag-and-scroll in memory views (#34526) Closes #34508 Release Notes: - N/A --- .../src/session/running/memory_view.rs | 74 +++++++++++-------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/crates/debugger_ui/src/session/running/memory_view.rs b/crates/debugger_ui/src/session/running/memory_view.rs index eb77604bee1cfa0ce48d6054c3c7231a4964d23f..499091ca0fe687934d8c386577bf3157f68c96ff 100644 --- a/crates/debugger_ui/src/session/running/memory_view.rs +++ b/crates/debugger_ui/src/session/running/memory_view.rs @@ -8,10 +8,10 @@ use std::{ use editor::{Editor, EditorElement, EditorStyle}; use gpui::{ - Action, AppContext, DismissEvent, Empty, Entity, FocusHandle, Focusable, MouseButton, - MouseMoveEvent, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task, - TextStyle, UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, bounds, - deferred, point, size, uniform_list, + Action, AppContext, DismissEvent, DragMoveEvent, Empty, Entity, FocusHandle, Focusable, + MouseButton, Point, ScrollStrategy, ScrollWheelEvent, Stateful, Subscription, Task, TextStyle, + UniformList, UniformListScrollHandle, WeakEntity, actions, anchored, deferred, point, + uniform_list, }; use notifications::status_toast::{StatusToast, ToastIcon}; use project::debugger::{MemoryCell, dap_command::DataBreakpointContext, session::Session}; @@ -126,6 +126,8 @@ impl ViewState { } } +struct ScrollbarDragging; + static HEX_BYTES_MEMOIZED: LazyLock<[SharedString; 256]> = LazyLock::new(|| std::array::from_fn(|byte| SharedString::from(format!("{byte:02X}")))); static UNKNOWN_BYTE: SharedString = SharedString::new_static("??"); @@ -189,11 +191,14 @@ impl MemoryView { div() .occlude() .id("memory-view-vertical-scrollbar") - .on_mouse_move(cx.listener(|this, evt, _, cx| { - this.handle_drag(evt); + .on_drag_move(cx.listener(|this, evt, _, cx| { + let did_handle = this.handle_scroll_drag(evt); cx.notify(); - cx.stop_propagation() + if did_handle { + cx.stop_propagation() + } })) + .on_drag(ScrollbarDragging, |_, _, _, cx| cx.new(|_| Empty)) .on_hover(|_, _, cx| { cx.stop_propagation(); }) @@ -307,16 +312,12 @@ impl MemoryView { .detach(); } - fn handle_drag(&mut self, evt: &MouseMoveEvent) { - if !evt.dragging() { - return; - } - if !self.scroll_state.is_dragging() - && !self - .view_state - .selection - .as_ref() - .is_some_and(|selection| selection.is_dragging()) + fn handle_memory_drag(&mut self, evt: &DragMoveEvent) { + if !self + .view_state + .selection + .as_ref() + .is_some_and(|selection| selection.is_dragging()) { return; } @@ -324,22 +325,31 @@ impl MemoryView { debug_assert!(row_count > 1); let scroll_handle = self.scroll_state.scroll_handle(); let viewport = scroll_handle.viewport(); - let (top_area, bottom_area) = { - let size = size(viewport.size.width, viewport.size.height / 10.); - ( - bounds(viewport.origin, size), - bounds( - point(viewport.origin.x, viewport.origin.y + size.height * 2.), - size, - ), - ) - }; - if bottom_area.contains(&evt.position) { - //ix == row_count - 1 { + if viewport.bottom() < evt.event.position.y { + self.view_state.schedule_scroll_down(); + } else if viewport.top() > evt.event.position.y { + self.view_state.schedule_scroll_up(); + } + } + + fn handle_scroll_drag(&mut self, evt: &DragMoveEvent) -> bool { + if !self.scroll_state.is_dragging() { + return false; + } + let row_count = self.view_state.row_count(); + debug_assert!(row_count > 1); + let scroll_handle = self.scroll_state.scroll_handle(); + let viewport = scroll_handle.viewport(); + + if viewport.bottom() < evt.event.position.y { self.view_state.schedule_scroll_down(); - } else if top_area.contains(&evt.position) { + true + } else if viewport.top() > evt.event.position.y { self.view_state.schedule_scroll_up(); + true + } else { + false } } @@ -955,8 +965,8 @@ impl Render for MemoryView { .child( v_flex() .size_full() - .on_mouse_move(cx.listener(|this, evt: &MouseMoveEvent, _, _| { - this.handle_drag(evt); + .on_drag_move(cx.listener(|this, evt, _, _| { + this.handle_memory_drag(&evt); })) .child(self.render_memory(cx).size_full()) .children(self.open_context_menu.as_ref().map(|(menu, position, _)| { From c29c46d3b604e00234b44b4fc5cc7feaf9f92d9f Mon Sep 17 00:00:00 2001 From: Ragul R <85612319+rv-ragul@users.noreply.github.com> Date: Wed, 16 Jul 2025 18:22:37 +0530 Subject: [PATCH 0029/1056] Appropriately pick venv activation script (#33205) when `terminal.detect_venv.activate_script` setting is default, pick the appropriate activate script as per the `terminal.shell` settings specified by the user. Previously when the activate_script setting is default, zed always try to use the `activate` script, which only works when the user shell is `bash or zsh`. But what if the user is using `fish` shell in zed? Release Notes: - python: value of `activate_script` setting is now automatically inferred based on the kind of shell the user is running with. --------- Co-authored-by: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> --- crates/project/src/terminals.rs | 43 +++++++++++++++++++++--- crates/terminal/src/terminal_settings.rs | 2 +- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index d3aec588ec126f64557f064c9c3d0fe225e284e3..3d62b4156b7b96caade454d5d05a3c02c44dae8c 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -16,7 +16,7 @@ use std::{ use task::{DEFAULT_REMOTE_SHELL, Shell, ShellBuilder, SpawnInTerminal}; use terminal::{ TaskState, TaskStatus, Terminal, TerminalBuilder, - terminal_settings::{self, TerminalSettings, VenvSettings}, + terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings}, }; use util::{ ResultExt, @@ -256,8 +256,11 @@ impl Project { let (spawn_task, shell) = match kind { TerminalKind::Shell(_) => { if let Some(python_venv_directory) = &python_venv_directory { - python_venv_activate_command = - this.python_activate_command(python_venv_directory, &settings.detect_venv); + python_venv_activate_command = this.python_activate_command( + python_venv_directory, + &settings.detect_venv, + &settings.shell, + ); } match ssh_details { @@ -510,10 +513,27 @@ impl Project { }) } + fn activate_script_kind(shell: Option<&str>) -> ActivateScript { + let shell_env = std::env::var("SHELL").ok(); + let shell_path = shell.or_else(|| shell_env.as_deref()); + let shell = std::path::Path::new(shell_path.unwrap_or("")) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + match shell { + "fish" => ActivateScript::Fish, + "tcsh" => ActivateScript::Csh, + "nu" => ActivateScript::Nushell, + "powershell" | "pwsh" => ActivateScript::PowerShell, + _ => ActivateScript::Default, + } + } + fn python_activate_command( &self, venv_base_directory: &Path, venv_settings: &VenvSettings, + shell: &Shell, ) -> Option { let venv_settings = venv_settings.as_option()?; let activate_keyword = match venv_settings.activate_script { @@ -526,7 +546,22 @@ impl Project { terminal_settings::ActivateScript::Pyenv => "pyenv", _ => "source", }; - let activate_script_name = match venv_settings.activate_script { + let script_kind = + if venv_settings.activate_script == terminal_settings::ActivateScript::Default { + match shell { + Shell::Program(program) => Self::activate_script_kind(Some(program)), + Shell::WithArguments { + program, + args: _, + title_override: _, + } => Self::activate_script_kind(Some(program)), + Shell::System => Self::activate_script_kind(None), + } + } else { + venv_settings.activate_script + }; + + let activate_script_name = match script_kind { terminal_settings::ActivateScript::Default | terminal_settings::ActivateScript::Pyenv => "activate", terminal_settings::ActivateScript::Csh => "activate.csh", diff --git a/crates/terminal/src/terminal_settings.rs b/crates/terminal/src/terminal_settings.rs index f5d7d5b306fc428f9aa876effa54ae410b2c4a7f..a290ce9c81c18f5043fd45e9c1afdac52efa2061 100644 --- a/crates/terminal/src/terminal_settings.rs +++ b/crates/terminal/src/terminal_settings.rs @@ -123,7 +123,7 @@ impl VenvSettings { } } -#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, JsonSchema)] +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum ActivateScript { #[default] From 3d160a6e263717728d4dc18f554b0b7c19cb4ae8 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 16 Jul 2025 09:10:51 -0400 Subject: [PATCH 0030/1056] Don't highlight partial indent guide backgrounds (#34433) Closes https://github.com/zed-industries/zed/issues/33665 Previously if a line was indented something that was not a multiple of `tab_size` with `"ident_guides": { "background_coloring": "indent_aware" } }` the background of characters would be highlighted. E.g. indent of 6 with tab_size 4. | Before / After | | - | | Screenshot 2025-07-14 at 14 43 46 | Screenshot 2025-07-14 at 14 43 09 | CC: @bennetbo Any idea why this partial indent was enabled in your initial implementation [here](https://github.com/zed-industries/zed/pull/11503/files#diff-1781b7848dd9630f3c4f62df322c08af9a2de74af736e7eba031ebaeb4a0e2f4R3156-R3160)? This looks to be intentional. Release Notes: - N/A --- crates/multi_buffer/src/multi_buffer.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index e22fdb1ed5a978211d4dc6fd071107600ccf789f..2cc8ea59abace61ea1cec23112524f9b3ec2dda8 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -5905,7 +5905,6 @@ impl MultiBufferSnapshot { let depth = if found_indent { line_indent.len(tab_size) / tab_size - + ((line_indent.len(tab_size) % tab_size) > 0) as u32 } else { 0 }; From d4110fd2ab680364b265704cba9165d29208c33b Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Wed, 16 Jul 2025 19:25:13 +0530 Subject: [PATCH 0031/1056] linux: Fix spacebar not working with multiple keyboard layouts (#34514) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #26468 #16667 This PR fixes the spacebar not working with multiple keyboard layouts on Linux X11. I have tested this with Czech, Russian, German, German Neo 2, etc. It seems to work correctly. `XkbStateNotify` events correctly update XKB state with complete modifier info (depressed/latched/locked), but `KeyPress/KeyRelease` events immediately overwrite that state using `update_mask()` with only raw X11 modifier bits. This breaks xkb state as we reset `latched_mods` and `locked_mods` to 0, as well as we might not correctly handle cases where this new xkb state needs to change. Previous logic is flawed because `KeyPress/KeyRelease` event only gives you depressed modifiers (`event.state`) and not others, which we try to fill in from `previous_xkb_state`. This patch was introduced to fix capitalization issue with Neo 2 (https://github.com/zed-industries/zed/pull/14466) and later to fix wrong keys with German layout (https://github.com/zed-industries/zed/pull/31193), both of which I have tested this PR with. Now, instead of manually managing XKB state, we use the `update_key` method, which internally handles modifier states and other cases we might have missed. From `update_key` docs: > Update the keyboard state to reflect a given key being pressed or released. > > This entry point is intended for programs which track the keyboard state explictly (like an evdev client). If the state is serialized to you by a master process (like a Wayland compositor) using functions like `xkb_state_serialize_mods()`, you should use `xkb_state_update_mask()` instead. **_The two functins should not generally be used together._** > > A series of calls to this function should be consistent; that is, a call with `xkb::KEY_DOWN` for a key should be matched by an `xkb::KEY_UP`; if a key is pressed twice, it should be released twice; etc. Otherwise (e.g. due to missed input events), situations like "stuck modifiers" may occur. > > This function is often used in conjunction with the function `xkb_state_key_get_syms()` (or `xkb_state_key_get_one_sym()`), for example, when handling a key event. In this case, you should prefer to get the keysyms *before* updating the key, such that the keysyms reported for the key event are not affected by the event itself. This is the conventional behavior. Release Notes: - Fix the issue where the spacebar doesn’t work with multiple keyboard layouts on Linux X11. --- crates/gpui/src/platform/linux/x11/client.rs | 70 +++++--------------- 1 file changed, 18 insertions(+), 52 deletions(-) diff --git a/crates/gpui/src/platform/linux/x11/client.rs b/crates/gpui/src/platform/linux/x11/client.rs index 6cff977128ec594d683085e5f2cc24683c9e9ba7..0606f619c6fb808e4be42abe07f51d1e124a69f4 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,23 +1,22 @@ use crate::{Capslock, xcb_flush}; -use core::str; -use std::{ - cell::RefCell, - collections::{BTreeMap, HashSet}, - ops::Deref, - path::PathBuf, - rc::{Rc, Weak}, - time::{Duration, Instant}, -}; - use anyhow::{Context as _, anyhow}; use calloop::{ EventLoop, LoopHandle, RegistrationToken, generic::{FdWrapper, Generic}, }; use collections::HashMap; +use core::str; use http_client::Url; use log::Level; use smallvec::SmallVec; +use std::{ + cell::RefCell, + collections::{BTreeMap, HashSet}, + ops::Deref, + path::PathBuf, + rc::{Rc, Weak}, + time::{Duration, Instant}, +}; use util::ResultExt; use x11rb::{ @@ -38,7 +37,7 @@ use x11rb::{ }; use xim::{AttributeName, Client, InputStyle, x11rb::X11rbClient}; use xkbc::x11::ffi::{XKB_X11_MIN_MAJOR_XKB_VERSION, XKB_X11_MIN_MINOR_XKB_VERSION}; -use xkbcommon::xkb::{self as xkbc, LayoutIndex, ModMask, STATE_LAYOUT_EFFECTIVE}; +use xkbcommon::xkb::{self as xkbc, STATE_LAYOUT_EFFECTIVE}; use super::{ ButtonOrScroll, ScrollDirection, X11Display, X11WindowStatePtr, XcbAtoms, XimCallbackEvent, @@ -141,13 +140,6 @@ impl From for EventHandlerError { } } -#[derive(Debug, Default, Clone)] -struct XKBStateNotiy { - depressed_layout: LayoutIndex, - latched_layout: LayoutIndex, - locked_layout: LayoutIndex, -} - #[derive(Debug, Default)] pub struct Xdnd { other_window: xproto::Window, @@ -200,7 +192,6 @@ pub struct X11ClientState { pub(crate) mouse_focused_window: Option, pub(crate) keyboard_focused_window: Option, pub(crate) xkb: xkbc::State, - previous_xkb_state: XKBStateNotiy, keyboard_layout: LinuxKeyboardLayout, pub(crate) ximc: Option>>, pub(crate) xim_handler: Option, @@ -507,7 +498,6 @@ impl X11Client { mouse_focused_window: None, keyboard_focused_window: None, xkb: xkb_state, - previous_xkb_state: XKBStateNotiy::default(), keyboard_layout, ximc, xim_handler, @@ -959,14 +949,6 @@ impl X11Client { state.xkb_device_id, ) }; - let depressed_layout = xkb_state.serialize_layout(xkbc::STATE_LAYOUT_DEPRESSED); - let latched_layout = xkb_state.serialize_layout(xkbc::STATE_LAYOUT_LATCHED); - let locked_layout = xkb_state.serialize_layout(xkbc::ffi::XKB_STATE_LAYOUT_LOCKED); - state.previous_xkb_state = XKBStateNotiy { - depressed_layout, - latched_layout, - locked_layout, - }; state.xkb = xkb_state; drop(state); self.handle_keyboard_layout_change(); @@ -983,12 +965,6 @@ impl X11Client { event.latched_group as u32, event.locked_group.into(), ); - state.previous_xkb_state = XKBStateNotiy { - depressed_layout: event.base_group as u32, - latched_layout: event.latched_group as u32, - locked_layout: event.locked_group.into(), - }; - let modifiers = Modifiers::from_xkb(&state.xkb); let capslock = Capslock::from_xkb(&state.xkb); if state.last_modifiers_changed_event == modifiers @@ -1025,17 +1001,12 @@ impl X11Client { state.pre_key_char_down.take(); let keystroke = { let code = event.detail.into(); - let xkb_state = state.previous_xkb_state.clone(); - state.xkb.update_mask( - event.state.bits() as ModMask, - 0, - 0, - xkb_state.depressed_layout, - xkb_state.latched_layout, - xkb_state.locked_layout, - ); let mut keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); + + // should be called after key_get_one_sym + state.xkb.update_key(code, xkbc::KeyDirection::Down); + if keysym.is_modifier_key() { return Some(()); } @@ -1093,17 +1064,12 @@ impl X11Client { let keystroke = { let code = event.detail.into(); - let xkb_state = state.previous_xkb_state.clone(); - state.xkb.update_mask( - event.state.bits() as ModMask, - 0, - 0, - xkb_state.depressed_layout, - xkb_state.latched_layout, - xkb_state.locked_layout, - ); let keystroke = crate::Keystroke::from_xkb(&state.xkb, modifiers, code); let keysym = state.xkb.key_get_one_sym(code); + + // should be called after key_get_one_sym + state.xkb.update_key(code, xkbc::KeyDirection::Up); + if keysym.is_modifier_key() { return Some(()); } From 37927a5dc839df577c328f54ce3c3d0f51003880 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 16 Jul 2025 10:01:31 -0400 Subject: [PATCH 0032/1056] docs: Add some more redirects (#34537) This PR adds some more redirects for the docs. Release Notes: - N/A --- docs/book.toml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/book.toml b/docs/book.toml index d04447d90f846ff33b7437b22d4dc82bbf586c7e..1895a377a62a44de104900221dc551d6672eed18 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -37,7 +37,16 @@ enable = false "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html" "/model-improvement.html" = "/docs/ai/ai-improvement.html" "/extensions/context-servers.html" = "/docs/extensions/mcp-extensions.html" - +"/assistant-panel" = "/docs/ai/agent-panel.html" +"/assistant/model-context-protocolCitedby" = "/docs/ai/mcp.html" +"/community/feedback" = "/community-links" +"/context-servers" = "/docs/ai/mcp.html" +"/contribute-to-zed" = "/docs/development.html#contributor-links" +"/contributing" = "/docs/development.html#contributor-links" +"/debuggers" = "/docs/debugger.html" +"/development/development/macos" = "/docs/development/macos.html" +"/development/development/linux" = "/docs/development/linux.html" +"/development/development/windows" = "/docs/development/windows.html" # Our custom preprocessor for expanding commands like `{#kb action::ActionName}`, # and other docs-related functions. From 257bedf09b1a3130531c20dcdf5b03c6f90f1f06 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 16 Jul 2025 10:15:33 -0400 Subject: [PATCH 0033/1056] docs: Add missing extensions to redirects (#34539) Fixes the redirects added in https://github.com/zed-industries/zed/pull/34537. Release Notes: - N/A --- docs/book.toml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/docs/book.toml b/docs/book.toml index 1895a377a62a44de104900221dc551d6672eed18..98085c0cfa8fc45e2b182f6b9daacc6305922b4d 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -37,16 +37,16 @@ enable = false "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html" "/model-improvement.html" = "/docs/ai/ai-improvement.html" "/extensions/context-servers.html" = "/docs/extensions/mcp-extensions.html" -"/assistant-panel" = "/docs/ai/agent-panel.html" -"/assistant/model-context-protocolCitedby" = "/docs/ai/mcp.html" -"/community/feedback" = "/community-links" -"/context-servers" = "/docs/ai/mcp.html" -"/contribute-to-zed" = "/docs/development.html#contributor-links" -"/contributing" = "/docs/development.html#contributor-links" -"/debuggers" = "/docs/debugger.html" -"/development/development/macos" = "/docs/development/macos.html" -"/development/development/linux" = "/docs/development/linux.html" -"/development/development/windows" = "/docs/development/windows.html" +"/assistant-panel.html" = "/docs/ai/agent-panel.html" +"/assistant/model-context-protocolCitedby.html" = "/docs/ai/mcp.html" +"/community/feedback.html" = "/community-links" +"/context-servers.html" = "/docs/ai/mcp.html" +"/contribute-to-zed.html" = "/docs/development.html#contributor-links" +"/contributing.html" = "/docs/development.html#contributor-links" +"/debuggers.html" = "/docs/debugger.html" +"/development/development/macos.html" = "/docs/development/macos.html" +"/development/development/linux.html" = "/docs/development/linux.html" +"/development/development/windows.html" = "/docs/development/windows.html" # Our custom preprocessor for expanding commands like `{#kb action::ActionName}`, # and other docs-related functions. From 406ffb1e20f2db900e6d6f1fb99d348d6f4dffac Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 16 Jul 2025 17:38:58 +0300 Subject: [PATCH 0034/1056] agent: Push diffs of user edits to the agent (#34487) This change improves user/agent collaborative editing. When the user edits files that are used by the agent, the `project_notification` tool now pushes *diffs* of the changes, not just file names. This helps the agent to stay up to date without needing to re-read files. Release Notes: - Improved user/agent collaborative editing: agent now receives diffs of user edits --- Cargo.lock | 1 + crates/agent/src/thread.rs | 17 +- crates/assistant_tool/Cargo.toml | 1 + crates/assistant_tool/src/action_log.rs | 282 +++++++++++++++--- .../src/project_notifications_tool.rs | 50 ++-- 5 files changed, 274 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 15a28016c6d2f168168657a07443ea40080b07bb..a2e9fc26ca417e9cf771cc5dd5e1a2e92d4e53e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -745,6 +745,7 @@ dependencies = [ "futures 0.3.31", "gpui", "icons", + "indoc", "language", "language_model", "log", diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 8e66e526deedc6db1bec7912f48008bd6b36782c..d46dada2703438686b9df0e452dfef28777ff715 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -1532,7 +1532,9 @@ impl Thread { ) -> Option { let action_log = self.action_log.read(cx); - action_log.unnotified_stale_buffers(cx).next()?; + if !action_log.has_unnotified_user_edits() { + return None; + } // Represent notification as a simulated `project_notifications` tool call let tool_name = Arc::from("project_notifications"); @@ -3253,7 +3255,6 @@ mod tests { use futures::stream::BoxStream; use gpui::TestAppContext; use http_client; - use indoc::indoc; use language_model::fake_provider::{FakeLanguageModel, FakeLanguageModelProvider}; use language_model::{ LanguageModelCompletionError, LanguageModelName, LanguageModelProviderId, @@ -3614,6 +3615,7 @@ fn main() {{ cx, ); }); + cx.run_until_parked(); // We shouldn't have a stale buffer notification yet let notifications = thread.read_with(cx, |thread, _| { @@ -3643,11 +3645,13 @@ fn main() {{ cx, ) }); + cx.run_until_parked(); // Check for the stale buffer warning thread.update(cx, |thread, cx| { thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) }); + cx.run_until_parked(); let notifications = thread.read_with(cx, |thread, _cx| { find_tool_uses(thread, "project_notifications") @@ -3661,12 +3665,8 @@ fn main() {{ panic!("`project_notifications` should return text"); }; - let expected_content = indoc! {"[The following is an auto-generated notification; do not reply] - - These files have changed since the last read: - - code.rs - "}; - assert_eq!(notification_content, expected_content); + assert!(notification_content.contains("These files have changed since the last read:")); + assert!(notification_content.contains("code.rs")); // Insert another user message and flush notifications again thread.update(cx, |thread, cx| { @@ -3682,6 +3682,7 @@ fn main() {{ thread.update(cx, |thread, cx| { thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) }); + cx.run_until_parked(); // There should be no new notifications (we already flushed one) let notifications = thread.read_with(cx, |thread, _cx| { diff --git a/crates/assistant_tool/Cargo.toml b/crates/assistant_tool/Cargo.toml index 5a54e86eac15c2846e7e72ee45b47ab014cd69e6..acbe674b02cfe31a08f63e01f7dae1a2448c453e 100644 --- a/crates/assistant_tool/Cargo.toml +++ b/crates/assistant_tool/Cargo.toml @@ -40,6 +40,7 @@ collections = { workspace = true, features = ["test-support"] } clock = { workspace = true, features = ["test-support"] } ctor.workspace = true gpui = { workspace = true, features = ["test-support"] } +indoc.workspace = true language = { workspace = true, features = ["test-support"] } language_model = { workspace = true, features = ["test-support"] } log.workspace = true diff --git a/crates/assistant_tool/src/action_log.rs b/crates/assistant_tool/src/action_log.rs index e983075cd1e6db22af77856d50a43ebf812de825..dce1b0cdc1e4bfcf48d1387dd645d8b88a252060 100644 --- a/crates/assistant_tool/src/action_log.rs +++ b/crates/assistant_tool/src/action_log.rs @@ -8,7 +8,10 @@ use language::{Anchor, Buffer, BufferEvent, DiskState, Point, ToPoint}; use project::{Project, ProjectItem, lsp_store::OpenLspBufferHandle}; use std::{cmp, ops::Range, sync::Arc}; use text::{Edit, Patch, Rope}; -use util::{RangeExt, ResultExt as _}; +use util::{ + RangeExt, ResultExt as _, + paths::{PathStyle, RemotePathBuf}, +}; /// Tracks actions performed by tools in a thread pub struct ActionLog { @@ -18,8 +21,6 @@ pub struct ActionLog { edited_since_project_diagnostics_check: bool, /// The project this action log is associated with project: Entity, - /// Tracks which buffer versions have already been notified as changed externally - notified_versions: BTreeMap, clock::Global>, } impl ActionLog { @@ -29,7 +30,6 @@ impl ActionLog { tracked_buffers: BTreeMap::default(), edited_since_project_diagnostics_check: false, project, - notified_versions: BTreeMap::default(), } } @@ -51,6 +51,67 @@ impl ActionLog { Some(self.tracked_buffers.get(buffer)?.snapshot.clone()) } + pub fn has_unnotified_user_edits(&self) -> bool { + self.tracked_buffers + .values() + .any(|tracked| tracked.has_unnotified_user_edits) + } + + /// Return a unified diff patch with user edits made since last read or notification + pub fn unnotified_user_edits(&self, cx: &Context) -> Option { + if !self.has_unnotified_user_edits() { + return None; + } + + let unified_diff = self + .tracked_buffers + .values() + .filter_map(|tracked| { + if !tracked.has_unnotified_user_edits { + return None; + } + + let text_with_latest_user_edits = tracked.diff_base.to_string(); + let text_with_last_seen_user_edits = tracked.last_seen_base.to_string(); + if text_with_latest_user_edits == text_with_last_seen_user_edits { + return None; + } + let patch = language::unified_diff( + &text_with_last_seen_user_edits, + &text_with_latest_user_edits, + ); + + let buffer = tracked.buffer.clone(); + let file_path = buffer + .read(cx) + .file() + .map(|file| RemotePathBuf::new(file.full_path(cx), PathStyle::Posix).to_proto()) + .unwrap_or_else(|| format!("buffer_{}", buffer.entity_id())); + + let mut result = String::new(); + result.push_str(&format!("--- a/{}\n", file_path)); + result.push_str(&format!("+++ b/{}\n", file_path)); + result.push_str(&patch); + + Some(result) + }) + .collect::>() + .join("\n\n"); + + Some(unified_diff) + } + + /// Return a unified diff patch with user edits made since last read/notification + /// and mark them as notified + pub fn flush_unnotified_user_edits(&mut self, cx: &Context) -> Option { + let patch = self.unnotified_user_edits(cx); + self.tracked_buffers.values_mut().for_each(|tracked| { + tracked.has_unnotified_user_edits = false; + tracked.last_seen_base = tracked.diff_base.clone(); + }); + patch + } + fn track_buffer_internal( &mut self, buffer: Entity, @@ -59,7 +120,6 @@ impl ActionLog { ) -> &mut TrackedBuffer { let status = if is_created { if let Some(tracked) = self.tracked_buffers.remove(&buffer) { - self.notified_versions.remove(&buffer); match tracked.status { TrackedBufferStatus::Created { existing_file_content, @@ -101,26 +161,31 @@ impl ActionLog { let diff = cx.new(|cx| BufferDiff::new(&text_snapshot, cx)); let (diff_update_tx, diff_update_rx) = mpsc::unbounded(); let diff_base; + let last_seen_base; let unreviewed_edits; if is_created { diff_base = Rope::default(); + last_seen_base = Rope::default(); unreviewed_edits = Patch::new(vec![Edit { old: 0..1, new: 0..text_snapshot.max_point().row + 1, }]) } else { diff_base = buffer.read(cx).as_rope().clone(); + last_seen_base = diff_base.clone(); unreviewed_edits = Patch::default(); } TrackedBuffer { buffer: buffer.clone(), diff_base, + last_seen_base, unreviewed_edits, snapshot: text_snapshot.clone(), status, version: buffer.read(cx).version(), diff, diff_update: diff_update_tx, + has_unnotified_user_edits: false, _open_lsp_handle: open_lsp_handle, _maintain_diff: cx.spawn({ let buffer = buffer.clone(); @@ -174,7 +239,6 @@ impl ActionLog { // If the buffer had been edited by a tool, but it got // deleted externally, we want to stop tracking it. self.tracked_buffers.remove(&buffer); - self.notified_versions.remove(&buffer); } cx.notify(); } @@ -188,7 +252,6 @@ impl ActionLog { // resurrected externally, we want to clear the edits we // were tracking and reset the buffer's state. self.tracked_buffers.remove(&buffer); - self.notified_versions.remove(&buffer); self.track_buffer_internal(buffer, false, cx); } cx.notify(); @@ -262,19 +325,23 @@ impl ActionLog { buffer_snapshot: text::BufferSnapshot, cx: &mut AsyncApp, ) -> Result<()> { - let rebase = this.read_with(cx, |this, cx| { + let rebase = this.update(cx, |this, cx| { let tracked_buffer = this .tracked_buffers - .get(buffer) + .get_mut(buffer) .context("buffer not tracked")?; + if let ChangeAuthor::User = author { + tracked_buffer.has_unnotified_user_edits = true; + } + let rebase = cx.background_spawn({ let mut base_text = tracked_buffer.diff_base.clone(); let old_snapshot = tracked_buffer.snapshot.clone(); let new_snapshot = buffer_snapshot.clone(); let unreviewed_edits = tracked_buffer.unreviewed_edits.clone(); + let edits = diff_snapshots(&old_snapshot, &new_snapshot); async move { - let edits = diff_snapshots(&old_snapshot, &new_snapshot); if let ChangeAuthor::User = author { apply_non_conflicting_edits( &unreviewed_edits, @@ -494,7 +561,6 @@ impl ActionLog { match tracked_buffer.status { TrackedBufferStatus::Created { .. } => { self.tracked_buffers.remove(&buffer); - self.notified_versions.remove(&buffer); cx.notify(); } TrackedBufferStatus::Modified => { @@ -520,7 +586,6 @@ impl ActionLog { match tracked_buffer.status { TrackedBufferStatus::Deleted => { self.tracked_buffers.remove(&buffer); - self.notified_versions.remove(&buffer); cx.notify(); } _ => { @@ -629,7 +694,6 @@ impl ActionLog { }; self.tracked_buffers.remove(&buffer); - self.notified_versions.remove(&buffer); cx.notify(); task } @@ -643,7 +707,6 @@ impl ActionLog { // Clear all tracked edits for this buffer and start over as if we just read it. self.tracked_buffers.remove(&buffer); - self.notified_versions.remove(&buffer); self.buffer_read(buffer.clone(), cx); cx.notify(); save @@ -744,33 +807,6 @@ impl ActionLog { .collect() } - /// Returns stale buffers that haven't been notified yet - pub fn unnotified_stale_buffers<'a>( - &'a self, - cx: &'a App, - ) -> impl Iterator> { - self.stale_buffers(cx).filter(|buffer| { - let buffer_entity = buffer.read(cx); - self.notified_versions - .get(buffer) - .map_or(true, |notified_version| { - *notified_version != buffer_entity.version - }) - }) - } - - /// Marks the given buffers as notified at their current versions - pub fn mark_buffers_as_notified( - &mut self, - buffers: impl IntoIterator>, - cx: &App, - ) { - for buffer in buffers { - let version = buffer.read(cx).version.clone(); - self.notified_versions.insert(buffer, version); - } - } - /// Iterate over buffers changed since last read or edited by the model pub fn stale_buffers<'a>(&'a self, cx: &'a App) -> impl Iterator> { self.tracked_buffers @@ -914,12 +950,14 @@ enum TrackedBufferStatus { struct TrackedBuffer { buffer: Entity, diff_base: Rope, + last_seen_base: Rope, unreviewed_edits: Patch, status: TrackedBufferStatus, version: clock::Global, diff: Entity, snapshot: text::BufferSnapshot, diff_update: mpsc::UnboundedSender<(ChangeAuthor, text::BufferSnapshot)>, + has_unnotified_user_edits: bool, _open_lsp_handle: OpenLspBufferHandle, _maintain_diff: Task<()>, _subscription: Subscription, @@ -950,6 +988,7 @@ mod tests { use super::*; use buffer_diff::DiffHunkStatusKind; use gpui::TestAppContext; + use indoc::indoc; use language::Point; use project::{FakeFs, Fs, Project, RemoveOptions}; use rand::prelude::*; @@ -1232,6 +1271,110 @@ mod tests { assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); } + #[gpui::test(iterations = 10)] + async fn test_user_edits_notifications(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({"file": indoc! {" + abc + def + ghi + jkl + mno"}}), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + let file_path = project + .read_with(cx, |project, cx| project.find_project_path("dir/file", cx)) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + // Agent edits + cx.update(|cx| { + action_log.update(cx, |log, cx| log.buffer_read(buffer.clone(), cx)); + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 2)..Point::new(2, 3), "F\nGHI")], None, cx) + .unwrap() + }); + action_log.update(cx, |log, cx| log.buffer_edited(buffer.clone(), cx)); + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + indoc! {" + abc + deF + GHI + jkl + mno"} + ); + assert_eq!( + unreviewed_hunks(&action_log, cx), + vec![( + buffer.clone(), + vec![HunkStatus { + range: Point::new(1, 0)..Point::new(3, 0), + diff_status: DiffHunkStatusKind::Modified, + old_text: "def\nghi\n".into(), + }], + )] + ); + + // User edits + buffer.update(cx, |buffer, cx| { + buffer.edit( + [ + (Point::new(0, 2)..Point::new(0, 2), "X"), + (Point::new(3, 0)..Point::new(3, 0), "Y"), + ], + None, + cx, + ) + }); + cx.run_until_parked(); + assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.text()), + indoc! {" + abXc + deF + GHI + Yjkl + mno"} + ); + + // User edits should be stored separately from agent's + let user_edits = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx)); + assert_eq!( + user_edits.expect("should have some user edits"), + indoc! {" + --- a/dir/file + +++ b/dir/file + @@ -1,5 +1,5 @@ + -abc + +abXc + def + ghi + -jkl + +Yjkl + mno + "} + ); + + action_log.update(cx, |log, cx| { + log.keep_edits_in_range(buffer.clone(), Point::new(0, 0)..Point::new(1, 0), cx) + }); + cx.run_until_parked(); + assert_eq!(unreviewed_hunks(&action_log, cx), vec![]); + } + #[gpui::test(iterations = 10)] async fn test_creating_files(cx: &mut TestAppContext) { init_test(cx); @@ -2221,4 +2364,61 @@ mod tests { .collect() }) } + + #[gpui::test] + async fn test_format_patch(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/dir"), + json!({"test.txt": "line 1\nline 2\nline 3\n"}), + ) + .await; + let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let file_path = project + .read_with(cx, |project, cx| { + project.find_project_path("dir/test.txt", cx) + }) + .unwrap(); + let buffer = project + .update(cx, |project, cx| project.open_buffer(file_path, cx)) + .await + .unwrap(); + + cx.update(|cx| { + // Track the buffer and mark it as read first + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + }); + + // Make some edits to create a patch + buffer.update(cx, |buffer, cx| { + buffer + .edit([(Point::new(1, 0)..Point::new(1, 6), "CHANGED")], None, cx) + .unwrap(); // Replace "line2" with "CHANGED" + }); + }); + + cx.run_until_parked(); + + // Get the patch + let patch = action_log.update(cx, |log, cx| log.unnotified_user_edits(cx)); + + // Verify the patch format contains expected unified diff elements + assert_eq!( + patch.unwrap(), + indoc! {" + --- a/dir/test.txt + +++ b/dir/test.txt + @@ -1,3 +1,3 @@ + line 1 + -line 2 + +CHANGED + line 3 + "} + ); + } } diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs index 168ec61ae98529e1c82dcbe1d4334436457bab44..1b926bb4469593689c4bdf797b055d71c60fca35 100644 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -6,7 +6,6 @@ use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchem use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::fmt::Write as _; use std::sync::Arc; use ui::IconName; @@ -52,34 +51,22 @@ impl Tool for ProjectNotificationsTool { _window: Option, cx: &mut App, ) -> ToolResult { - let mut stale_files = String::new(); - let mut notified_buffers = Vec::new(); - - for stale_file in action_log.read(cx).unnotified_stale_buffers(cx) { - if let Some(file) = stale_file.read(cx).file() { - writeln!(&mut stale_files, "- {}", file.path().display()).ok(); - notified_buffers.push(stale_file.clone()); - } - } - - if !notified_buffers.is_empty() { - action_log.update(cx, |log, cx| { - log.mark_buffers_as_notified(notified_buffers, cx); - }); - } - - let response = if stale_files.is_empty() { - "No new notifications".to_string() - } else { - // NOTE: Changes to this prompt require a symmetric update in the LLM Worker - const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); - format!("{HEADER}{stale_files}").replace("\r\n", "\n") + let Some(user_edits_diff) = + action_log.update(cx, |log, cx| log.flush_unnotified_user_edits(cx)) + else { + return result("No new notifications"); }; - Task::ready(Ok(response.into())).into() + // NOTE: Changes to this prompt require a symmetric update in the LLM Worker + const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); + result(&format!("{HEADER}\n\n```diff\n{user_edits_diff}\n```\n").replace("\r\n", "\n")) } } +fn result(response: &str) -> ToolResult { + Task::ready(Ok(response.to_string().into())).into() +} + #[cfg(test)] mod tests { use super::*; @@ -123,6 +110,7 @@ mod tests { action_log.update(cx, |log, cx| { log.buffer_read(buffer.clone(), cx); }); + cx.run_until_parked(); // Run the tool before any changes let tool = Arc::new(ProjectNotificationsTool); @@ -142,6 +130,7 @@ mod tests { cx, ) }); + cx.run_until_parked(); let response = result.output.await.unwrap(); let response_text = match &response.content { @@ -158,6 +147,7 @@ mod tests { buffer.update(cx, |buffer, cx| { buffer.edit([(1..1, "\nChange!\n")], None, cx); }); + cx.run_until_parked(); // Run the tool again let result = cx.update(|cx| { @@ -171,6 +161,7 @@ mod tests { cx, ) }); + cx.run_until_parked(); // This time the buffer is stale, so the tool should return a notification let response = result.output.await.unwrap(); @@ -179,10 +170,12 @@ mod tests { _ => panic!("Expected text response"), }; - let expected_content = "[The following is an auto-generated notification; do not reply]\n\nThese files have changed since the last read:\n- code.rs\n"; - assert_eq!( - response_text.as_str(), - expected_content, + assert!( + response_text.contains("These files have changed"), + "Tool should return the stale buffer notification" + ); + assert!( + response_text.contains("test/code.rs"), "Tool should return the stale buffer notification" ); @@ -198,6 +191,7 @@ mod tests { cx, ) }); + cx.run_until_parked(); let response = result.output.await.unwrap(); let response_text = match &response.content { From 875c86e3ef30937f07b2db5324bf16331695f39b Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Wed, 16 Jul 2025 20:09:07 +0530 Subject: [PATCH 0035/1056] agent_ui: Fix token count not getting shown in the TextThread (#34485) Closes #34319 In this pr: https://github.com/zed-industries/zed/pull/33462 there was check added for early return for active_thread and message_editor as those are not present in the TextThread and only available in the Thread the token count was not getting triggered for TextThread this pr fixes that regression by moving the logic specific to Thread inside of thread view match. CleanShot 2025-07-15 at 23 50
18@2x Release Notes: - Fix token count not getting shown in the TextThread --- crates/agent_ui/src/agent_panel.rs | 57 ++++++++++++++---------------- 1 file changed, 27 insertions(+), 30 deletions(-) diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index ded26b189642c1eb9e6c79ec958a18ebb99ded68..2caa9dab42d374514324a2e97db3473db50fcbcf 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -1975,48 +1975,45 @@ impl AgentPanel { } fn render_token_count(&self, cx: &App) -> Option { - let (active_thread, message_editor) = match &self.active_view { + match &self.active_view { ActiveView::Thread { thread, message_editor, .. - } => (thread.read(cx), message_editor.read(cx)), - ActiveView::AcpThread { .. } => { - return None; - } - ActiveView::TextThread { .. } | ActiveView::History | ActiveView::Configuration => { - return None; - } - }; + } => { + let active_thread = thread.read(cx); + let message_editor = message_editor.read(cx); - let editor_empty = message_editor.is_editor_fully_empty(cx); + let editor_empty = message_editor.is_editor_fully_empty(cx); - if active_thread.is_empty() && editor_empty { - return None; - } + if active_thread.is_empty() && editor_empty { + return None; + } - let thread = active_thread.thread().read(cx); - let is_generating = thread.is_generating(); - let conversation_token_usage = thread.total_token_usage()?; + let thread = active_thread.thread().read(cx); + let is_generating = thread.is_generating(); + let conversation_token_usage = thread.total_token_usage()?; - let (total_token_usage, is_estimating) = - if let Some((editing_message_id, unsent_tokens)) = active_thread.editing_message_id() { - let combined = thread - .token_usage_up_to_message(editing_message_id) - .add(unsent_tokens); + let (total_token_usage, is_estimating) = + if let Some((editing_message_id, unsent_tokens)) = + active_thread.editing_message_id() + { + let combined = thread + .token_usage_up_to_message(editing_message_id) + .add(unsent_tokens); - (combined, unsent_tokens > 0) - } else { - let unsent_tokens = message_editor.last_estimated_token_count().unwrap_or(0); - let combined = conversation_token_usage.add(unsent_tokens); + (combined, unsent_tokens > 0) + } else { + let unsent_tokens = + message_editor.last_estimated_token_count().unwrap_or(0); + let combined = conversation_token_usage.add(unsent_tokens); - (combined, unsent_tokens > 0) - }; + (combined, unsent_tokens > 0) + }; - let is_waiting_to_update_token_count = message_editor.is_waiting_to_update_token_count(); + let is_waiting_to_update_token_count = + message_editor.is_waiting_to_update_token_count(); - match &self.active_view { - ActiveView::Thread { .. } => { if total_token_usage.total == 0 { return None; } From 6e147b3b910c192c5e795c3a759d6c9427de4d4a Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 16 Jul 2025 10:44:24 -0400 Subject: [PATCH 0036/1056] docs: Organize redirects (#34541) This PR organizes the docs redirects and adds some instructions for them. Release Notes: - N/A --- docs/book.toml | 56 ++++++++++++++++++++++++++++++++++---------------- 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/docs/book.toml b/docs/book.toml index 98085c0cfa8fc45e2b182f6b9daacc6305922b4d..70e294c014fc4a89a6d1a1ec3c2b8a0eb4be3637 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -15,37 +15,57 @@ additional-js = ["theme/page-toc.js", "theme/plugins.js"] [output.html.print] enable = false +# Redirects for `/docs` pages. +# +# All of the source URLs are interpreted relative to mdBook, so they must: +# 1. Not start with `/docs` +# 2. End in `.html` +# +# The destination URLs are interpreted relative to `https://zed.dev`. +# - Redirects to other docs pages should end in `.html` +# - You can link to pages on the Zed site by omitting the `/docs` in front of it. [output.html.redirect] -"/elixir.html" = "/docs/languages/elixir.html" -"/javascript.html" = "/docs/languages/javascript.html" -"/ruby.html" = "/docs/languages/ruby.html" -"/python.html" = "/docs/languages/python.html" -"/adding-new-languages.html" = "/docs/extensions/languages.html" -"/language-model-integration.html" = "/docs/assistant/assistant.html" -"/assistant.html" = "/docs/assistant/assistant.html" -"/developing-zed.html" = "/docs/development.html" -"/conversations.html" = "/community-links" +# AI "/ai.html" = "/docs/ai/overview.html" +"/assistant-panel.html" = "/docs/ai/agent-panel.html" +"/assistant.html" = "/docs/assistant/assistant.html" +"/assistant/assistant-panel.html" = "/docs/ai/agent-panel.html" "/assistant/assistant.html" = "/docs/ai/overview.html" +"/assistant/commands.html" = "/docs/ai/text-threads.html" "/assistant/configuration.html" = "/docs/ai/configuration.html" -"/assistant/assistant-panel.html" = "/docs/ai/agent-panel.html" +"/assistant/context-servers.html" = "/docs/ai/mcp.html" "/assistant/contexts.html" = "/docs/ai/text-threads.html" "/assistant/inline-assistant.html" = "/docs/ai/inline-assistant.html" -"/assistant/commands.html" = "/docs/ai/text-threads.html" -"/assistant/prompting.html" = "/docs/ai/rules.html" -"/assistant/context-servers.html" = "/docs/ai/mcp.html" "/assistant/model-context-protocol.html" = "/docs/ai/mcp.html" +"/assistant/prompting.html" = "/docs/ai/rules.html" +"/language-model-integration.html" = "/docs/assistant/assistant.html" "/model-improvement.html" = "/docs/ai/ai-improvement.html" -"/extensions/context-servers.html" = "/docs/extensions/mcp-extensions.html" -"/assistant-panel.html" = "/docs/ai/agent-panel.html" -"/assistant/model-context-protocolCitedby.html" = "/docs/ai/mcp.html" + +# Community "/community/feedback.html" = "/community-links" +"/conversations.html" = "/community-links" + +# Debugger +"/debuggers.html" = "/docs/debugger.html" + +# MCP +"/assistant/model-context-protocolCitedby.html" = "/docs/ai/mcp.html" "/context-servers.html" = "/docs/ai/mcp.html" +"/extensions/context-servers.html" = "/docs/extensions/mcp-extensions.html" + +# Languages +"/adding-new-languages.html" = "/docs/extensions/languages.html" +"/elixir.html" = "/docs/languages/elixir.html" +"/javascript.html" = "/docs/languages/javascript.html" +"/python.html" = "/docs/languages/python.html" +"/ruby.html" = "/docs/languages/ruby.html" + +# Zed development "/contribute-to-zed.html" = "/docs/development.html#contributor-links" "/contributing.html" = "/docs/development.html#contributor-links" -"/debuggers.html" = "/docs/debugger.html" -"/development/development/macos.html" = "/docs/development/macos.html" +"/developing-zed.html" = "/docs/development.html" "/development/development/linux.html" = "/docs/development/linux.html" +"/development/development/macos.html" = "/docs/development/macos.html" "/development/development/windows.html" = "/docs/development/windows.html" # Our custom preprocessor for expanding commands like `{#kb action::ActionName}`, From 2a9a82d757480f5ec95b9fd86d1701ccf4e9922a Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Wed, 16 Jul 2025 10:45:34 -0400 Subject: [PATCH 0037/1056] macos: Add mappings for alt-delete and cmd-delete (#34493) Closes https://github.com/zed-industries/zed/issues/34484 Release Notes: - macos: Add default mappings for `alt-delete` and `cmd-delete` in Terminal (delete word to right; delete to end of line) --- assets/keymaps/default-macos.json | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 7af79bdeea1b461e6b0f6fb665ccc9f8cef2138f..1eece3169929f6289595fd29f903dfe0981eff63 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -1105,7 +1105,9 @@ "ctrl-enter": "assistant::InlineAssist", "ctrl-_": null, // emacs undo // Some nice conveniences - "cmd-backspace": ["terminal::SendText", "\u0015"], + "cmd-backspace": ["terminal::SendText", "\u0015"], // ctrl-u: clear line + "alt-delete": ["terminal::SendText", "\u001bd"], // alt-d: delete word forward + "cmd-delete": ["terminal::SendText", "\u000b"], // ctrl-k: delete to end of line "cmd-right": ["terminal::SendText", "\u0005"], "cmd-left": ["terminal::SendText", "\u0001"], // Terminal.app compatibility From 21b4a2ecdd9cae24fcc20b73c35eee278b51bbe3 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 16 Jul 2025 09:49:16 -0500 Subject: [PATCH 0038/1056] keymap_ui: Infer use key equivalents (#34498) Closes #ISSUE This PR attempts to add workarounds for `use_key_equivalents` in the keymap UI. First of all it makes it so that `use_key_equivalents` is ignored when searching for a binding to replace so that replacing a keybind with `use_key_equivalents` set to true does not result in a new binding. Second, it attempts to infer the value of `use_key_equivalents` off of a base binding when adding a binding by adding an optional `from` parameter to the `KeymapUpdateOperation::Add` variant. Neither workaround will work when the `from` binding for an add or the `target` binding for a replace are not in the user keymap. cc: @Anthony-Eid Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/settings/src/keymap_file.rs | 178 +++++++++++++++++--------- crates/settings/src/settings_json.rs | 25 +++- crates/settings_ui/src/keybindings.rs | 117 +++++++++-------- 3 files changed, 201 insertions(+), 119 deletions(-) diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 470c5faf78d7e7b41d8c4895e471b82b557a5c3a..b61d30e405471ce3d4ab7378f64610a1057ed439 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -10,6 +10,7 @@ use serde::Deserialize; use serde_json::{Value, json}; use std::borrow::Cow; use std::{any::TypeId, fmt::Write, rc::Rc, sync::Arc, sync::LazyLock}; +use util::ResultExt as _; use util::{ asset_str, markdown::{MarkdownEscaped, MarkdownInlineCode, MarkdownString}, @@ -612,19 +613,26 @@ impl KeymapFile { KeybindUpdateOperation::Replace { target_keybind_source: target_source, source, - .. + target, } if target_source != KeybindSource::User => { - operation = KeybindUpdateOperation::Add(source); + operation = KeybindUpdateOperation::Add { + source, + from: Some(target), + }; } // if trying to remove a keybinding that is not user-defined, treat it as creating a binding // that binds it to `zed::NoAction` KeybindUpdateOperation::Remove { - mut target, + target, target_keybind_source, } if target_keybind_source != KeybindSource::User => { - target.action_name = gpui::NoAction.name(); - target.action_arguments.take(); - operation = KeybindUpdateOperation::Add(target); + let mut source = target.clone(); + source.action_name = gpui::NoAction.name(); + source.action_arguments.take(); + operation = KeybindUpdateOperation::Add { + source, + from: Some(target), + }; } _ => {} } @@ -742,7 +750,10 @@ impl KeymapFile { ) .context("Failed to replace keybinding")?; keymap_contents.replace_range(replace_range, &replace_value); - operation = KeybindUpdateOperation::Add(source); + operation = KeybindUpdateOperation::Add { + source, + from: Some(target), + }; } } else { log::warn!( @@ -752,16 +763,28 @@ impl KeymapFile { source.keystrokes, source_action_value, ); - operation = KeybindUpdateOperation::Add(source); + operation = KeybindUpdateOperation::Add { + source, + from: Some(target), + }; } } - if let KeybindUpdateOperation::Add(keybinding) = operation { + if let KeybindUpdateOperation::Add { + source: keybinding, + from, + } = operation + { let mut value = serde_json::Map::with_capacity(4); if let Some(context) = keybinding.context { value.insert("context".to_string(), context.into()); } - if keybinding.use_key_equivalents { + 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)?; + Some(keymap.0[index].use_key_equivalents) + }).unwrap_or(false); + if use_key_equivalents { value.insert("use_key_equivalents".to_string(), true.into()); } @@ -794,9 +817,6 @@ impl KeymapFile { if section_context_parsed != target_context_parsed { continue; } - if section.use_key_equivalents != target.use_key_equivalents { - continue; - } let Some(bindings) = §ion.bindings else { continue; }; @@ -835,19 +855,27 @@ pub enum KeybindUpdateOperation<'a> { target: KeybindUpdateTarget<'a>, target_keybind_source: KeybindSource, }, - Add(KeybindUpdateTarget<'a>), + Add { + source: KeybindUpdateTarget<'a>, + from: Option>, + }, Remove { target: KeybindUpdateTarget<'a>, target_keybind_source: KeybindSource, }, } -#[derive(Debug)] +impl<'a> KeybindUpdateOperation<'a> { + pub fn add(source: KeybindUpdateTarget<'a>) -> Self { + Self::Add { source, from: None } + } +} + +#[derive(Debug, Clone)] pub struct KeybindUpdateTarget<'a> { pub context: Option<&'a str>, pub keystrokes: &'a [Keystroke], pub action_name: &'a str, - pub use_key_equivalents: bool, pub action_arguments: Option<&'a str>, } @@ -933,6 +961,7 @@ impl From for KeyBindingMetaIndex { #[cfg(test)] mod tests { + use gpui::Keystroke; use unindent::Unindent; use crate::{ @@ -955,37 +984,35 @@ mod tests { KeymapFile::parse(json).unwrap(); } + #[track_caller] + fn check_keymap_update( + input: impl ToString, + operation: KeybindUpdateOperation, + expected: impl ToString, + ) { + let result = KeymapFile::update_keybinding(operation, input.to_string(), 4) + .expect("Update succeeded"); + pretty_assertions::assert_eq!(expected.to_string(), result); + } + + #[track_caller] + fn parse_keystrokes(keystrokes: &str) -> Vec { + return keystrokes + .split(' ') + .map(|s| Keystroke::parse(s).expect("Keystrokes valid")) + .collect(); + } + #[test] fn keymap_update() { - use gpui::Keystroke; - zlog::init_test(); - #[track_caller] - fn check_keymap_update( - input: impl ToString, - operation: KeybindUpdateOperation, - expected: impl ToString, - ) { - let result = KeymapFile::update_keybinding(operation, input.to_string(), 4) - .expect("Update succeeded"); - pretty_assertions::assert_eq!(expected.to_string(), result); - } - - #[track_caller] - fn parse_keystrokes(keystrokes: &str) -> Vec { - return keystrokes - .split(' ') - .map(|s| Keystroke::parse(s).expect("Keystrokes valid")) - .collect(); - } check_keymap_update( "[]", - KeybindUpdateOperation::Add(KeybindUpdateTarget { + KeybindUpdateOperation::add(KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, - use_key_equivalents: false, action_arguments: None, }), r#"[ @@ -1007,11 +1034,10 @@ mod tests { } ]"# .unindent(), - KeybindUpdateOperation::Add(KeybindUpdateTarget { + KeybindUpdateOperation::add(KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, - use_key_equivalents: false, action_arguments: None, }), r#"[ @@ -1038,11 +1064,10 @@ mod tests { } ]"# .unindent(), - KeybindUpdateOperation::Add(KeybindUpdateTarget { + KeybindUpdateOperation::add(KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, - use_key_equivalents: false, action_arguments: Some(r#"{"foo": "bar"}"#), }), r#"[ @@ -1074,11 +1099,10 @@ mod tests { } ]"# .unindent(), - KeybindUpdateOperation::Add(KeybindUpdateTarget { + KeybindUpdateOperation::add(KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: Some("Zed > Editor && some_condition = true"), - use_key_equivalents: true, action_arguments: Some(r#"{"foo": "bar"}"#), }), r#"[ @@ -1089,7 +1113,6 @@ mod tests { }, { "context": "Zed > Editor && some_condition = true", - "use_key_equivalents": true, "bindings": { "ctrl-b": [ "zed::SomeOtherAction", @@ -1117,14 +1140,12 @@ mod tests { keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, - use_key_equivalents: false, action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, - use_key_equivalents: false, action_arguments: Some(r#"{"foo": "bar"}"#), }, target_keybind_source: KeybindSource::Base, @@ -1163,14 +1184,12 @@ mod tests { keystrokes: &parse_keystrokes("a"), action_name: "zed::SomeAction", context: None, - use_key_equivalents: false, action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, - use_key_equivalents: false, action_arguments: Some(r#"{"foo": "bar"}"#), }, target_keybind_source: KeybindSource::User, @@ -1204,14 +1223,12 @@ mod tests { keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeNonexistentAction", context: None, - use_key_equivalents: false, action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, - use_key_equivalents: false, action_arguments: None, }, target_keybind_source: KeybindSource::User, @@ -1247,14 +1264,12 @@ mod tests { keystrokes: &parse_keystrokes("ctrl-a"), action_name: "zed::SomeAction", context: None, - use_key_equivalents: false, action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("ctrl-b"), action_name: "zed::SomeOtherAction", context: None, - use_key_equivalents: false, action_arguments: Some(r#"{"foo": "bar"}"#), }, target_keybind_source: KeybindSource::User, @@ -1292,14 +1307,12 @@ mod tests { keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", context: Some("SomeContext"), - use_key_equivalents: false, action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("c"), action_name: "foo::baz", context: Some("SomeOtherContext"), - use_key_equivalents: false, action_arguments: None, }, target_keybind_source: KeybindSource::User, @@ -1336,14 +1349,12 @@ mod tests { keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", context: Some("SomeContext"), - use_key_equivalents: false, action_arguments: None, }, source: KeybindUpdateTarget { keystrokes: &parse_keystrokes("c"), action_name: "foo::baz", context: Some("SomeOtherContext"), - use_key_equivalents: false, action_arguments: None, }, target_keybind_source: KeybindSource::User, @@ -1375,7 +1386,6 @@ mod tests { context: Some("SomeContext"), keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", - use_key_equivalents: false, action_arguments: None, }, target_keybind_source: KeybindSource::User, @@ -1407,7 +1417,6 @@ mod tests { context: Some("SomeContext"), keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", - use_key_equivalents: false, action_arguments: Some("true"), }, target_keybind_source: KeybindSource::User, @@ -1450,7 +1459,6 @@ mod tests { context: Some("SomeContext"), keystrokes: &parse_keystrokes("a"), action_name: "foo::bar", - use_key_equivalents: false, action_arguments: Some("true"), }, target_keybind_source: KeybindSource::User, @@ -1472,4 +1480,54 @@ mod tests { .unindent(), ); } + + #[test] + fn test_append() { + check_keymap_update( + r#"[ + { + "context": "SomeOtherContext", + "use_key_equivalents": true, + "bindings": { + "b": "foo::bar", + } + }, + ]"# + .unindent(), + KeybindUpdateOperation::Add { + source: KeybindUpdateTarget { + context: Some("SomeContext"), + keystrokes: &parse_keystrokes("a"), + action_name: "foo::baz", + action_arguments: Some("true"), + }, + from: Some(KeybindUpdateTarget { + context: Some("SomeOtherContext"), + keystrokes: &parse_keystrokes("b"), + action_name: "foo::bar", + action_arguments: None, + }), + }, + r#"[ + { + "context": "SomeOtherContext", + "use_key_equivalents": true, + "bindings": { + "b": "foo::bar", + } + }, + { + "context": "SomeContext", + "use_key_equivalents": true, + "bindings": { + "a": [ + "foo::baz", + true + ] + } + } + ]"# + .unindent(), + ); + } } diff --git a/crates/settings/src/settings_json.rs b/crates/settings/src/settings_json.rs index 1aed18b44ad46c78299e71314c62ebd17d4955cb..a448eb27375645b63247703affe25f2524164b8b 100644 --- a/crates/settings/src/settings_json.rs +++ b/crates/settings/src/settings_json.rs @@ -437,17 +437,19 @@ pub fn append_top_level_array_value_in_json_text( ); debug_assert_eq!(cursor.node().kind(), "]"); let close_bracket_start = cursor.node().start_byte(); - cursor.goto_previous_sibling(); - while (cursor.node().is_extra() || cursor.node().is_missing()) && cursor.goto_previous_sibling() - { - } + while cursor.goto_previous_sibling() + && (cursor.node().is_extra() || cursor.node().is_missing()) + && !cursor.node().is_error() + {} let mut comma_range = None; let mut prev_item_range = None; - if cursor.node().kind() == "," { + if cursor.node().kind() == "," || is_error_of_kind(&mut cursor, ",") { comma_range = Some(cursor.node().byte_range()); - while cursor.goto_previous_sibling() && cursor.node().is_extra() {} + while cursor.goto_previous_sibling() + && (cursor.node().is_extra() || cursor.node().is_missing()) + {} debug_assert_ne!(cursor.node().kind(), "["); prev_item_range = Some(cursor.node().range()); @@ -514,6 +516,17 @@ pub fn append_top_level_array_value_in_json_text( replace_value.push('\n'); } return Ok((replace_range, replace_value)); + + fn is_error_of_kind(cursor: &mut tree_sitter::TreeCursor<'_>, kind: &str) -> bool { + if cursor.node().kind() != "ERROR" { + return false; + } + + let descendant_index = cursor.descendant_index(); + let res = cursor.goto_first_child() && cursor.node().kind() == kind; + cursor.goto_descendant(descendant_index); + return res; + } } pub fn to_pretty_json( diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 5b2cca92bb3f5c60d2e8386aabd3f41c44c85e32..4526b7fcc8d8c598846dd3a230b53be913e7daa0 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -1,5 +1,5 @@ use std::{ - ops::{Not, Range}, + ops::{Not as _, Range}, sync::Arc, }; @@ -1602,32 +1602,45 @@ impl KeybindingEditorModal { Ok(action_arguments) } - fn save(&mut self, cx: &mut Context) { - let existing_keybind = self.editing_keybind.clone(); - let fs = self.fs.clone(); + fn validate_keystrokes(&self, cx: &App) -> anyhow::Result> { let new_keystrokes = self .keybind_editor .read_with(cx, |editor, _| editor.keystrokes().to_vec()); - if new_keystrokes.is_empty() { - self.set_error(InputError::error("Keystrokes cannot be empty"), cx); - return; - } - let tab_size = cx.global::().json_tab_size(); + anyhow::ensure!(!new_keystrokes.is_empty(), "Keystrokes cannot be empty"); + Ok(new_keystrokes) + } + + fn validate_context(&self, cx: &App) -> anyhow::Result> { let new_context = self .context_editor .read_with(cx, |input, cx| input.editor().read(cx).text(cx)); - let new_context = new_context.is_empty().not().then_some(new_context); - let new_context_err = new_context.as_deref().and_then(|context| { - gpui::KeyBindingContextPredicate::parse(context) - .context("Failed to parse key context") - .err() - }); - if let Some(err) = new_context_err { - // TODO: store and display as separate error - // TODO: also, should be validating on keystroke - self.set_error(InputError::error(err.to_string()), cx); - return; - } + let Some(context) = new_context.is_empty().not().then_some(new_context) else { + return Ok(None); + }; + gpui::KeyBindingContextPredicate::parse(&context).context("Failed to parse key context")?; + + Ok(Some(context)) + } + + fn save(&mut self, cx: &mut Context) { + let existing_keybind = self.editing_keybind.clone(); + let fs = self.fs.clone(); + let tab_size = cx.global::().json_tab_size(); + let new_keystrokes = match self.validate_keystrokes(cx) { + Err(err) => { + self.set_error(InputError::error(err.to_string()), cx); + return; + } + Ok(keystrokes) => keystrokes, + }; + + let new_context = match self.validate_context(cx) { + Err(err) => { + self.set_error(InputError::error(err.to_string()), cx); + return; + } + Ok(context) => context, + }; let new_action_args = match self.validate_action_arguments(cx) { Err(input_err) => { @@ -2064,46 +2077,45 @@ async fn save_keybinding_update( .await .context("Failed to load keymap file")?; - let operation = if !create { - let existing_keystrokes = existing.keystrokes().unwrap_or_default(); - let existing_context = existing - .context - .as_ref() - .and_then(KeybindContextString::local_str); - let existing_args = existing - .action_arguments - .as_ref() - .map(|args| args.text.as_ref()); + let existing_keystrokes = existing.keystrokes().unwrap_or_default(); + let existing_context = existing + .context + .as_ref() + .and_then(KeybindContextString::local_str); + let existing_args = existing + .action_arguments + .as_ref() + .map(|args| args.text.as_ref()); + + let target = settings::KeybindUpdateTarget { + context: existing_context, + keystrokes: existing_keystrokes, + action_name: &existing.action_name, + action_arguments: existing_args, + }; + + let source = settings::KeybindUpdateTarget { + context: new_context, + keystrokes: new_keystrokes, + action_name: &existing.action_name, + action_arguments: new_args, + }; + let operation = if !create { settings::KeybindUpdateOperation::Replace { - target: settings::KeybindUpdateTarget { - context: existing_context, - keystrokes: existing_keystrokes, - action_name: &existing.action_name, - use_key_equivalents: false, - action_arguments: existing_args, - }, + target, target_keybind_source: existing .source .as_ref() .map(|(source, _name)| *source) .unwrap_or(KeybindSource::User), - source: settings::KeybindUpdateTarget { - context: new_context, - keystrokes: new_keystrokes, - action_name: &existing.action_name, - use_key_equivalents: false, - action_arguments: new_args, - }, + source, } } else { - settings::KeybindUpdateOperation::Add(settings::KeybindUpdateTarget { - context: new_context, - keystrokes: new_keystrokes, - action_name: &existing.action_name, - use_key_equivalents: false, - action_arguments: new_args, - }) + settings::KeybindUpdateOperation::Add { + source, + from: Some(target), + } }; let updated_keymap_contents = settings::KeymapFile::update_keybinding(operation, keymap_contents, tab_size) @@ -2137,7 +2149,6 @@ async fn remove_keybinding( .and_then(KeybindContextString::local_str), keystrokes, action_name: &existing.action_name, - use_key_equivalents: false, action_arguments: existing .action_arguments .as_ref() From 2a49f40cf53c23a674d9cd47070a7ad38ebc14be Mon Sep 17 00:00:00 2001 From: Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Date: Wed, 16 Jul 2025 11:49:53 -0300 Subject: [PATCH 0039/1056] docs: Add some improvements to the agent panel page (#34543) Release Notes: - N/A --- docs/src/ai/agent-panel.md | 36 +++++++++++++++++------------------- 1 file changed, 17 insertions(+), 19 deletions(-) diff --git a/docs/src/ai/agent-panel.md b/docs/src/ai/agent-panel.md index 3c04ae5c43f87ee54e96a253300aa20524d6d844..ca35e06e113c401876cc68de1a1cfa83846352c6 100644 --- a/docs/src/ai/agent-panel.md +++ b/docs/src/ai/agent-panel.md @@ -1,18 +1,21 @@ # Agent Panel -The Agent Panel provides you with a way to interact with LLMs. -You can use it for various tasks, such as generating code, asking questions about your code base, and general inquiries such as emails and documentation. +The Agent Panel provides you with a surface to interact with LLMs, enabling various types of tasks, such as generating code, asking questions about your codebase, and general inquiries like emails, documentation, and more. -To open the Agent Panel, use the `agent: new thread` action in [the Command Palette](../getting-started.md#command-palette) or click the ✨ (sparkles) icon in the status bar. +To open it, use the `agent: new thread` action in [the Command Palette](../getting-started.md#command-palette) or click the ✨ (sparkles) icon in the status bar. -If you're using the Agent Panel for the first time, you'll need to [configure at least one LLM provider](./configuration.md). +If you're using the Agent Panel for the first time, you need to have at least one LLM provider configured. +You can do that by: + +1. [subscribing to our Pro plan](https://zed.dev/pricing), so you have access to our hosted models +2. or by [bringing your own API keys](./configuration.md#use-your-own-keys) for your desired provider ## Overview {#overview} After you've configured one or more LLM providers, type at the message editor and hit `enter` to submit your prompt. If you need extra room to type, you can expand the message editor with {#kb agent::ExpandMessageEditor}. -You should start to see the responses stream in with indications of [which tools](./tools.md) the AI is using to fulfill your prompt. +You should start to see the responses stream in with indications of [which tools](./tools.md) the model is using to fulfill your prompt. ### Editing Messages {#editing-messages} @@ -21,13 +24,13 @@ You can click on the card that contains your message and re-submit it with an ad ### Checkpoints {#checkpoints} -Every time the AI performs an edit, you should see a "Restore Checkpoint" button to the top of your message, allowing you to return your codebase to the state it was in prior to that message. +Every time the AI performs an edit, you should see a "Restore Checkpoint" button to the top of your message, allowing you to return your code base to the state it was in prior to that message. The checkpoint button appears even if you interrupt the thread midway through an edit attempt, as this is likely a moment when you've identified that the agent is not heading in the right direction and you want to revert back. ### Navigating History {#navigating-history} -To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the hamburger icon button at the top left of the panel to open the dropdown that shows you the six most recent threads. +To quickly navigate through recently opened threads, use the {#kb agent::ToggleNavigationMenu} binding, when focused on the panel's editor, or click the menu icon button at the top left of the panel to open the dropdown that shows you the six most recent threads. The items in this menu function similarly to tabs, and closing them doesn’t delete the thread; instead, it simply removes them from the recent list. @@ -39,6 +42,8 @@ Zed is built with collaboration natively integrated. This approach extends to collaboration with AI as well. To follow the agent reading through your codebase and performing edits, click on the "crosshair" icon button at the bottom left of the panel. +You can also do that with the keyboard by pressing the `cmd`/`ctrl` modifier with `enter` when submitting a message. + ### Get Notified {#get-notified} If you send a prompt to the Agent and then move elsewhere, thus putting Zed in the background, you can be notified of whether its response is finished either via: @@ -63,12 +68,12 @@ So, if your active tab had edits made by the AI, you'll see diffs with the same ## Adding Context {#adding-context} -Although Zed's agent is very efficient at reading through your codebase to autonomously pick up relevant files, directories, and other context, manually adding context is still encouraged as a way to speed up and improve the AI's response quality. +Although Zed's agent is very efficient at reading through your code base to autonomously pick up relevant files, directories, and other context, manually adding context is still encouraged as a way to speed up and improve the AI's response quality. -If you have a tab open when opening the Agent Panel, that tab appears as a suggested context in form of a dashed button. +If you have a tab open while using the Agent Panel, that tab appears as a suggested context in form of a dashed button. You can also add other forms of context by either mentioning them with `@` or hitting the `+` icon button. -You can even add previous threads as context by mentioning them with `@thread`, or by selecting the "New From Summary" option from the top-right menu to continue a longer conversation, keeping it within the context window. +You can even add previous threads as context by mentioning them with `@thread`, or by selecting the "New From Summary" option from the `+` menu to continue a longer conversation, keeping it within the context window. Pasting images as context is also supported by the Agent Panel. @@ -141,24 +146,17 @@ You can remove and edit responses from the LLM, swap roles, and include more con For users who have been with us for some time, you'll notice that text threads are our original assistant panel—users love it for the control it offers. We do not plan to deprecate text threads, but it should be noted that if you want the AI to write to your code base autonomously, that's only available in the newer, and now default, "Threads". -### Text Thread History {#text-thread-history} - -Content from text thread are saved to your file system. -Visit [the dedicated docs](./text-threads.md#history) for more info. - ## Errors and Debugging {#errors-and-debugging} In case of any error or strange LLM response behavior, the best way to help the Zed team debug is by reaching for the `agent: open thread as markdown` action and attaching that data as part of your issue on GitHub. -This action exposes the entire thread in the form of Markdown and allows for deeper understanding of what each tool call was doing. - You can also open threads as Markdown by clicking on the file icon button, to the right of the thumbs down button, when focused on the panel's editor. ## Feedback {#feedback} -Every change we make to Zed's system prompt and tool set, needs to be backed by an eval with good scores. +Every change we make to Zed's system prompt and tool set, needs to be backed by a thorough eval with good scores. -Every time the LLM performs a weird change or investigates a certain topic in your codebase completely incorrectly, it's an indication that there's an improvement opportunity. +Every time the LLM performs a weird change or investigates a certain topic in your code base incorrectly, it's an indication that there's an improvement opportunity. > Note that rating responses will send your data related to that response to Zed's servers. > See [AI Improvement](./ai-improvement.md) and [Privacy and Security](./privacy-and-security.md) for more information about Zed's approach to AI improvement, privacy, and security. From b0e0485b32e34b8416c010a6f0c86ed4e46759a0 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Wed, 16 Jul 2025 10:50:54 -0400 Subject: [PATCH 0040/1056] docs: Add redirects for language pages (#34544) This PR adds some more docs redirects for language pages. Release Notes: - N/A --- docs/book.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/book.toml b/docs/book.toml index 70e294c014fc4a89a6d1a1ec3c2b8a0eb4be3637..f5d186f377698e21a928f2f978f87f895da8944d 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -57,6 +57,12 @@ enable = false "/adding-new-languages.html" = "/docs/extensions/languages.html" "/elixir.html" = "/docs/languages/elixir.html" "/javascript.html" = "/docs/languages/javascript.html" +"/languages/languages/html.html" = "/docs/languages/html.html" +"/languages/languages/javascript.html" = "/docs/languages/javascript.html" +"/languages/languages/makefile.html" = "/docs/languages/makefile.html" +"/languages/languages/nim.html" = "/docs/languages/nim.html" +"/languages/languages/ruby.html" = "/docs/languages/ruby.html" +"/languages/languages/scala.html" = "/docs/languages/scala.html" "/python.html" = "/docs/languages/python.html" "/ruby.html" = "/docs/languages/ruby.html" From 8ee5bf2c38528770620d33ead1d1042c6758287b Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Wed, 16 Jul 2025 21:14:08 +0530 Subject: [PATCH 0041/1056] open_router: Fix tool_choice getting serialized to null (#34532) Closes #34314 This PR resolves an issue where serde(untagged) caused Rust None values to serialize as null, which OpenRouter's Mistral API (when tool_choice is present) incorrectly interprets as a defined value, leading to a 400 error. By replacing serde(untagged) with serde(snake_case), None values are now correctly omitted from the serialized JSON, fixing the problem. P.S. A separate PR will address serde(untagged) usage for other providers, as null is not expected for them either. Release Notes: - Fix ToolChoice getting serialized to null on OpenRouter --- crates/open_router/src/open_router.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/open_router/src/open_router.rs b/crates/open_router/src/open_router.rs index 4128426a7fb429337028b03c12e90c6395651f0e..3e6e406d9842d5996f2e866d534094ded23fd61c 100644 --- a/crates/open_router/src/open_router.rs +++ b/crates/open_router/src/open_router.rs @@ -153,11 +153,12 @@ pub struct RequestUsage { } #[derive(Debug, Serialize, Deserialize)] -#[serde(untagged)] +#[serde(rename_all = "lowercase")] pub enum ToolChoice { Auto, Required, None, + #[serde(untagged)] Other(ToolDefinition), } From e339566dab4d64431a57bc828615cef581c707fe Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Wed, 16 Jul 2025 18:46:13 +0300 Subject: [PATCH 0042/1056] agent: Limit the size of patches generated from user edits (#34548) Gradually remove details from a patch to keep it within the size limit. This helps avoid using too much context when the user pastes large files, generates files, or just makes many changes between agent notifications. Release Notes: - N/A --- Cargo.lock | 1 + crates/assistant_tools/Cargo.toml | 1 + .../src/project_notifications_tool.rs | 145 +++++++++++++++++- 3 files changed, 145 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a2e9fc26ca417e9cf771cc5dd5e1a2e92d4e53e7..395087168808d53ac5e508d7805e61bbae4cc932 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -779,6 +779,7 @@ dependencies = [ "collections", "component", "derive_more 0.99.19", + "diffy", "editor", "feature_flags", "fs", diff --git a/crates/assistant_tools/Cargo.toml b/crates/assistant_tools/Cargo.toml index 2b8958feb1bddc719bcd085058cdb5162fd777b1..e234b62b142c368ab8383df4eeff8848704a5b98 100644 --- a/crates/assistant_tools/Cargo.toml +++ b/crates/assistant_tools/Cargo.toml @@ -63,6 +63,7 @@ which.workspace = true workspace-hack.workspace = true workspace.workspace = true zed_llm_client.workspace = true +diffy = "0.4.2" [dev-dependencies] lsp = { workspace = true, features = ["test-support"] } diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs index 1b926bb4469593689c4bdf797b055d71c60fca35..ec315d9ab15337ea78784df84e949090d2e0f1da 100644 --- a/crates/assistant_tools/src/project_notifications_tool.rs +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -6,7 +6,7 @@ use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchem use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use std::sync::Arc; +use std::{fmt::Write, sync::Arc}; use ui::IconName; #[derive(Debug, Serialize, Deserialize, JsonSchema)] @@ -59,7 +59,9 @@ impl Tool for ProjectNotificationsTool { // NOTE: Changes to this prompt require a symmetric update in the LLM Worker const HEADER: &str = include_str!("./project_notifications_tool/prompt_header.txt"); - result(&format!("{HEADER}\n\n```diff\n{user_edits_diff}\n```\n").replace("\r\n", "\n")) + const MAX_BYTES: usize = 8000; + let diff = fit_patch_to_size(&user_edits_diff, MAX_BYTES); + result(&format!("{HEADER}\n\n```diff\n{diff}\n```\n").replace("\r\n", "\n")) } } @@ -67,11 +69,95 @@ fn result(response: &str) -> ToolResult { Task::ready(Ok(response.to_string().into())).into() } +/// Make sure that the patch fits into the size limit (in bytes). +/// Compress the patch by omitting some parts if needed. +/// Unified diff format is assumed. +fn fit_patch_to_size(patch: &str, max_size: usize) -> String { + if patch.len() <= max_size { + return patch.to_string(); + } + + // Compression level 1: remove context lines in diff bodies, but + // leave the counts and positions of inserted/deleted lines + let mut current_size = patch.len(); + let mut file_patches = split_patch(&patch); + file_patches.sort_by_key(|patch| patch.len()); + let compressed_patches = file_patches + .iter() + .rev() + .map(|patch| { + if current_size > max_size { + let compressed = compress_patch(patch).unwrap_or_else(|_| patch.to_string()); + current_size -= patch.len() - compressed.len(); + compressed + } else { + patch.to_string() + } + }) + .collect::>(); + + if current_size <= max_size { + return compressed_patches.join("\n\n"); + } + + // Compression level 2: list paths of the changed files only + let filenames = file_patches + .iter() + .map(|patch| { + let patch = diffy::Patch::from_str(patch).unwrap(); + let path = patch + .modified() + .and_then(|path| path.strip_prefix("b/")) + .unwrap_or_default(); + format!("- {path}\n") + }) + .collect::>(); + + filenames.join("") +} + +/// Split a potentially multi-file patch into multiple single-file patches +fn split_patch(patch: &str) -> Vec { + let mut result = Vec::new(); + let mut current_patch = String::new(); + + for line in patch.lines() { + if line.starts_with("---") && !current_patch.is_empty() { + result.push(current_patch.trim_end_matches('\n').into()); + current_patch = String::new(); + } + current_patch.push_str(line); + current_patch.push('\n'); + } + + if !current_patch.is_empty() { + result.push(current_patch.trim_end_matches('\n').into()); + } + + result +} + +fn compress_patch(patch: &str) -> anyhow::Result { + let patch = diffy::Patch::from_str(patch)?; + let mut out = String::new(); + + writeln!(out, "--- {}", patch.original().unwrap_or("a"))?; + writeln!(out, "+++ {}", patch.modified().unwrap_or("b"))?; + + for hunk in patch.hunks() { + writeln!(out, "@@ -{} +{} @@", hunk.old_range(), hunk.new_range())?; + writeln!(out, "[...skipped...]")?; + } + + Ok(out) +} + #[cfg(test)] mod tests { use super::*; use assistant_tool::ToolResultContent; use gpui::{AppContext, TestAppContext}; + use indoc::indoc; use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider}; use project::{FakeFs, Project}; use serde_json::json; @@ -206,6 +292,61 @@ mod tests { ); } + #[test] + fn test_patch_compression() { + // Given a patch that doesn't fit into the size budget + let patch = indoc! {" + --- a/dir/test.txt + +++ b/dir/test.txt + @@ -1,3 +1,3 @@ + line 1 + -line 2 + +CHANGED + line 3 + @@ -10,2 +10,2 @@ + line 10 + -line 11 + +line eleven + + + --- a/dir/another.txt + +++ b/dir/another.txt + @@ -100,1 +1,1 @@ + -before + +after + "}; + + // When the size deficit can be compensated by dropping the body, + // then the body should be trimmed for larger files first + let limit = patch.len() - 10; + let compressed = fit_patch_to_size(patch, limit); + let expected = indoc! {" + --- a/dir/test.txt + +++ b/dir/test.txt + @@ -1,3 +1,3 @@ + [...skipped...] + @@ -10,2 +10,2 @@ + [...skipped...] + + + --- a/dir/another.txt + +++ b/dir/another.txt + @@ -100,1 +1,1 @@ + -before + +after"}; + assert_eq!(compressed, expected); + + // When the size deficit is too large, then only file paths + // should be returned + let limit = 10; + let compressed = fit_patch_to_size(patch, limit); + let expected = indoc! {" + - dir/another.txt + - dir/test.txt + "}; + assert_eq!(compressed, expected); + } + fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); From 9ab3d55211daafe71f9141a9c1c542f5cec23f23 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 16 Jul 2025 12:14:09 -0400 Subject: [PATCH 0043/1056] Add exact matching option to keymap editor search (#34497) We know have the ability to filter matches in the keymap editor search by exact keystroke matches. This allows user's to have the same behavior as vscode when they toggle all actions with the same bindings We also fixed a bug where conflicts weren't counted correctly when saving a keymapping. This cause issues where warnings wouldn't appear when they were supposed to. Release Notes: - N/A --------- Co-authored-by: Ben Kunkle --- assets/icons/equal.svg | 1 + crates/icons/src/icons.rs | 1 + crates/settings_ui/src/keybindings.rs | 201 +++++++++++++++++++------- 3 files changed, 150 insertions(+), 53 deletions(-) create mode 100644 assets/icons/equal.svg diff --git a/assets/icons/equal.svg b/assets/icons/equal.svg new file mode 100644 index 0000000000000000000000000000000000000000..9b3a151a12fc3dea5f1eb295bf299e6360846ed2 --- /dev/null +++ b/assets/icons/equal.svg @@ -0,0 +1 @@ + diff --git a/crates/icons/src/icons.rs b/crates/icons/src/icons.rs index b2ec7684355c27280ea7d4a056bfb30ff31ea79b..b29a8b78e679907d1077e015f3aae2528511267b 100644 --- a/crates/icons/src/icons.rs +++ b/crates/icons/src/icons.rs @@ -107,6 +107,7 @@ pub enum IconName { Ellipsis, EllipsisVertical, Envelope, + Equal, Eraser, Escape, Exit, diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 4526b7fcc8d8c598846dd3a230b53be913e7daa0..c83a4c2423a447129eaeebd9035f02363b6e2c1c 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -66,6 +66,8 @@ actions!( ToggleConflictFilter, /// Toggle Keystroke search ToggleKeystrokeSearch, + /// Toggles exact matching for keystroke search + ToggleExactKeystrokeMatching, ] ); @@ -176,14 +178,16 @@ impl KeymapEventChannel { enum SearchMode { #[default] Normal, - KeyStroke, + KeyStroke { + exact_match: bool, + }, } impl SearchMode { fn invert(&self) -> Self { match self { - SearchMode::Normal => SearchMode::KeyStroke, - SearchMode::KeyStroke => SearchMode::Normal, + SearchMode::Normal => SearchMode::KeyStroke { exact_match: false }, + SearchMode::KeyStroke { .. } => SearchMode::Normal, } } } @@ -204,7 +208,11 @@ impl FilterState { } } -type ActionMapping = (SharedString, Option); +#[derive(Debug, Default, PartialEq, Eq, Clone, Hash)] +struct ActionMapping { + keystroke_text: SharedString, + context: Option, +} #[derive(Default)] struct ConflictState { @@ -257,6 +265,12 @@ impl ConflictState { }) } + fn will_conflict(&self, action_mapping: ActionMapping) -> Option> { + self.action_keybind_mapping + .get(&action_mapping) + .and_then(|indices| indices.is_empty().not().then_some(indices.clone())) + } + fn has_conflict(&self, candidate_idx: &usize) -> bool { self.conflicts.contains(candidate_idx) } @@ -375,7 +389,7 @@ impl KeymapEditor { fn current_keystroke_query(&self, cx: &App) -> Vec { match self.search_mode { - SearchMode::KeyStroke => self + SearchMode::KeyStroke { .. } => self .keystroke_editor .read(cx) .keystrokes() @@ -432,17 +446,27 @@ impl KeymapEditor { } match this.search_mode { - SearchMode::KeyStroke => { + SearchMode::KeyStroke { exact_match } => { matches.retain(|item| { this.keybindings[item.candidate_id] .keystrokes() .is_some_and(|keystrokes| { - keystroke_query.iter().all(|key| { - keystrokes.iter().any(|keystroke| { - keystroke.key == key.key - && keystroke.modifiers == key.modifiers + if exact_match { + keystroke_query.len() == keystrokes.len() + && keystroke_query.iter().zip(keystrokes).all( + |(query, keystroke)| { + query.key == keystroke.key + && query.modifiers == keystroke.modifiers + }, + ) + } else { + keystroke_query.iter().all(|key| { + keystrokes.iter().any(|keystroke| { + keystroke.key == key.key + && keystroke.modifiers == key.modifiers + }) }) - }) + } }) }); } @@ -699,7 +723,12 @@ impl KeymapEditor { window: &mut Window, cx: &mut Context, ) { + let weak = cx.weak_entity(); self.context_menu = self.selected_binding().map(|selected_binding| { + let key_strokes = selected_binding + .keystrokes() + .map(Vec::from) + .unwrap_or_default(); let selected_binding_has_no_context = selected_binding .context .as_ref() @@ -727,6 +756,22 @@ impl KeymapEditor { "Copy Context", Box::new(CopyContext), ) + .entry("Show matching keybindings", None, { + let weak = weak.clone(); + let key_strokes = key_strokes.clone(); + + move |_, cx| { + weak.update(cx, |this, cx| { + this.filter_state = FilterState::All; + this.search_mode = SearchMode::KeyStroke { exact_match: true }; + + this.keystroke_editor.update(cx, |editor, cx| { + editor.set_keystrokes(key_strokes.clone(), cx); + }); + }) + .ok(); + } + }) }); let context_menu_handle = context_menu.focus_handle(cx); @@ -943,17 +988,32 @@ impl KeymapEditor { // Update the keystroke editor to turn the `search` bool on self.keystroke_editor.update(cx, |keystroke_editor, cx| { - keystroke_editor.set_search_mode(self.search_mode == SearchMode::KeyStroke); + keystroke_editor + .set_search_mode(matches!(self.search_mode, SearchMode::KeyStroke { .. })); cx.notify(); }); match self.search_mode { - SearchMode::KeyStroke => { + SearchMode::KeyStroke { .. } => { window.focus(&self.keystroke_editor.read(cx).recording_focus_handle(cx)); } SearchMode::Normal => {} } } + + fn toggle_exact_keystroke_matching( + &mut self, + _: &ToggleExactKeystrokeMatching, + _: &mut Window, + cx: &mut Context, + ) { + let SearchMode::KeyStroke { exact_match } = &mut self.search_mode else { + return; + }; + + *exact_match = !(*exact_match); + self.on_query_changed(cx); + } } #[derive(Clone)] @@ -970,13 +1030,14 @@ struct ProcessedKeybinding { impl ProcessedKeybinding { fn get_action_mapping(&self) -> ActionMapping { - ( - self.keystroke_text.clone(), - self.context + ActionMapping { + keystroke_text: self.keystroke_text.clone(), + context: self + .context .as_ref() .and_then(|context| context.local()) .cloned(), - ) + } } fn keystrokes(&self) -> Option<&[Keystroke]> { @@ -1061,6 +1122,7 @@ impl Render for KeymapEditor { .on_action(cx.listener(Self::copy_context_to_clipboard)) .on_action(cx.listener(Self::toggle_conflict_filter)) .on_action(cx.listener(Self::toggle_keystroke_search)) + .on_action(cx.listener(Self::toggle_exact_keystroke_matching)) .size_full() .p_2() .gap_1() @@ -1103,7 +1165,10 @@ impl Render for KeymapEditor { cx, ) }) - .toggle_state(matches!(self.search_mode, SearchMode::KeyStroke)) + .toggle_state(matches!( + self.search_mode, + SearchMode::KeyStroke { .. } + )) .on_click(|_, window, cx| { window.dispatch_action(ToggleKeystrokeSearch.boxed_clone(), cx); }), @@ -1141,19 +1206,43 @@ impl Render for KeymapEditor { ) }), ) - .when(matches!(self.search_mode, SearchMode::KeyStroke), |this| { - this.child( - div() - .map(|this| { - if self.keybinding_conflict_state.any_conflicts() { - this.pr(rems_from_px(54.)) - } else { - this.pr_7() - } - }) - .child(self.keystroke_editor.clone()), - ) - }), + .when_some( + match self.search_mode { + SearchMode::Normal => None, + SearchMode::KeyStroke { exact_match } => Some(exact_match), + }, + |this, exact_match| { + this.child( + h_flex() + .map(|this| { + if self.keybinding_conflict_state.any_conflicts() { + this.pr(rems_from_px(54.)) + } else { + this.pr_7() + } + }) + .child(self.keystroke_editor.clone()) + .child( + div().p_1().child( + IconButton::new( + "keystrokes-exact-match", + IconName::Equal, + ) + .shape(IconButtonShape::Square) + .toggle_state(exact_match) + .on_click( + cx.listener(|_, _, window, cx| { + window.dispatch_action( + ToggleExactKeystrokeMatching.boxed_clone(), + cx, + ); + }), + ), + ), + ), + ) + }, + ), ) .child( Table::new() @@ -1650,20 +1739,23 @@ impl KeybindingEditorModal { Ok(input) => input, }; - let action_mapping: ActionMapping = ( - ui::text_for_keystrokes(&new_keystrokes, cx).into(), - new_context - .as_ref() - .map(Into::into) - .or_else(|| existing_keybind.get_action_mapping().1), - ); + let action_mapping = ActionMapping { + keystroke_text: ui::text_for_keystrokes(&new_keystrokes, cx).into(), + context: new_context.as_ref().map(Into::into), + }; - if let Some(conflicting_indices) = self - .keymap_editor - .read(cx) - .keybinding_conflict_state - .conflicting_indices_for_mapping(action_mapping, self.editing_keybind_idx) - { + let conflicting_indices = if self.creating { + self.keymap_editor + .read(cx) + .keybinding_conflict_state + .will_conflict(action_mapping) + } else { + self.keymap_editor + .read(cx) + .keybinding_conflict_state + .conflicting_indices_for_mapping(action_mapping, self.editing_keybind_idx) + }; + if let Some(conflicting_indices) = conflicting_indices { let first_conflicting_index = conflicting_indices[0]; let conflicting_action_name = self .keymap_editor @@ -1739,10 +1831,11 @@ impl KeybindingEditorModal { .log_err(); } else { this.update(cx, |this, cx| { - let action_mapping = ( - ui::text_for_keystrokes(new_keystrokes.as_slice(), cx).into(), - new_context.map(SharedString::from), - ); + let action_mapping = ActionMapping { + keystroke_text: ui::text_for_keystrokes(new_keystrokes.as_slice(), cx) + .into(), + context: new_context.map(SharedString::from), + }; this.keymap_editor.update(cx, |keymap, cx| { keymap.previous_edit = Some(PreviousEdit::Keybinding { @@ -2221,6 +2314,11 @@ impl KeystrokeInput { } } + fn set_keystrokes(&mut self, keystrokes: Vec, cx: &mut Context) { + self.keystrokes = keystrokes; + self.keystrokes_changed(cx); + } + fn dummy(modifiers: Modifiers) -> Keystroke { return Keystroke { modifiers, @@ -2438,14 +2536,11 @@ impl KeystrokeInput { fn clear_keystrokes( &mut self, _: &ClearKeystrokes, - window: &mut Window, + _window: &mut Window, cx: &mut Context, ) { - if !self.outer_focus_handle.is_focused(window) { - return; - } self.keystrokes.clear(); - cx.notify(); + self.keystrokes_changed(cx); } } From 313f5968ebc26ff294f04d1714f0ac6fa2a0fb15 Mon Sep 17 00:00:00 2001 From: Adam Date: Wed, 16 Jul 2025 18:32:58 +0200 Subject: [PATCH 0044/1056] Improve the `read_file` tool prompt for long files (#34542) Closes [#ISSUE](https://github.com/zed-industries/zed/issues/31780) Release Notes: - Enhanced `read_file` tool call result message for long files. --- crates/assistant_tools/src/read_file_tool.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/assistant_tools/src/read_file_tool.rs b/crates/assistant_tools/src/read_file_tool.rs index 6bbc2fc0897fa92d676ab92392dbadac0447be33..dc504e2dc4adf5dbb155f03f9c92320fdedc15ae 100644 --- a/crates/assistant_tools/src/read_file_tool.rs +++ b/crates/assistant_tools/src/read_file_tool.rs @@ -285,7 +285,10 @@ impl Tool for ReadFileTool { Using the line numbers in this outline, you can call this tool again while specifying the start_line and end_line fields to see the - implementations of symbols in the outline." + implementations of symbols in the outline. + + Alternatively, you can fall back to the `grep` tool (if available) + to search the file for specific content." } .into()) } From 58807f0dd2e54a623c82a078023b04bd54ad265b Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Wed, 16 Jul 2025 12:00:47 -0500 Subject: [PATCH 0045/1056] keymap_ui: Create language for Zed keybind context (#34558) Closes #ISSUE Creates a new language in the languages crate for the DSL used in Zed keybinding context. Previously, keybind context was highlighted as Rust in the keymap UI due to the expression syntax of Rust matching that of the context DSL, however, this had the side effect of highlighting upper case contexts (e.g. `Editor`) however Rust types would be highlighted based on the theme. By extracting only the necessary pieces of the Rust language `highlights.scm`, `brackets.scm`, and `config.toml`, and continuing to use the Rust grammar, we get a better result across different themes Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/languages/src/lib.rs | 4 ++ .../src/zed-keybind-context/brackets.scm | 1 + .../src/zed-keybind-context/config.toml | 6 +++ .../src/zed-keybind-context/highlights.scm | 23 ++++++++++ crates/settings_ui/src/keybindings.rs | 43 +++++++++++++------ crates/ui_input/src/ui_input.rs | 1 + 6 files changed, 65 insertions(+), 13 deletions(-) create mode 100644 crates/languages/src/zed-keybind-context/brackets.scm create mode 100644 crates/languages/src/zed-keybind-context/config.toml create mode 100644 crates/languages/src/zed-keybind-context/highlights.scm diff --git a/crates/languages/src/lib.rs b/crates/languages/src/lib.rs index 3db015a24182ff9f210958558c868da9e7168be6..431c05108184519bc110f03a801d224a3b4077d7 100644 --- a/crates/languages/src/lib.rs +++ b/crates/languages/src/lib.rs @@ -212,6 +212,10 @@ pub fn init(languages: Arc, node: NodeRuntime, cx: &mut App) { name: "gitcommit", ..Default::default() }, + LanguageInfo { + name: "zed-keybind-context", + ..Default::default() + }, ]; for registration in built_in_languages { diff --git a/crates/languages/src/zed-keybind-context/brackets.scm b/crates/languages/src/zed-keybind-context/brackets.scm new file mode 100644 index 0000000000000000000000000000000000000000..d086b2e98df0837208a13f6c6f79db84c204fb99 --- /dev/null +++ b/crates/languages/src/zed-keybind-context/brackets.scm @@ -0,0 +1 @@ +("(" @open ")" @close) diff --git a/crates/languages/src/zed-keybind-context/config.toml b/crates/languages/src/zed-keybind-context/config.toml new file mode 100644 index 0000000000000000000000000000000000000000..a999c70f6679843d07521c75a6a14bef26af67bb --- /dev/null +++ b/crates/languages/src/zed-keybind-context/config.toml @@ -0,0 +1,6 @@ +name = "Zed Keybind Context" +grammar = "rust" +autoclose_before = ")" +brackets = [ + { start = "(", end = ")", close = true, newline = false }, +] diff --git a/crates/languages/src/zed-keybind-context/highlights.scm b/crates/languages/src/zed-keybind-context/highlights.scm new file mode 100644 index 0000000000000000000000000000000000000000..9c5ec58eaeb7084bf79f31b280197b57bfe64b54 --- /dev/null +++ b/crates/languages/src/zed-keybind-context/highlights.scm @@ -0,0 +1,23 @@ +(identifier) @variable + +[ + "(" + ")" +] @punctuation.bracket + +[ + (integer_literal) + (float_literal) +] @number + +(boolean_literal) @boolean + +[ + "!=" + "==" + "=>" + ">" + "&&" + "||" + "!" +] @operator diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index c83a4c2423a447129eaeebd9035f02363b6e2c1c..2bfa6f820e9d63f3e0c9425b2caf3d7e432395b5 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -505,7 +505,7 @@ impl KeymapEditor { fn process_bindings( json_language: Arc, - rust_language: Arc, + zed_keybind_context_language: Arc, cx: &mut App, ) -> (Vec, Vec) { let key_bindings_ptr = cx.key_bindings(); @@ -536,7 +536,10 @@ impl KeymapEditor { let context = key_binding .predicate() .map(|predicate| { - KeybindContextString::Local(predicate.to_string().into(), rust_language.clone()) + KeybindContextString::Local( + predicate.to_string().into(), + zed_keybind_context_language.clone(), + ) }) .unwrap_or(KeybindContextString::Global); @@ -588,11 +591,12 @@ impl KeymapEditor { let workspace = self.workspace.clone(); cx.spawn(async move |this, cx| { let json_language = load_json_language(workspace.clone(), cx).await; - let rust_language = load_rust_language(workspace.clone(), cx).await; + let zed_keybind_context_language = + load_keybind_context_language(workspace.clone(), cx).await; let (action_query, keystroke_query) = this.update(cx, |this, cx| { let (key_bindings, string_match_candidates) = - Self::process_bindings(json_language, rust_language, cx); + Self::process_bindings(json_language, zed_keybind_context_language, cx); this.keybinding_conflict_state = ConflictState::new(&key_bindings); @@ -1590,13 +1594,20 @@ impl KeybindingEditorModal { } let editor_entity = input.editor().clone(); + let workspace = workspace.clone(); cx.spawn(async move |_input_handle, cx| { let contexts = cx .background_spawn(async { collect_contexts_from_assets() }) .await; + let language = load_keybind_context_language(workspace, cx).await; editor_entity - .update(cx, |editor, _cx| { + .update(cx, |editor, cx| { + if let Some(buffer) = editor.buffer().read(cx).as_singleton() { + buffer.update(cx, |buffer, cx| { + buffer.set_language(Some(language), cx); + }); + } editor.set_completion_provider(Some(std::rc::Rc::new( KeyContextCompletionProvider { contexts }, ))); @@ -2131,25 +2142,31 @@ async fn load_json_language(workspace: WeakEntity, cx: &mut AsyncApp) }); } -async fn load_rust_language(workspace: WeakEntity, cx: &mut AsyncApp) -> Arc { - let rust_language_task = workspace +async fn load_keybind_context_language( + workspace: WeakEntity, + cx: &mut AsyncApp, +) -> Arc { + let language_task = workspace .read_with(cx, |workspace, cx| { workspace .project() .read(cx) .languages() - .language_for_name("Rust") + .language_for_name("Zed Keybind Context") }) - .context("Failed to load Rust language") + .context("Failed to load Zed Keybind Context language") .log_err(); - let rust_language = match rust_language_task { - Some(task) => task.await.context("Failed to load Rust language").log_err(), + let language = match language_task { + Some(task) => task + .await + .context("Failed to load Zed Keybind Context language") + .log_err(), None => None, }; - return rust_language.unwrap_or_else(|| { + return language.unwrap_or_else(|| { Arc::new(Language::new( LanguageConfig { - name: "Rust".into(), + name: "Zed Keybind Context".into(), ..Default::default() }, Some(tree_sitter_rust::LANGUAGE.into()), diff --git a/crates/ui_input/src/ui_input.rs b/crates/ui_input/src/ui_input.rs index ca2dea36df00ecb5f80fd535e98b80c1f0502141..18aa732e8153c15a064d74c88dfdb03d20bffedc 100644 --- a/crates/ui_input/src/ui_input.rs +++ b/crates/ui_input/src/ui_input.rs @@ -135,6 +135,7 @@ impl Render for SingleLineInput { let editor_style = EditorStyle { background: theme_color.ghost_element_background, local_player: cx.theme().players().local(), + syntax: cx.theme().syntax().clone(), text: text_style, ..Default::default() }; From dc8d0868ecc19f3a4436a8907206531406dbafaa Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Wed, 16 Jul 2025 19:24:34 +0200 Subject: [PATCH 0046/1056] project: Fix up documentation for Path Trie and add a test for having multiple present nodes (#34560) cc @cole-miller I was worried with https://github.com/zed-industries/zed/pull/34460#discussion_r2210814806 that PathTrie would not be able to support nested .git repositories, but it seems fine. Release Notes: - N/A --- crates/project/src/manifest_tree/path_trie.rs | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/crates/project/src/manifest_tree/path_trie.rs b/crates/project/src/manifest_tree/path_trie.rs index 0f7575324b040bc951db730ee97f7a08350d571f..1a0736765a43b9e1365334de95eacbe9dbf64382 100644 --- a/crates/project/src/manifest_tree/path_trie.rs +++ b/crates/project/src/manifest_tree/path_trie.rs @@ -6,7 +6,7 @@ use std::{ sync::Arc, }; -/// [RootPathTrie] is a workhorse of [super::ManifestTree]. It is responsible for determining the closest known project root for a given path. +/// [RootPathTrie] is a workhorse of [super::ManifestTree]. It is responsible for determining the closest known entry for a given path. /// It also determines how much of a given path is unexplored, thus letting callers fill in that gap if needed. /// Conceptually, it allows one to annotate Worktree entries with arbitrary extra metadata and run closest-ancestor searches. /// @@ -20,19 +20,16 @@ pub(super) struct RootPathTrie