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 53dc24a21a..ad32d2dd34 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 2db1d4a20c..ce04b621cb 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 0a5a1a01fe..de808ff263 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 7b536a2d24..242694d963 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 2cc3ca76d1..73ab2b8167 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 b2e0a68205..ba64ba0eed 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 4eea5e7e1f..7ac6d101f3 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 ad32d2dd34..4248f910ee 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 fd626cf2d6..e4078393ee 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 8f4aa12354..e1d360cd97 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 4d3f6823b3..19afd49848 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 | 331 ++++++++++++++++++-------- crates/zed/src/zed.rs | 1 + 4 files changed, 248 insertions(+), 102 deletions(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 02d08347fe..562afea854 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 ecb8648978..fa9fce4555 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 a5008e17a0..bf9e72297f 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,74 +1938,143 @@ 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( &mut self, event: &ModifiersChangedEvent, _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 - { - cx.emit(()); - self.keystrokes.push(Keystroke { - modifiers: event.keystroke.modifiers, - key: "".to_string(), - key_char: None, - }); + 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() + { + 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 dc094a6c12..cc3906af4d 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 bf9e72297f..f246e9498c 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 f246e9498c..46a428038c 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 c3b70d7d4f..98dd738765 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 72470c0a7d..acd9c23c97 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 06fb52cdb3..e77be3398c 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 f871416f39..be6a69c23b 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 562afea854..9ca7d8589a 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 fa9fce4555..7af79bdeea 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 46a428038c..3567439d2b 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))), ) - }), + .child(Button::new("save-btn", "Save").on_click(cx.listener( + |this, _event, _window, cx| { + this.save(cx); + }, + ))), + ), ), - ) - .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); - }, - ))), - ), - ), - ) + ) + } +} + +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 e1d360cd97..b6fdcd6fa5 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 6a20ad8f83..8e66e526de 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); + _ => { + 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, + ); } } - ApiInternalServerError { .. } - | ApiReadResponseError { .. } - | HttpSend { .. } => { - retry_scheduled = thread.handle_retryable_error( - &completion_error, - model.clone(), - intent, - window, - cx, - ); - if !retry_scheduled { - emit_generic_error(error, 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(), - ); + fn get_retry_strategy(error: &LanguageModelCompletionError) -> Option { + use LanguageModelCompletionError::*; - // 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; - - 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,11 +4336,14 @@ 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"); + assert_eq!( + retry_state.max_attempts, 1, + "Internal server errors should only retry once" + ); }); // Advance clock for first retry - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS)); + cx.executor().advance_clock(BASE_RETRY_DELAY); cx.run_until_parked(); // Should have scheduled second retry - count retry messages @@ -4395,93 +4363,25 @@ fn main() {{ }) .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" - ); - }); - - // Advance clock for second retry (exponential backoff) - cx.executor() - .advance_clock(Duration::from_secs(BASE_RETRY_DELAY_SECS * 2)); - cx.run_until_parked(); - - // Should have scheduled third retry - // Count all retry messages now - 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 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 383729017a..3cf68b887d 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 31fb0dd69f..000e270322 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 904eca83e6..09770364cb 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 982542ca39..5168887581 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 de808ff263..e2d86576c3 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 5403f279c8..0e4cd1504f 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 530c2cf925..1026454026 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 edf07fdbf9..aa6e4399c3 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 bad3ebd479..1cc1fbcf6f 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 | 49 +- 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, 840 insertions(+), 28 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 e2d86576c3..15a28016c6 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 0e4cd1504f..afb47c006e 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 0000000000..289525c8ef --- /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 3c24ee59f6..b2ec768435 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 0f248edd57..5d158e84f4 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 c7324732c9..192f5a5fae 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 6bc93bd366..c717be7c90 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 c46135ff3e..5a6acc4329 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 0000000000..5f6034571b --- /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 f96a2c0a66..dafbb62910 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 google: GoogleSettings, - pub vercel: VercelSettings, - - pub lmstudio: LmStudioSettings, pub deepseek: DeepSeekSettings, + pub google: GoogleSettings, + pub lmstudio: LmStudioSettings, 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 0000000000..7ca0ca0939 --- /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 0000000000..89e542f750 --- /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 0000000000..ac116b2f8f --- /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 ade1ae672f..56eb4ab76c 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 8bfdd50761..579331c9ac 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 18e43dd51e..ded26b1896 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 bf5f313918..d81c593484 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 0d00deb10e..b944b1ec50 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 40ba7bd5a6..1adfc4b5d8 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 09d9877df8..022daced7a 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 6309c3a137..5eb96f21a4 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 0fb08d1be5..42eb8198a4 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 06121a9de8..fc7d98178e 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 78e306ed63..470c5faf78 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 3567439d2b..5b2cca92bb 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 c4866179c1..87463d246d 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 acd9c23c97..e5ff755615 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 dc2c6516dd..be5d693d35 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 6d7fa244a2..42f77ab056 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 a51377cd76..2d19921a0f 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 dc3d15e124..eb541bde8e 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 579331c9ac..699a776330 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 9d94644954..eb77604bee 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 cf157ce4f9..1e296ac2ac 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 8351aeaee0..a7e72b0c2d 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 5d588cd4ae..ba43122570 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 ea352a9320..98b70ad834 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 1b9669c5d5..4be20525f9 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 043791cdd8..b16ca795b4 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 banner = banner.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)); - } - - 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 699a776330..0697f5dee7 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 b749ea20ea..9fe52507d8 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 b944b1ec50..fe3e94f5c2 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 88676e8a2b..9c2961c55f 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 0000000000..e1262875fe --- /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 d6dc094d41..a34ca40ed8 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 18c9decc59..ed0fdd0114 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 5c91625412..4b8902d14e 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 aa6e4399c3..32d4c496c1 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 2ddb93c8d5..9cb7c40762 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 eb77604bee..499091ca0f 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,25 +325,34 @@ 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 top_area.contains(&evt.position) { + } 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(); + true + } else if viewport.top() > evt.event.position.y { + self.view_state.schedule_scroll_up(); + true + } else { + false + } + } + fn editor_style(editor: &Entity, cx: &Context) -> EditorStyle { let is_read_only = editor.read(cx).read_only(cx); let settings = ThemeSettings::get_global(cx); @@ -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 d3aec588ec..3d62b4156b 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 f5d7d5b306..a290ce9c81 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 e22fdb1ed5..2cc8ea59ab 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 6cff977128..0606f619c6 100644 --- a/crates/gpui/src/platform/linux/x11/client.rs +++ b/crates/gpui/src/platform/linux/x11/client.rs @@ -1,5 +1,14 @@ use crate::{Capslock, xcb_flush}; +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}, @@ -8,16 +17,6 @@ use std::{ rc::{Rc, Weak}, time::{Duration, Instant}, }; - -use anyhow::{Context as _, anyhow}; -use calloop::{ - EventLoop, LoopHandle, RegistrationToken, - generic::{FdWrapper, Generic}, -}; -use collections::HashMap; -use http_client::Url; -use log::Level; -use smallvec::SmallVec; 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 d04447d90f..1895a377a6 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 1895a377a6..98085c0cfa 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 15a28016c6..a2e9fc26ca 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 8e66e526de..d46dada270 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 5a54e86eac..acbe674b02 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 e983075cd1..dce1b0cdc1 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 168ec61ae9..1b926bb446 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 ded26b1896..2caa9dab42 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 | 58 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 39 insertions(+), 19 deletions(-) diff --git a/docs/book.toml b/docs/book.toml index 98085c0cfa..70e294c014 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/assistant.html" = "/docs/ai/overview.html" -"/assistant/configuration.html" = "/docs/ai/configuration.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/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 7af79bdeea..1eece31699 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 470c5faf78..b61d30e405 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 1aed18b44a..a448eb2737 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 5b2cca92bb..4526b7fcc8 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 3c04ae5c43..ca35e06e11 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 70e294c014..f5d186f377 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 4128426a7f..3e6e406d98 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 a2e9fc26ca..3950871688 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 2b8958feb1..e234b62b14 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 1b926bb446..ec315d9ab1 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 0000000000..9b3a151a12 --- /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 b2ec768435..b29a8b78e6 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 4526b7fcc8..c83a4c2423 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 6bbc2fc089..dc504e2dc4 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 3db015a241..431c051081 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 0000000000..d086b2e98d --- /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 0000000000..a999c70f66 --- /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 0000000000..9c5ec58eae --- /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 c83a4c2423..2bfa6f820e 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 ca2dea36df..18aa732e81 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 0f7575324b..1a0736765a 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