From 01295aa687d726343e3b58ac4f0b3ae3b0d123eb Mon Sep 17 00:00:00 2001 From: Remco Smits Date: Sun, 6 Jul 2025 01:48:55 +0200 Subject: [PATCH 01/21] debugger: Fix the JavaScript debug terminal scenario (#33924) There were a couple of things preventing this from working: - our hack to stop the node REPL from appearing broke in recent versions of the JS DAP that started passing `--experimental-network-inspection` by default - we had lost the ability to create a debug terminal without specifying a program This PR fixes those issues. We also fixed environment variables from the **runInTerminal** request not getting passed to the spawned program. Release Notes: - Debugger: Fix RunInTerminal not working for JavaScript debugger. --------- Co-authored-by: Cole Miller --- crates/assistant_tools/src/terminal_tool.rs | 2 +- crates/dap_adapters/src/javascript.rs | 13 ++- crates/debugger_ui/src/session/running.rs | 87 +++++++++++-------- crates/extension_host/src/wasm_host/wit.rs | 2 +- .../src/wasm_host/wit/since_v0_6_0.rs | 12 +-- crates/project/src/debugger/locators/cargo.rs | 2 +- crates/project/src/project_tests.rs | 2 +- crates/project/src/terminals.rs | 19 ++-- crates/proto/proto/debugger.proto | 2 +- crates/task/src/shell_builder.rs | 26 +++--- crates/task/src/task.rs | 2 +- crates/task/src/task_template.rs | 8 +- crates/terminal_view/src/terminal_panel.rs | 2 +- crates/vim/src/command.rs | 2 +- 14 files changed, 108 insertions(+), 73 deletions(-) diff --git a/crates/assistant_tools/src/terminal_tool.rs b/crates/assistant_tools/src/terminal_tool.rs index 2c582a531069eb9a81340af7eb07731e8df8a96e..9a3eac907cbdd6848df32eeed9481058bc368840 100644 --- a/crates/assistant_tools/src/terminal_tool.rs +++ b/crates/assistant_tools/src/terminal_tool.rs @@ -218,7 +218,7 @@ impl Tool for TerminalTool { .update(cx, |project, cx| { project.create_terminal( TerminalKind::Task(task::SpawnInTerminal { - command: program, + command: Some(program), args, cwd, env, diff --git a/crates/dap_adapters/src/javascript.rs b/crates/dap_adapters/src/javascript.rs index d261d3b8b6e88c3b8069935caa9e3fd2b9d2d836..76c1d1fb7bb3b2b3a534293957b43919a079a888 100644 --- a/crates/dap_adapters/src/javascript.rs +++ b/crates/dap_adapters/src/javascript.rs @@ -1,9 +1,10 @@ use adapters::latest_github_release; use anyhow::Context as _; +use collections::HashMap; use dap::{StartDebuggingRequestArguments, adapters::DebugTaskDefinition}; use gpui::AsyncApp; use serde_json::Value; -use std::{collections::HashMap, path::PathBuf, sync::OnceLock}; +use std::{path::PathBuf, sync::OnceLock}; use task::DebugRequest; use util::{ResultExt, maybe}; @@ -70,6 +71,8 @@ impl JsDebugAdapter { let tcp_connection = task_definition.tcp_connection.clone().unwrap_or_default(); let (host, port, timeout) = crate::configure_tcp_connection(tcp_connection).await?; + let mut envs = HashMap::default(); + let mut configuration = task_definition.config.clone(); if let Some(configuration) = configuration.as_object_mut() { maybe!({ @@ -110,6 +113,12 @@ impl JsDebugAdapter { } } + if let Some(env) = configuration.get("env").cloned() { + if let Ok(env) = serde_json::from_value(env) { + envs = env; + } + } + configuration .entry("cwd") .or_insert(delegate.worktree_root_path().to_string_lossy().into()); @@ -158,7 +167,7 @@ impl JsDebugAdapter { ), arguments, cwd: Some(delegate.worktree_root_path().to_path_buf()), - envs: HashMap::default(), + envs, connection: Some(adapters::TcpArguments { host, port, diff --git a/crates/debugger_ui/src/session/running.rs b/crates/debugger_ui/src/session/running.rs index b9f373daa4b6afc96e63817d64b686840a2d0738..af8c14aef77d0886071dfd899d8de5adff0d3ed6 100644 --- a/crates/debugger_ui/src/session/running.rs +++ b/crates/debugger_ui/src/session/running.rs @@ -973,7 +973,7 @@ impl RunningState { let task_with_shell = SpawnInTerminal { command_label, - command, + command: Some(command), args, ..task.resolved.clone() }; @@ -1085,19 +1085,6 @@ impl RunningState { .map(PathBuf::from) .or_else(|| session.binary().unwrap().cwd.clone()); - let mut args = request.args.clone(); - - // Handle special case for NodeJS debug adapter - // If only the Node binary path is provided, we set the command to None - // This prevents the NodeJS REPL from appearing, which is not the desired behavior - // The expected usage is for users to provide their own Node command, e.g., `node test.js` - // This allows the NodeJS debug client to attach correctly - let command = if args.len() > 1 { - Some(args.remove(0)) - } else { - None - }; - let mut envs: HashMap = self.session.read(cx).task_context().project_env.clone(); if let Some(Value::Object(env)) = &request.env { @@ -1111,32 +1098,58 @@ impl RunningState { } } - let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); - let kind = if let Some(command) = command { - let title = request.title.clone().unwrap_or(command.clone()); - TerminalKind::Task(task::SpawnInTerminal { - id: task::TaskId("debug".to_string()), - full_label: title.clone(), - label: title.clone(), - command: command.clone(), - args, - command_label: title.clone(), - cwd, - env: envs, - use_new_terminal: true, - allow_concurrent_runs: true, - reveal: task::RevealStrategy::NoFocus, - reveal_target: task::RevealTarget::Dock, - hide: task::HideStrategy::Never, - shell, - show_summary: false, - show_command: false, - show_rerun: false, - }) + let mut args = request.args.clone(); + let command = if envs.contains_key("VSCODE_INSPECTOR_OPTIONS") { + // Handle special case for NodeJS debug adapter + // If the Node binary path is provided (possibly with arguments like --experimental-network-inspection), + // we set the command to None + // This prevents the NodeJS REPL from appearing, which is not the desired behavior + // The expected usage is for users to provide their own Node command, e.g., `node test.js` + // This allows the NodeJS debug client to attach correctly + if args + .iter() + .filter(|arg| !arg.starts_with("--")) + .collect::>() + .len() + > 1 + { + Some(args.remove(0)) + } else { + None + } + } else if args.len() > 0 { + Some(args.remove(0)) } else { - TerminalKind::Shell(cwd.map(|c| c.to_path_buf())) + None }; + let shell = project.read(cx).terminal_settings(&cwd, cx).shell.clone(); + let title = request + .title + .clone() + .filter(|title| !title.is_empty()) + .or_else(|| command.clone()) + .unwrap_or_else(|| "Debug terminal".to_string()); + let kind = TerminalKind::Task(task::SpawnInTerminal { + id: task::TaskId("debug".to_string()), + full_label: title.clone(), + label: title.clone(), + command: command.clone(), + args, + command_label: title.clone(), + cwd, + env: envs, + use_new_terminal: true, + allow_concurrent_runs: true, + reveal: task::RevealStrategy::NoFocus, + reveal_target: task::RevealTarget::Dock, + hide: task::HideStrategy::Never, + shell, + show_summary: false, + show_command: false, + show_rerun: false, + }); + let workspace = self.workspace.clone(); let weak_project = project.downgrade(); diff --git a/crates/extension_host/src/wasm_host/wit.rs b/crates/extension_host/src/wasm_host/wit.rs index b2b7726a1566dd202f25f52c3ceb8023ff216371..1f1fa49bd535ad19f4981eeed9fcdca1ba9421a9 100644 --- a/crates/extension_host/src/wasm_host/wit.rs +++ b/crates/extension_host/src/wasm_host/wit.rs @@ -999,7 +999,7 @@ impl Extension { ) -> Result> { match self { Extension::V0_6_0(ext) => { - let build_config_template = resolved_build_task.into(); + let build_config_template = resolved_build_task.try_into()?; let dap_request = ext .call_run_dap_locator(store, &locator_name, &build_config_template) .await? diff --git a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs index f8f9ae1977687296790a562711c286e2fce026e4..ced2ea9c677022e95f106ac6ba0543303fe5a372 100644 --- a/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs +++ b/crates/extension_host/src/wasm_host/wit/since_v0_6_0.rs @@ -299,15 +299,17 @@ impl From for DebugScenario { } } -impl From for ResolvedTask { - fn from(value: SpawnInTerminal) -> Self { - Self { +impl TryFrom for ResolvedTask { + type Error = anyhow::Error; + + fn try_from(value: SpawnInTerminal) -> Result { + Ok(Self { label: value.label, - command: value.command, + command: value.command.context("missing command")?, args: value.args, env: value.env.into_iter().collect(), cwd: value.cwd.map(|s| s.to_string_lossy().into_owned()), - } + }) } } diff --git a/crates/project/src/debugger/locators/cargo.rs b/crates/project/src/debugger/locators/cargo.rs index bad7dfe9f8947810346c06493d47d5f0b4c89c22..7d70371380192c99e1ace9676b02088f86ed9e5f 100644 --- a/crates/project/src/debugger/locators/cargo.rs +++ b/crates/project/src/debugger/locators/cargo.rs @@ -119,7 +119,7 @@ impl DapLocator for CargoLocator { .context("Couldn't get cwd from debug config which is needed for locators")?; let builder = ShellBuilder::new(true, &build_config.shell).non_interactive(); let (program, args) = builder.build( - "cargo".into(), + Some("cargo".into()), &build_config .args .iter() diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index b5eb62c4eb75e2165deaab9044d7b96f4c5e1f57..779cf95add9ad5547e13d85d87c0dcc3935ab326 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -568,7 +568,7 @@ async fn test_fallback_to_single_worktree_tasks(cx: &mut gpui::TestAppContext) { .into_iter() .map(|(source_kind, task)| { let resolved = task.resolved; - (source_kind, resolved.command) + (source_kind, resolved.command.unwrap()) }) .collect::>(), vec![( diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index b4e1093293b6275b9da68075425dd3b75b5bb335..b067396881d3b1bc0c20d8b0f21cb5ea80b675f9 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -149,7 +149,7 @@ impl Project { let settings = self.terminal_settings(&path, cx).clone(); let builder = ShellBuilder::new(ssh_details.is_none(), &settings.shell).non_interactive(); - let (command, args) = builder.build(command, &Vec::new()); + let (command, args) = builder.build(Some(command), &Vec::new()); let mut env = self .environment @@ -297,7 +297,10 @@ impl Project { .or_insert_with(|| "xterm-256color".to_string()); let (program, args) = wrap_for_ssh( &ssh_command, - Some((&spawn_task.command, &spawn_task.args)), + spawn_task + .command + .as_ref() + .map(|command| (command, &spawn_task.args)), path.as_deref(), env, python_venv_directory.as_deref(), @@ -317,14 +320,16 @@ impl Project { add_environment_path(&mut env, &venv_path.join("bin")).log_err(); } - ( - task_state, + let shell = if let Some(program) = spawn_task.command { Shell::WithArguments { - program: spawn_task.command, + program, args: spawn_task.args, title_override: None, - }, - ) + } + } else { + Shell::System + }; + (task_state, shell) } } } diff --git a/crates/proto/proto/debugger.proto b/crates/proto/proto/debugger.proto index 3979265accaa07040373174a4e7984d181a1da33..09abd4bf1c1aa73e89d77c55ade1bce21f0027d4 100644 --- a/crates/proto/proto/debugger.proto +++ b/crates/proto/proto/debugger.proto @@ -535,7 +535,7 @@ message DebugScenario { message SpawnInTerminal { string label = 1; - string command = 2; + optional string command = 2; repeated string args = 3; map env = 4; optional string cwd = 5; diff --git a/crates/task/src/shell_builder.rs b/crates/task/src/shell_builder.rs index c75aa059f0e4571093ce97fec31860af3c8c4652..544663713933dd967f71c9330268f46688b11d93 100644 --- a/crates/task/src/shell_builder.rs +++ b/crates/task/src/shell_builder.rs @@ -149,17 +149,23 @@ impl ShellBuilder { } } /// Returns the program and arguments to run this task in a shell. - pub fn build(mut self, task_command: String, task_args: &Vec) -> (String, Vec) { - let combined_command = task_args - .into_iter() - .fold(task_command, |mut command, arg| { - command.push(' '); - command.push_str(&self.kind.to_shell_variable(arg)); - command - }); + pub fn build( + mut self, + task_command: Option, + task_args: &Vec, + ) -> (String, Vec) { + if let Some(task_command) = task_command { + let combined_command = task_args + .into_iter() + .fold(task_command, |mut command, arg| { + command.push(' '); + command.push_str(&self.kind.to_shell_variable(arg)); + command + }); - self.args - .extend(self.kind.args_for_shell(self.interactive, combined_command)); + self.args + .extend(self.kind.args_for_shell(self.interactive, combined_command)); + } (self.program, self.args) } diff --git a/crates/task/src/task.rs b/crates/task/src/task.rs index bbae4850c16d5e7b7caacde245dc36429c18ed8e..aae28ab874544f683bf48f873d4a9a80a529a32b 100644 --- a/crates/task/src/task.rs +++ b/crates/task/src/task.rs @@ -46,7 +46,7 @@ pub struct SpawnInTerminal { /// Human readable name of the terminal tab. pub label: String, /// Executable command to spawn. - pub command: String, + pub command: Option, /// Arguments to the command, potentially unsubstituted, /// to let the shell that spawns the command to do the substitution, if needed. pub args: Vec, diff --git a/crates/task/src/task_template.rs b/crates/task/src/task_template.rs index cc36b28e4beebc230cd635b894c5288cfcd3ada4..ae5054ac556b4ad82f5c9243005592593b033006 100644 --- a/crates/task/src/task_template.rs +++ b/crates/task/src/task_template.rs @@ -255,7 +255,7 @@ impl TaskTemplate { command_label }, ), - command, + command: Some(command), args: self.args.clone(), env, use_new_terminal: self.use_new_terminal, @@ -635,7 +635,7 @@ mod tests { "Human-readable label should have long substitutions trimmed" ); assert_eq!( - spawn_in_terminal.command, + spawn_in_terminal.command.clone().unwrap(), format!("echo test_file {long_value}"), "Command should be substituted with variables and those should not be shortened" ); @@ -652,7 +652,7 @@ mod tests { spawn_in_terminal.command_label, format!( "{} arg1 test_selected_text arg2 5678 arg3 {long_value}", - spawn_in_terminal.command + spawn_in_terminal.command.clone().unwrap() ), "Command label args should be substituted with variables and those should not be shortened" ); @@ -711,7 +711,7 @@ mod tests { assert_substituted_variables(&resolved_task, Vec::new()); let resolved = resolved_task.resolved; assert_eq!(resolved.label, task.label); - assert_eq!(resolved.command, task.command); + assert_eq!(resolved.command, Some(task.command)); assert_eq!(resolved.args, task.args); } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 8c55fed2a60127db4dd6fd0845f219507a8f4f78..f6eee3065ca974449315ab2ac519de1acb5da11e 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -505,7 +505,7 @@ impl TerminalPanel { let task = SpawnInTerminal { command_label, - command, + command: Some(command), args, ..task.clone() }; diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index 729e1a7b3c008c957f3f018f79bdcccf78a8b698..b24ca75e8bc1f922a86b011c9dcfc27a92b57e47 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1688,7 +1688,7 @@ impl ShellExec { id: TaskId("vim".to_string()), full_label: command.clone(), label: command.clone(), - command: command.clone(), + command: Some(command.clone()), args: Vec::new(), command_label: command.clone(), cwd, From 2246b01c4b6504966542bf92a49805fd6bf46772 Mon Sep 17 00:00:00 2001 From: feeiyu <158308373+feeiyu@users.noreply.github.com> Date: Sun, 6 Jul 2025 20:52:16 +0800 Subject: [PATCH 02/21] Allow remote loading for DAP-only extensions (#33981) Closes #ISSUE ![image](https://github.com/user-attachments/assets/8e1766e4-5d89-4263-875d-ad0dff5c55c2) Release Notes: - Allow remote loading for DAP-only extensions --- .../src/extension_locator_adapter.rs | 6 ++- crates/extension/src/extension_manifest.rs | 6 +++ crates/extension_host/src/extension_host.rs | 2 +- crates/extension_host/src/headless_host.rs | 40 ++++++++++--------- 4 files changed, 33 insertions(+), 21 deletions(-) diff --git a/crates/debug_adapter_extension/src/extension_locator_adapter.rs b/crates/debug_adapter_extension/src/extension_locator_adapter.rs index 54c03b1eafa1cda8495c29f419f1588c570d78c3..55094ea7de02385ad3a5a75ea8ac0042c50a8600 100644 --- a/crates/debug_adapter_extension/src/extension_locator_adapter.rs +++ b/crates/debug_adapter_extension/src/extension_locator_adapter.rs @@ -44,7 +44,9 @@ impl DapLocator for ExtensionLocatorAdapter { .flatten() } - async fn run(&self, _build_config: SpawnInTerminal) -> Result { - Err(anyhow::anyhow!("Not implemented")) + async fn run(&self, build_config: SpawnInTerminal) -> Result { + self.extension + .run_dap_locator(self.locator_name.as_ref().to_owned(), build_config) + .await } } diff --git a/crates/extension/src/extension_manifest.rs b/crates/extension/src/extension_manifest.rs index 4e3f8a3dc214e7b6f8970c72562b85838a1660aa..0a14923c0c1a4ccfb153d9fa7f602d36805799fe 100644 --- a/crates/extension/src/extension_manifest.rs +++ b/crates/extension/src/extension_manifest.rs @@ -130,6 +130,12 @@ impl ExtensionManifest { Ok(()) } + + pub fn allow_remote_load(&self) -> bool { + !self.language_servers.is_empty() + || !self.debug_adapters.is_empty() + || !self.debug_locators.is_empty() + } } pub fn build_debug_adapter_schema_path( diff --git a/crates/extension_host/src/extension_host.rs b/crates/extension_host/src/extension_host.rs index eb6fb52eb82acec5b628aac05fd9131568a6c919..7c58fac1e0d363a4536fd0c7ea0035609c90330e 100644 --- a/crates/extension_host/src/extension_host.rs +++ b/crates/extension_host/src/extension_host.rs @@ -1670,7 +1670,7 @@ impl ExtensionStore { .extensions .iter() .filter_map(|(id, entry)| { - if entry.manifest.language_servers.is_empty() { + if !entry.manifest.allow_remote_load() { return None; } Some(proto::Extension { diff --git a/crates/extension_host/src/headless_host.rs b/crates/extension_host/src/headless_host.rs index ad3931ce838043c7644fd3e0c3d0eb249db1dd9b..8feaec89de5c0c607bffe87c3be9b4700169e190 100644 --- a/crates/extension_host/src/headless_host.rs +++ b/crates/extension_host/src/headless_host.rs @@ -125,7 +125,7 @@ impl HeadlessExtensionStore { let manifest = Arc::new(ExtensionManifest::load(fs.clone(), &extension_dir).await?); - debug_assert!(!manifest.languages.is_empty() || !manifest.language_servers.is_empty()); + debug_assert!(!manifest.languages.is_empty() || manifest.allow_remote_load()); if manifest.version.as_ref() != extension.version.as_str() { anyhow::bail!( @@ -165,7 +165,7 @@ impl HeadlessExtensionStore { })?; } - if manifest.language_servers.is_empty() { + if !manifest.allow_remote_load() { return Ok(()); } @@ -187,24 +187,28 @@ impl HeadlessExtensionStore { ); })?; } - for (debug_adapter, meta) in &manifest.debug_adapters { - let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta); + log::info!("Loaded language server: {}", language_server_id); + } - this.update(cx, |this, _cx| { - this.proxy.register_debug_adapter( - wasm_extension.clone(), - debug_adapter.clone(), - &extension_dir.join(schema_path), - ); - })?; - } + for (debug_adapter, meta) in &manifest.debug_adapters { + let schema_path = extension::build_debug_adapter_schema_path(debug_adapter, meta); - for debug_adapter in manifest.debug_locators.keys() { - this.update(cx, |this, _cx| { - this.proxy - .register_debug_locator(wasm_extension.clone(), debug_adapter.clone()); - })?; - } + this.update(cx, |this, _cx| { + this.proxy.register_debug_adapter( + wasm_extension.clone(), + debug_adapter.clone(), + &extension_dir.join(schema_path), + ); + })?; + log::info!("Loaded debug adapter: {}", debug_adapter); + } + + for debug_locator in manifest.debug_locators.keys() { + this.update(cx, |this, _cx| { + this.proxy + .register_debug_locator(wasm_extension.clone(), debug_locator.clone()); + })?; + log::info!("Loaded debug locator: {}", debug_locator); } Ok(()) From 6efc5ecefe98487b7ede1f35536b7710b831c251 Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 7 Jul 2025 08:32:42 +0530 Subject: [PATCH 03/21] project_panel: Add Sticky Scroll (#33994) Closes #7243 - Adds `top_slot_items` to `uniform_list` component to offset list items. - Adds `ToPosition` scroll strategy to `uniform_list` to scroll list to specified index. - Adds `sticky_items` component which can be used along with `uniform_list` to add sticky functionality to any view that implements uniform list. https://github.com/user-attachments/assets/eb508fa4-167e-4595-911b-52651537284c Release Notes: - Added sticky scroll to the project panel, which keeps parent directories visible while scrolling. This feature is enabled by default. To disable it, toggle `sticky_scroll` in settings. --- assets/settings/default.json | 2 + crates/gpui/src/elements/uniform_list.rs | 83 +- crates/project_panel/src/project_panel.rs | 779 +++++++++++------- .../src/project_panel_settings.rs | 5 + crates/ui/src/components.rs | 2 + crates/ui/src/components/sticky_items.rs | 150 ++++ 6 files changed, 738 insertions(+), 283 deletions(-) create mode 100644 crates/ui/src/components/sticky_items.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index 9d858b42a8867b9968f6e6d8113e5d0c7fe357ff..985e322cac2a2c4b6b807aeff24caeb68beacf89 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -617,6 +617,8 @@ // 3. Mark files with errors and warnings: // "all" "show_diagnostics": "all", + // Whether to stick parent directories at top of the project panel. + "sticky_scroll": true, // Settings related to indent guides in the project panel. "indent_guides": { // When to show indent guides in the project panel. diff --git a/crates/gpui/src/elements/uniform_list.rs b/crates/gpui/src/elements/uniform_list.rs index c85f71eae8954e8afd72812e313bfd1ebfbea2c1..f32ecfc20cb0d1a488705f9e48e596f9a05ef98c 100644 --- a/crates/gpui/src/elements/uniform_list.rs +++ b/crates/gpui/src/elements/uniform_list.rs @@ -7,8 +7,8 @@ use crate::{ AnyElement, App, AvailableSpace, Bounds, ContentMask, Element, ElementId, GlobalElementId, Hitbox, InspectorElementId, InteractiveElement, Interactivity, IntoElement, IsZero, LayoutId, - ListSizingBehavior, Overflow, Pixels, ScrollHandle, Size, StyleRefinement, Styled, Window, - point, size, + ListSizingBehavior, Overflow, Pixels, Point, ScrollHandle, Size, StyleRefinement, Styled, + Window, point, size, }; use smallvec::SmallVec; use std::{cell::RefCell, cmp, ops::Range, rc::Rc}; @@ -42,6 +42,7 @@ where item_count, item_to_measure_index: 0, render_items: Box::new(render_range), + top_slot: None, decorations: Vec::new(), interactivity: Interactivity { element_id: Some(id), @@ -61,6 +62,7 @@ pub struct UniformList { render_items: Box< dyn for<'a> Fn(Range, &'a mut Window, &'a mut App) -> SmallVec<[AnyElement; 64]>, >, + top_slot: Option>, decorations: Vec>, interactivity: Interactivity, scroll_handle: Option, @@ -71,6 +73,7 @@ pub struct UniformList { /// Frame state used by the [UniformList]. pub struct UniformListFrameState { items: SmallVec<[AnyElement; 32]>, + top_slot_items: SmallVec<[AnyElement; 8]>, decorations: SmallVec<[AnyElement; 1]>, } @@ -88,6 +91,8 @@ pub enum ScrollStrategy { /// May not be possible if there's not enough list items above the item scrolled to: /// in this case, the element will be placed at the closest possible position. Center, + /// Scrolls the element to be at the given item index from the top of the viewport. + ToPosition(usize), } #[derive(Clone, Debug, Default)] @@ -212,6 +217,7 @@ impl Element for UniformList { UniformListFrameState { items: SmallVec::new(), decorations: SmallVec::new(), + top_slot_items: SmallVec::new(), }, ) } @@ -345,6 +351,15 @@ impl Element for UniformList { } } } + ScrollStrategy::ToPosition(sticky_index) => { + let target_y_in_viewport = item_height * sticky_index; + let target_scroll_top = item_top - target_y_in_viewport; + let max_scroll_top = + (content_height - list_height).max(Pixels::ZERO); + let new_scroll_top = + target_scroll_top.clamp(Pixels::ZERO, max_scroll_top); + updated_scroll_offset.y = -new_scroll_top; + } } scroll_offset = *updated_scroll_offset } @@ -354,7 +369,17 @@ impl Element for UniformList { let last_visible_element_ix = ((-scroll_offset.y + padded_bounds.size.height) / item_height) .ceil() as usize; - let visible_range = first_visible_element_ix + let initial_range = first_visible_element_ix + ..cmp::min(last_visible_element_ix, self.item_count); + + let mut top_slot_elements = if let Some(ref mut top_slot) = self.top_slot { + top_slot.compute(initial_range, window, cx) + } else { + SmallVec::new() + }; + let top_slot_offset = top_slot_elements.len(); + + let visible_range = (top_slot_offset + first_visible_element_ix) ..cmp::min(last_visible_element_ix, self.item_count); let items = if y_flipped { @@ -393,6 +418,20 @@ impl Element for UniformList { frame_state.items.push(item); } + if let Some(ref top_slot) = self.top_slot { + top_slot.prepaint( + &mut top_slot_elements, + padded_bounds, + item_height, + scroll_offset, + padding, + can_scroll_horizontally, + window, + cx, + ); + } + frame_state.top_slot_items = top_slot_elements; + let bounds = Bounds::new( padded_bounds.origin + point( @@ -454,6 +493,9 @@ impl Element for UniformList { for decoration in &mut request_layout.decorations { decoration.paint(window, cx); } + if let Some(ref top_slot) = self.top_slot { + top_slot.paint(&mut request_layout.top_slot_items, window, cx); + } }, ) } @@ -483,6 +525,35 @@ pub trait UniformListDecoration { ) -> AnyElement; } +/// A trait for implementing top slots in a [`UniformList`]. +/// Top slots are elements that appear at the top of the list and can adjust +/// the visible range of list items. +pub trait UniformListTopSlot { + /// Returns elements to render at the top slot for the given visible range. + fn compute( + &mut self, + visible_range: Range, + window: &mut Window, + cx: &mut App, + ) -> SmallVec<[AnyElement; 8]>; + + /// Layout and prepaint the top slot elements. + fn prepaint( + &self, + elements: &mut SmallVec<[AnyElement; 8]>, + bounds: Bounds, + item_height: Pixels, + scroll_offset: Point, + padding: crate::Edges, + can_scroll_horizontally: bool, + window: &mut Window, + cx: &mut App, + ); + + /// Paint the top slot elements. + fn paint(&self, elements: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App); +} + impl UniformList { /// Selects a specific list item for measurement. pub fn with_width_from_item(mut self, item_index: Option) -> Self { @@ -521,6 +592,12 @@ impl UniformList { self } + /// Sets a top slot for the list. + pub fn with_top_slot(mut self, top_slot: impl UniformListTopSlot + 'static) -> Self { + self.top_slot = Some(Box::new(top_slot)); + self + } + fn measure_item( &self, list_width: Option, diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index ded6e0e3f48f0650f97f265a1b5aed1b9b1b443a..ca791869d9db9a70090583f21b06b8099e9d74f1 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -56,7 +56,7 @@ use theme::ThemeSettings; use ui::{ Color, ContextMenu, DecoratedIcon, Icon, IconDecoration, IconDecorationKind, IndentGuideColors, IndentGuideLayout, KeyBinding, Label, LabelSize, ListItem, ListItemSpacing, Scrollbar, - ScrollbarState, Tooltip, prelude::*, v_flex, + ScrollbarState, StickyCandidate, Tooltip, prelude::*, v_flex, }; use util::{ResultExt, TakeUntilExt, TryFutureExt, maybe, paths::compare_paths}; use workspace::{ @@ -173,6 +173,7 @@ struct EntryDetails { is_editing: bool, is_processing: bool, is_cut: bool, + sticky: Option, filename_text_color: Color, diagnostic_severity: Option, git_status: GitSummary, @@ -181,6 +182,11 @@ struct EntryDetails { canonical_path: Option>, } +#[derive(Debug, PartialEq, Eq, Clone)] +struct StickyDetails { + sticky_index: usize, +} + /// Permanently deletes the selected file or directory. #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)] #[action(namespace = project_panel)] @@ -3366,22 +3372,13 @@ impl ProjectPanel { } let end_ix = range.end.min(ix + visible_worktree_entries.len()); - let (git_status_setting, show_file_icons, show_folder_icons) = { + let git_status_setting = { let settings = ProjectPanelSettings::get_global(cx); - ( - settings.git_status, - settings.file_icons, - settings.folder_icons, - ) + settings.git_status }; if let Some(worktree) = self.project.read(cx).worktree_for_id(*worktree_id, cx) { let snapshot = worktree.read(cx).snapshot(); let root_name = OsStr::new(snapshot.root_name()); - let expanded_entry_ids = self - .expanded_dir_ids - .get(&snapshot.id()) - .map(Vec::as_slice) - .unwrap_or(&[]); let entry_range = range.start.saturating_sub(ix)..end_ix - ix; let entries = entries_paths.get_or_init(|| { @@ -3394,80 +3391,17 @@ impl ProjectPanel { let status = git_status_setting .then_some(entry.git_summary) .unwrap_or_default(); - let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); - let icon = match entry.kind { - EntryKind::File => { - if show_file_icons { - FileIcons::get_icon(&entry.path, cx) - } else { - None - } - } - _ => { - if show_folder_icons { - FileIcons::get_folder_icon(is_expanded, cx) - } else { - FileIcons::get_chevron_icon(is_expanded, cx) - } - } - }; - - let (depth, difference) = - ProjectPanel::calculate_depth_and_difference(&entry, entries); - - let filename = match difference { - diff if diff > 1 => entry - .path - .iter() - .skip(entry.path.components().count() - diff) - .collect::() - .to_str() - .unwrap_or_default() - .to_string(), - _ => entry - .path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| root_name.to_string_lossy().to_string()), - }; - let selection = SelectedEntry { - worktree_id: snapshot.id(), - entry_id: entry.id, - }; - let is_marked = self.marked_entries.contains(&selection); - - let diagnostic_severity = self - .diagnostics - .get(&(*worktree_id, entry.path.to_path_buf())) - .cloned(); - - let filename_text_color = - entry_git_aware_label_color(status, entry.is_ignored, is_marked); - - let mut details = EntryDetails { - filename, - icon, - path: entry.path.clone(), - depth, - kind: entry.kind, - is_ignored: entry.is_ignored, - is_expanded, - is_selected: self.selection == Some(selection), - is_marked, - is_editing: false, - is_processing: false, - is_cut: self - .clipboard - .as_ref() - .map_or(false, |e| e.is_cut() && e.items().contains(&selection)), - filename_text_color, - diagnostic_severity, - git_status: status, - is_private: entry.is_private, - worktree_id: *worktree_id, - canonical_path: entry.canonical_path.clone(), - }; + let mut details = self.details_for_entry( + entry, + *worktree_id, + root_name, + entries, + status, + None, + window, + cx, + ); if let Some(edit_state) = &self.edit_state { let is_edited_entry = if edit_state.is_new_entry() { @@ -3879,6 +3813,8 @@ impl ProjectPanel { const GROUP_NAME: &str = "project_entry"; let kind = details.kind; + let is_sticky = details.sticky.is_some(); + let sticky_index = details.sticky.as_ref().map(|this| this.sticky_index); let settings = ProjectPanelSettings::get_global(cx); let show_editor = details.is_editing && !details.is_processing; @@ -4002,141 +3938,144 @@ impl ProjectPanel { .border_r_2() .border_color(border_color) .hover(|style| style.bg(bg_hover_color).border_color(border_hover_color)) - .on_drag_move::(cx.listener( - move |this, event: &DragMoveEvent, _, cx| { - let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); - - if !event.bounds.contains(&event.event.position) { - // Entry responsible for setting drag target is also responsible to - // clear it up after drag is out of bounds + .when(!is_sticky, |this| { + this + .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, _, cx| { + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); + + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; + } + return; + } + if is_current_target { - this.drag_target_entry = None; + return; } - return; - } - if is_current_target { - return; - } + let Some((entry_id, highlight_entry_id)) = maybe!({ + let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); + let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; + let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; - let Some((entry_id, highlight_entry_id)) = maybe!({ - let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); - let target_entry = target_worktree.entry_for_path(&path_for_external_paths)?; - let highlight_entry_id = this.highlight_entry_for_external_drag(target_entry, target_worktree); - Some((target_entry.id, highlight_entry_id)) - }) else { - return; - }; + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + this.marked_entries.clear(); + }, + )) + .on_drop(cx.listener( + move |this, external_paths: &ExternalPaths, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + this.drop_external_files(external_paths.paths(), entry_id, window, cx); + cx.stop_propagation(); + }, + )) + .on_drag_move::(cx.listener( + move |this, event: &DragMoveEvent, window, cx| { + let is_current_target = this.drag_target_entry.as_ref() + .map(|entry| entry.entry_id) == Some(entry_id); + + if !event.bounds.contains(&event.event.position) { + // Entry responsible for setting drag target is also responsible to + // clear it up after drag is out of bounds + if is_current_target { + this.drag_target_entry = None; + } + return; + } - this.drag_target_entry = Some(DragTargetEntry { - entry_id, - highlight_entry_id, - }); - this.marked_entries.clear(); - }, - )) - .on_drop(cx.listener( - move |this, external_paths: &ExternalPaths, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.drop_external_files(external_paths.paths(), entry_id, window, cx); - cx.stop_propagation(); - }, - )) - .on_drag_move::(cx.listener( - move |this, event: &DragMoveEvent, window, cx| { - let is_current_target = this.drag_target_entry.as_ref() - .map(|entry| entry.entry_id) == Some(entry_id); - - if !event.bounds.contains(&event.event.position) { - // Entry responsible for setting drag target is also responsible to - // clear it up after drag is out of bounds if is_current_target { - this.drag_target_entry = None; + return; } - return; - } - if is_current_target { - return; - } - - let drag_state = event.drag(cx); - let Some((entry_id, highlight_entry_id)) = maybe!({ - let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); - let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; - let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); - Some((target_entry.id, highlight_entry_id)) - }) else { - return; - }; + let drag_state = event.drag(cx); + let Some((entry_id, highlight_entry_id)) = maybe!({ + let target_worktree = this.project.read(cx).worktree_for_id(selection.worktree_id, cx)?.read(cx); + let target_entry = target_worktree.entry_for_path(&path_for_dragged_selection)?; + let highlight_entry_id = this.highlight_entry_for_selection_drag(target_entry, target_worktree, drag_state, cx); + Some((target_entry.id, highlight_entry_id)) + }) else { + return; + }; - this.drag_target_entry = Some(DragTargetEntry { - entry_id, - highlight_entry_id, - }); - if drag_state.items().count() == 1 { - this.marked_entries.clear(); - this.marked_entries.insert(drag_state.active_selection); - } - this.hover_expand_task.take(); + this.drag_target_entry = Some(DragTargetEntry { + entry_id, + highlight_entry_id, + }); + if drag_state.items().count() == 1 { + this.marked_entries.clear(); + this.marked_entries.insert(drag_state.active_selection); + } + this.hover_expand_task.take(); - if !kind.is_dir() - || this - .expanded_dir_ids - .get(&details.worktree_id) - .map_or(false, |ids| ids.binary_search(&entry_id).is_ok()) - { - return; - } + if !kind.is_dir() + || this + .expanded_dir_ids + .get(&details.worktree_id) + .map_or(false, |ids| ids.binary_search(&entry_id).is_ok()) + { + return; + } - let bounds = event.bounds; - this.hover_expand_task = - Some(cx.spawn_in(window, async move |this, cx| { - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - this.update_in(cx, |this, window, cx| { - this.hover_expand_task.take(); - if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) - && bounds.contains(&window.mouse_position()) - { - this.expand_entry(worktree_id, entry_id, cx); - this.update_visible_entries( - Some((worktree_id, entry_id)), - cx, - ); - cx.notify(); - } - }) - .ok(); - })); - }, - )) - .on_drag( - dragged_selection, - move |selection, click_offset, _window, cx| { - cx.new(|_| DraggedProjectEntryView { - details: details.clone(), - click_offset, - selection: selection.active_selection, - selections: selection.marked_selections.clone(), - }) - }, - ) - .when(is_highlighted && folded_directory_drag_target.is_none(), |this| this.border_color(transparent_white()).bg(item_colors.drag_over)) - .on_drop( - cx.listener(move |this, selections: &DraggedSelection, window, cx| { - this.drag_target_entry = None; - this.hover_scroll_task.take(); - this.hover_expand_task.take(); - if folded_directory_drag_target.is_some() { - return; - } - this.drag_onto(selections, entry_id, kind.is_file(), window, cx); - }), - ) + let bounds = event.bounds; + this.hover_expand_task = + Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(500)) + .await; + this.update_in(cx, |this, window, cx| { + this.hover_expand_task.take(); + if this.drag_target_entry.as_ref().map(|entry| entry.entry_id) == Some(entry_id) + && bounds.contains(&window.mouse_position()) + { + this.expand_entry(worktree_id, entry_id, cx); + this.update_visible_entries( + Some((worktree_id, entry_id)), + cx, + ); + cx.notify(); + } + }) + .ok(); + })); + }, + )) + .on_drag( + dragged_selection, + move |selection, click_offset, _window, cx| { + cx.new(|_| DraggedProjectEntryView { + details: details.clone(), + click_offset, + selection: selection.active_selection, + selections: selection.marked_selections.clone(), + }) + }, + ) + .on_drop( + cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.drag_target_entry = None; + this.hover_scroll_task.take(); + this.hover_expand_task.take(); + if folded_directory_drag_target.is_some() { + return; + } + this.drag_onto(selections, entry_id, kind.is_file(), window, cx); + }), + ) + }) .on_mouse_down( MouseButton::Left, cx.listener(move |this, _, _, cx| { @@ -4168,7 +4107,7 @@ impl ProjectPanel { current_selection.zip(target_selection) { let range_start = source_index.min(target_index); - let range_end = source_index.max(target_index) + 1; // Make the range inclusive. + let range_end = source_index.max(target_index) + 1; let mut new_selections = BTreeSet::new(); this.for_each_visible_entry( range_start..range_end, @@ -4214,6 +4153,16 @@ impl ProjectPanel { let allow_preview = preview_tabs_enabled && click_count == 1; this.open_entry(entry_id, focus_opened_item, allow_preview, cx); } + + if is_sticky { + if let Some((_, _, index)) = this.index_for_entry(entry_id, worktree_id) { + let strategy = sticky_index + .map(ScrollStrategy::ToPosition) + .unwrap_or(ScrollStrategy::Top); + this.scroll_handle.scroll_to_item(index, strategy); + cx.notify(); + } + } }), ) .child( @@ -4328,51 +4277,99 @@ impl ProjectPanel { let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - delimiter_target_index).cloned(); this = this.child( div() - .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; - if let Some(target_entry_id) = target_entry_id { - this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); - } - })) + .when(!is_sticky, |div| { + div + .on_drop(cx.listener(move |this, selections: &DraggedSelection, window, cx| { + this.hover_scroll_task.take(); + this.drag_target_entry = None; + this.folded_directory_drag_target = None; + if let Some(target_entry_id) = target_entry_id { + this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); + } + })) + .on_drag_move(cx.listener( + move |this, event: &DragMoveEvent, _, _| { + if event.bounds.contains(&event.event.position) { + this.folded_directory_drag_target = Some( + FoldedDirectoryDragTarget { + entry_id, + index: delimiter_target_index, + is_delimiter_target: true, + } + ); + } else { + let is_current_target = this.folded_directory_drag_target + .map_or(false, |target| + target.entry_id == entry_id && + target.index == delimiter_target_index && + target.is_delimiter_target + ); + if is_current_target { + this.folded_directory_drag_target = None; + } + } + + }, + )) + }) + .child( + Label::new(DELIMITER.clone()) + .single_line() + .color(filename_text_color) + ) + ); + } + let id = SharedString::from(format!( + "project_panel_path_component_{}_{index}", + entry_id.to_usize() + )); + let label = div() + .id(id) + .when(!is_sticky,| div| { + div + .when(index != components_len - 1, |div|{ + let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); + div .on_drag_move(cx.listener( move |this, event: &DragMoveEvent, _, _| { - if event.bounds.contains(&event.event.position) { + if event.bounds.contains(&event.event.position) { this.folded_directory_drag_target = Some( FoldedDirectoryDragTarget { entry_id, - index: delimiter_target_index, - is_delimiter_target: true, + index, + is_delimiter_target: false, } ); } else { let is_current_target = this.folded_directory_drag_target + .as_ref() .map_or(false, |target| target.entry_id == entry_id && - target.index == delimiter_target_index && - target.is_delimiter_target + target.index == index && + !target.is_delimiter_target ); if is_current_target { this.folded_directory_drag_target = None; } } - }, )) - .child( - Label::new(DELIMITER.clone()) - .single_line() - .color(filename_text_color) - ) - ); - } - let id = SharedString::from(format!( - "project_panel_path_component_{}_{index}", - entry_id.to_usize() - )); - let label = div() - .id(id) + .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { + this.hover_scroll_task.take(); + this.drag_target_entry = None; + this.folded_directory_drag_target = None; + if let Some(target_entry_id) = target_entry_id { + this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); + } + })) + .when(folded_directory_drag_target.map_or(false, |target| + target.entry_id == entry_id && + target.index == index + ), |this| { + this.bg(item_colors.drag_over) + }) + }) + }) .on_click(cx.listener(move |this, _, _, cx| { if index != active_index { if let Some(folds) = @@ -4384,48 +4381,6 @@ impl ProjectPanel { } } })) - .when(index != components_len - 1, |div|{ - let target_entry_id = folded_ancestors.ancestors.get(components_len - 1 - index).cloned(); - div - .on_drag_move(cx.listener( - move |this, event: &DragMoveEvent, _, _| { - if event.bounds.contains(&event.event.position) { - this.folded_directory_drag_target = Some( - FoldedDirectoryDragTarget { - entry_id, - index, - is_delimiter_target: false, - } - ); - } else { - let is_current_target = this.folded_directory_drag_target - .as_ref() - .map_or(false, |target| - target.entry_id == entry_id && - target.index == index && - !target.is_delimiter_target - ); - if is_current_target { - this.folded_directory_drag_target = None; - } - } - }, - )) - .on_drop(cx.listener(move |this, selections: &DraggedSelection, window,cx| { - this.hover_scroll_task.take(); - this.drag_target_entry = None; - this.folded_directory_drag_target = None; - if let Some(target_entry_id) = target_entry_id { - this.drag_onto(selections, target_entry_id, kind.is_file(), window, cx); - } - })) - .when(folded_directory_drag_target.map_or(false, |target| - target.entry_id == entry_id && - target.index == index - ), |this| { - this.bg(item_colors.drag_over) - }) - }) .child( Label::new(component) .single_line() @@ -4497,6 +4452,108 @@ impl ProjectPanel { ) } + fn details_for_entry( + &self, + entry: &Entry, + worktree_id: WorktreeId, + root_name: &OsStr, + entries_paths: &HashSet>, + git_status: GitSummary, + sticky: Option, + _window: &mut Window, + cx: &mut Context, + ) -> EntryDetails { + let (show_file_icons, show_folder_icons) = { + let settings = ProjectPanelSettings::get_global(cx); + (settings.file_icons, settings.folder_icons) + }; + + let expanded_entry_ids = self + .expanded_dir_ids + .get(&worktree_id) + .map(Vec::as_slice) + .unwrap_or(&[]); + let is_expanded = expanded_entry_ids.binary_search(&entry.id).is_ok(); + + let icon = match entry.kind { + EntryKind::File => { + if show_file_icons { + FileIcons::get_icon(&entry.path, cx) + } else { + None + } + } + _ => { + if show_folder_icons { + FileIcons::get_folder_icon(is_expanded, cx) + } else { + FileIcons::get_chevron_icon(is_expanded, cx) + } + } + }; + + let (depth, difference) = + ProjectPanel::calculate_depth_and_difference(&entry, entries_paths); + + let filename = match difference { + diff if diff > 1 => entry + .path + .iter() + .skip(entry.path.components().count() - diff) + .collect::() + .to_str() + .unwrap_or_default() + .to_string(), + _ => entry + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| root_name.to_string_lossy().to_string()), + }; + + let selection = SelectedEntry { + worktree_id, + entry_id: entry.id, + }; + let is_marked = self.marked_entries.contains(&selection); + let is_selected = self.selection == Some(selection); + + let diagnostic_severity = self + .diagnostics + .get(&(worktree_id, entry.path.to_path_buf())) + .cloned(); + + let filename_text_color = + entry_git_aware_label_color(git_status, entry.is_ignored, is_marked); + + let is_cut = self + .clipboard + .as_ref() + .map_or(false, |e| e.is_cut() && e.items().contains(&selection)); + + EntryDetails { + filename, + icon, + path: entry.path.clone(), + depth, + kind: entry.kind, + is_ignored: entry.is_ignored, + is_expanded, + is_selected, + is_marked, + is_editing: false, + is_processing: false, + is_cut, + sticky, + filename_text_color, + diagnostic_severity, + git_status, + is_private: entry.is_private, + worktree_id, + canonical_path: entry.canonical_path.clone(), + } + } + fn render_vertical_scrollbar(&self, cx: &mut Context) -> Option> { if !Self::should_show_scrollbar(cx) || !(self.show_scrollbar || self.vertical_scrollbar_state.is_dragging()) @@ -4751,6 +4808,156 @@ impl ProjectPanel { } None } + + fn candidate_entries_in_range_for_sticky( + &self, + range: Range, + _window: &mut Window, + _cx: &mut Context, + ) -> Vec { + let mut result = Vec::new(); + let mut current_offset = 0; + + for (_, visible_worktree_entries, entries_paths) in &self.visible_entries { + let worktree_len = visible_worktree_entries.len(); + let worktree_end_offset = current_offset + worktree_len; + + if current_offset >= range.end { + break; + } + + if worktree_end_offset > range.start { + let local_start = range.start.saturating_sub(current_offset); + let local_end = range.end.saturating_sub(current_offset).min(worktree_len); + + let paths = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| e.path.clone()) + .collect() + }); + + let entries_from_this_worktree = visible_worktree_entries[local_start..local_end] + .iter() + .enumerate() + .map(|(i, entry)| { + let (depth, _) = Self::calculate_depth_and_difference(&entry.entry, paths); + StickyProjectPanelCandidate { + index: current_offset + local_start + i, + depth, + } + }); + + result.extend(entries_from_this_worktree); + } + + current_offset = worktree_end_offset; + } + + result + } + + fn render_sticky_entries( + &self, + child: StickyProjectPanelCandidate, + window: &mut Window, + cx: &mut Context, + ) -> SmallVec<[AnyElement; 8]> { + let project = self.project.read(cx); + + let Some((worktree_id, entry_ref)) = self.entry_at_index(child.index) else { + return SmallVec::new(); + }; + + let Some((_, visible_worktree_entries, entries_paths)) = self + .visible_entries + .iter() + .find(|(id, _, _)| *id == worktree_id) + else { + return SmallVec::new(); + }; + + let Some(worktree) = project.worktree_for_id(worktree_id, cx) else { + return SmallVec::new(); + }; + let worktree = worktree.read(cx).snapshot(); + + let paths = entries_paths.get_or_init(|| { + visible_worktree_entries + .iter() + .map(|e| e.path.clone()) + .collect() + }); + + let mut sticky_parents = Vec::new(); + let mut current_path = entry_ref.path.clone(); + + 'outer: loop { + if let Some(parent_path) = current_path.parent() { + for ancestor_path in parent_path.ancestors() { + if paths.contains(ancestor_path) { + if let Some(parent_entry) = worktree.entry_for_path(ancestor_path) { + sticky_parents.push(parent_entry.clone()); + current_path = parent_entry.path.clone(); + continue 'outer; + } + } + } + } + break 'outer; + } + + sticky_parents.reverse(); + + let git_status_enabled = ProjectPanelSettings::get_global(cx).git_status; + let root_name = OsStr::new(worktree.root_name()); + + let git_summaries_by_id = if git_status_enabled { + visible_worktree_entries + .iter() + .map(|e| (e.id, e.git_summary)) + .collect::>() + } else { + Default::default() + }; + + sticky_parents + .iter() + .enumerate() + .map(|(index, entry)| { + let git_status = git_summaries_by_id + .get(&entry.id) + .copied() + .unwrap_or_default(); + let sticky_details = Some(StickyDetails { + sticky_index: index, + }); + let details = self.details_for_entry( + entry, + worktree_id, + root_name, + paths, + git_status, + sticky_details, + window, + cx, + ); + self.render_entry(entry.id, details, window, cx).into_any() + }) + .collect() + } +} + +#[derive(Clone)] +struct StickyProjectPanelCandidate { + index: usize, + depth: usize, +} + +impl StickyCandidate for StickyProjectPanelCandidate { + fn depth(&self) -> usize { + self.depth + } } fn item_width_estimate(depth: usize, item_text_chars: usize, is_symlink: bool) -> usize { @@ -4769,6 +4976,7 @@ impl Render for ProjectPanel { let indent_size = ProjectPanelSettings::get_global(cx).indent_size; let show_indent_guides = ProjectPanelSettings::get_global(cx).indent_guides.show == ShowIndentGuides::Always; + let show_sticky_scroll = ProjectPanelSettings::get_global(cx).sticky_scroll; let is_local = project.is_local(); if has_worktree { @@ -4963,6 +5171,17 @@ impl Render for ProjectPanel { items }) }) + .when(show_sticky_scroll, |list| { + list.with_top_slot(ui::sticky_items( + cx.entity().clone(), + |this, range, window, cx| { + this.candidate_entries_in_range_for_sticky(range, window, cx) + }, + |this, marker_entry, window, cx| { + this.render_sticky_entries(marker_entry, window, cx) + }, + )) + }) .when(show_indent_guides, |list| { list.with_decoration( ui::indent_guides( @@ -5079,7 +5298,7 @@ impl Render for ProjectPanel { .anchor(gpui::Corner::TopLeft) .child(menu.clone()), ) - .with_priority(1) + .with_priority(3) })) } else { v_flex() diff --git a/crates/project_panel/src/project_panel_settings.rs b/crates/project_panel/src/project_panel_settings.rs index 31f4a21b0973c430bbddff168bafc3c40c69aa3c..9057480972a07b25ad30917a03ccf871b0bb6e3f 100644 --- a/crates/project_panel/src/project_panel_settings.rs +++ b/crates/project_panel/src/project_panel_settings.rs @@ -40,6 +40,7 @@ pub struct ProjectPanelSettings { pub git_status: bool, pub indent_size: f32, pub indent_guides: IndentGuidesSettings, + pub sticky_scroll: bool, pub auto_reveal_entries: bool, pub auto_fold_dirs: bool, pub scrollbar: ScrollbarSettings, @@ -150,6 +151,10 @@ pub struct ProjectPanelSettingsContent { /// /// Default: false pub hide_root: Option, + /// Whether to stick parent directories at top of the project panel. + /// + /// Default: true + pub sticky_scroll: Option, } impl Settings for ProjectPanelSettings { diff --git a/crates/ui/src/components.rs b/crates/ui/src/components.rs index 237403d4ba053646108e88546242df1f07cdc8ab..88676e8a2bbe383538e91499a71ca908b2057203 100644 --- a/crates/ui/src/components.rs +++ b/crates/ui/src/components.rs @@ -30,6 +30,7 @@ mod scrollbar; mod settings_container; mod settings_group; mod stack; +mod sticky_items; mod tab; mod tab_bar; mod toggle; @@ -70,6 +71,7 @@ pub use scrollbar::*; pub use settings_container::*; pub use settings_group::*; pub use stack::*; +pub use sticky_items::*; pub use tab::*; pub use tab_bar::*; pub use toggle::*; diff --git a/crates/ui/src/components/sticky_items.rs b/crates/ui/src/components/sticky_items.rs new file mode 100644 index 0000000000000000000000000000000000000000..e5ef0cdf27daae5ccbd9fc1eeceac631e8ce757b --- /dev/null +++ b/crates/ui/src/components/sticky_items.rs @@ -0,0 +1,150 @@ +use std::ops::Range; + +use gpui::{ + AnyElement, App, AvailableSpace, Bounds, Context, Entity, Pixels, Render, UniformListTopSlot, + Window, point, size, +}; +use smallvec::SmallVec; + +pub trait StickyCandidate { + fn depth(&self) -> usize; +} + +pub struct StickyItems { + compute_fn: Box, &mut Window, &mut App) -> Vec>, + render_fn: Box SmallVec<[AnyElement; 8]>>, + last_item_is_drifting: bool, + anchor_index: Option, +} + +pub fn sticky_items( + entity: Entity, + compute_fn: impl Fn(&mut V, Range, &mut Window, &mut Context) -> Vec + 'static, + render_fn: impl Fn(&mut V, T, &mut Window, &mut Context) -> SmallVec<[AnyElement; 8]> + 'static, +) -> StickyItems +where + V: Render, + T: StickyCandidate + Clone + 'static, +{ + let entity_compute = entity.clone(); + let entity_render = entity.clone(); + + let compute_fn = Box::new( + move |range: Range, window: &mut Window, cx: &mut App| -> Vec { + entity_compute.update(cx, |view, cx| compute_fn(view, range, window, cx)) + }, + ); + let render_fn = Box::new( + move |entry: T, window: &mut Window, cx: &mut App| -> SmallVec<[AnyElement; 8]> { + entity_render.update(cx, |view, cx| render_fn(view, entry, window, cx)) + }, + ); + StickyItems { + compute_fn, + render_fn, + last_item_is_drifting: false, + anchor_index: None, + } +} + +impl UniformListTopSlot for StickyItems +where + T: StickyCandidate + Clone + 'static, +{ + fn compute( + &mut self, + visible_range: Range, + window: &mut Window, + cx: &mut App, + ) -> SmallVec<[AnyElement; 8]> { + let entries = (self.compute_fn)(visible_range.clone(), window, cx); + + let mut anchor_entry = None; + + let mut iter = entries.iter().enumerate().peekable(); + while let Some((ix, current_entry)) = iter.next() { + let current_depth = current_entry.depth(); + let index_in_range = ix; + + if current_depth < index_in_range { + anchor_entry = Some(current_entry.clone()); + break; + } + + if let Some(&(_next_ix, next_entry)) = iter.peek() { + let next_depth = next_entry.depth(); + + if next_depth < current_depth && next_depth < index_in_range { + self.last_item_is_drifting = true; + self.anchor_index = Some(visible_range.start + ix); + anchor_entry = Some(current_entry.clone()); + break; + } + } + } + + if let Some(anchor_entry) = anchor_entry { + (self.render_fn)(anchor_entry, window, cx) + } else { + SmallVec::new() + } + } + + fn prepaint( + &self, + items: &mut SmallVec<[AnyElement; 8]>, + bounds: Bounds, + item_height: Pixels, + scroll_offset: gpui::Point, + padding: gpui::Edges, + can_scroll_horizontally: bool, + window: &mut Window, + cx: &mut App, + ) { + let items_count = items.len(); + + for (ix, item) in items.iter_mut().enumerate() { + let mut item_y_offset = None; + if ix == items_count - 1 && self.last_item_is_drifting { + if let Some(anchor_index) = self.anchor_index { + let scroll_top = -scroll_offset.y; + let anchor_top = item_height * anchor_index; + let sticky_area_height = item_height * items_count; + item_y_offset = + Some((anchor_top - scroll_top - sticky_area_height).min(Pixels::ZERO)); + }; + } + + let sticky_origin = bounds.origin + + point( + if can_scroll_horizontally { + scroll_offset.x + padding.left + } else { + scroll_offset.x + }, + item_height * ix + padding.top + item_y_offset.unwrap_or(Pixels::ZERO), + ); + + let available_width = if can_scroll_horizontally { + bounds.size.width + scroll_offset.x.abs() + } else { + bounds.size.width + }; + + let available_space = size( + AvailableSpace::Definite(available_width), + AvailableSpace::Definite(item_height), + ); + + item.layout_as_root(available_space, window, cx); + item.prepaint_at(sticky_origin, window, cx); + } + } + + fn paint(&self, items: &mut SmallVec<[AnyElement; 8]>, window: &mut Window, cx: &mut App) { + // reverse so that last item is bottom most among sticky items + for item in items.iter_mut().rev() { + item.paint(window, cx); + } + } +} From 6b456ede49d8d95acc4d257bdaaf5ca6190b50db Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 7 Jul 2025 11:45:54 +0530 Subject: [PATCH 04/21] languages: Fix string override to match just `string_fragment` part of `template_string` (#33997) Closes #33703 `template_string` consists of `template_substitution` and `string_fragment` chunks. `template_substitution` should not be considered a string. ```ts const variable = `this is a string_fragment but ${this.is.template_substitution}`; ``` Release Notes: - Fixed auto-complete not showing on typing `.` character in template literal string in JavaScript and TypeScript files. --- crates/languages/src/javascript/overrides.scm | 7 +++---- crates/languages/src/tsx/overrides.scm | 7 +++---- crates/languages/src/typescript/overrides.scm | 3 +++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/languages/src/javascript/overrides.scm b/crates/languages/src/javascript/overrides.scm index d93c8b5aea27b1fced6d021c68403348a97bb9e9..6dbbc88ef924c2cac65aaf9ff7e7dba87b99a359 100644 --- a/crates/languages/src/javascript/overrides.scm +++ b/crates/languages/src/javascript/overrides.scm @@ -1,9 +1,8 @@ (comment) @comment.inclusive -[ - (string) - (template_string) -] @string +(string) @string + +(template_string (string_fragment) @string) (jsx_element) @element diff --git a/crates/languages/src/tsx/overrides.scm b/crates/languages/src/tsx/overrides.scm index b26d010ce34b58cac34e516075c8c010525ed5fe..f5a51af33fee340762d6b689e78d2e94e9c84901 100644 --- a/crates/languages/src/tsx/overrides.scm +++ b/crates/languages/src/tsx/overrides.scm @@ -1,9 +1,8 @@ (comment) @comment.inclusive -[ - (string) - (template_string) -] @string +(string) @string + +(template_string (string_fragment) @string) (jsx_element) @element diff --git a/crates/languages/src/typescript/overrides.scm b/crates/languages/src/typescript/overrides.scm index 17ad7be339ccb2e670ebcf225b1ab9d2b9af40ae..8f437a1424af06aa4855aac67511926181977936 100644 --- a/crates/languages/src/typescript/overrides.scm +++ b/crates/languages/src/typescript/overrides.scm @@ -1,6 +1,9 @@ (comment) @comment.inclusive + (string) @string +(template_string (string_fragment) @string) + (_ value: (call_expression function: (identifier) @function_name_before_type_arguments type_arguments: (type_arguments))) From 83562fca77ff176e2519453ff924e5d6fc987b1c Mon Sep 17 00:00:00 2001 From: Liam <33645555+lj3954@users.noreply.github.com> Date: Mon, 7 Jul 2025 08:24:17 +0000 Subject: [PATCH 05/21] copilot: Indicate whether a request is initiated by an agent to Copilot API (#33895) Per [GitHub's documentation for VSCode's agent mode](https://docs.github.com/en/copilot/how-tos/chat/asking-github-copilot-questions-in-your-ide#agent-mode), a premium request is charged per user-submitted prompt. rather than per individual request the agent makes to an LLM. This PR matches Zed's functionality to VSCode's, accurately indicating to GitHub's API whether a given request is initiated by the user or by an agent, allowing a user to be metered only for prompts they send. See also: #31068 Release Notes: - Improve Copilot premium request tracking --- crates/copilot/src/copilot_chat.rs | 16 ++++++++++++++-- .../src/provider/copilot_chat.rs | 17 ++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index b1fa1565f30ed79fdff763964708fe01c62d023f..4c91b4fedb790ab3500273ff21aba767cacd28e0 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -528,6 +528,7 @@ impl CopilotChat { pub async fn stream_completion( request: Request, + is_user_initiated: bool, mut cx: AsyncApp, ) -> Result>> { let this = cx @@ -562,7 +563,14 @@ impl CopilotChat { }; let api_url = configuration.api_url_from_endpoint(&token.api_endpoint); - stream_completion(client.clone(), token.api_key, api_url.into(), request).await + stream_completion( + client.clone(), + token.api_key, + api_url.into(), + request, + is_user_initiated, + ) + .await } pub fn set_configuration( @@ -697,6 +705,7 @@ async fn stream_completion( api_key: String, completion_url: Arc, request: Request, + is_user_initiated: bool, ) -> Result>> { let is_vision_request = request.messages.iter().any(|message| match message { ChatMessage::User { content } @@ -707,6 +716,8 @@ async fn stream_completion( _ => false, }); + let request_initiator = if is_user_initiated { "user" } else { "agent" }; + let mut request_builder = HttpRequest::builder() .method(Method::POST) .uri(completion_url.as_ref()) @@ -719,7 +730,8 @@ async fn stream_completion( ) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") - .header("Copilot-Integration-Id", "vscode-chat"); + .header("Copilot-Integration-Id", "vscode-chat") + .header("X-Initiator", request_initiator); if is_vision_request { request_builder = diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index 5411fbc63c10d84d96e2d85bf77c453bf66b5411..d9a84f1eb74465a0d5e72591d450802d5708cb20 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -30,6 +30,7 @@ use settings::SettingsStore; use std::time::Duration; use ui::prelude::*; use util::debug_panic; +use zed_llm_client::CompletionIntent; use super::anthropic::count_anthropic_tokens; use super::google::count_google_tokens; @@ -268,6 +269,19 @@ impl LanguageModel for CopilotChatLanguageModel { LanguageModelCompletionError, >, > { + let is_user_initiated = request.intent.is_none_or(|intent| match intent { + CompletionIntent::UserPrompt + | CompletionIntent::ThreadContextSummarization + | CompletionIntent::InlineAssist + | CompletionIntent::TerminalInlineAssist + | CompletionIntent::GenerateGitCommitMessage => true, + + CompletionIntent::ToolResults + | CompletionIntent::ThreadSummarization + | CompletionIntent::CreateFile + | CompletionIntent::EditFile => false, + }); + let copilot_request = match into_copilot_chat(&self.model, request) { Ok(request) => request, Err(err) => return futures::future::ready(Err(err.into())).boxed(), @@ -276,7 +290,8 @@ impl LanguageModel for CopilotChatLanguageModel { let request_limiter = self.request_limiter.clone(); let future = cx.spawn(async move |cx| { - let request = CopilotChat::stream_completion(copilot_request, cx.clone()); + let request = + CopilotChat::stream_completion(copilot_request, is_user_initiated, cx.clone()); request_limiter .stream(async move { let response = request.await?; From 30a441b7149d38d1cdf0a070c360b165b191c1f5 Mon Sep 17 00:00:00 2001 From: Umesh Yadav <23421535+imumesh18@users.noreply.github.com> Date: Mon, 7 Jul 2025 15:32:33 +0530 Subject: [PATCH 06/21] agent_ui: Fix disabled context servers not showing in agent setting (#33856) Previously if I set enabled: false for one the context servers in settings.json it will not show up in the settings in agent panel when I start zed. But if I enabled it from settings it properly showed up. We were filtering the configuration to only get the enabled context servers from settings.json. This PR adds fetching all of them. Release Notes: - agent: Show context servers which are disabled in settings in agent panel settings. --- crates/agent_ui/src/agent_configuration.rs | 2 +- crates/project/src/context_server_store.rs | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/agent_ui/src/agent_configuration.rs b/crates/agent_ui/src/agent_configuration.rs index d91aa5fb2200629703f2f789f293686eb4c3ad73..8bfdd507611112b2930fd07270667050796533e3 100644 --- a/crates/agent_ui/src/agent_configuration.rs +++ b/crates/agent_ui/src/agent_configuration.rs @@ -436,7 +436,7 @@ impl AgentConfiguration { window: &mut Window, cx: &mut Context, ) -> impl IntoElement { - let context_server_ids = self.context_server_store.read(cx).all_server_ids().clone(); + let context_server_ids = self.context_server_store.read(cx).configured_server_ids(); v_flex() .p(DynamicSpacing::Base16.rems(cx)) diff --git a/crates/project/src/context_server_store.rs b/crates/project/src/context_server_store.rs index d2541e1b31fe6a798edc92bb070a17b631f61f98..fd31e638d4bf7774af83d430dca232d1ade74f01 100644 --- a/crates/project/src/context_server_store.rs +++ b/crates/project/src/context_server_store.rs @@ -171,6 +171,15 @@ impl ContextServerStore { ) } + /// Returns all configured context server ids, regardless of enabled state. + pub fn configured_server_ids(&self) -> Vec { + self.context_server_settings + .keys() + .cloned() + .map(ContextServerId) + .collect() + } + #[cfg(any(test, feature = "test-support"))] pub fn test( registry: Entity, From 018dbfba0948a3df6f23fe932eeb35b05c9807fb Mon Sep 17 00:00:00 2001 From: Bennet Bo Fenner Date: Mon, 7 Jul 2025 12:53:59 +0200 Subject: [PATCH 07/21] agent: Show line numbers of symbols when using `@symbol` (#34004) Closes #ISSUE Release Notes: - N/A --- crates/agent_ui/src/context_picker/completion_provider.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/agent_ui/src/context_picker/completion_provider.rs b/crates/agent_ui/src/context_picker/completion_provider.rs index ab91ded2c8e45ffd8c840f9dacaa413137dacd51..b377e40b193d090a61b88232098fd45645a2ab4f 100644 --- a/crates/agent_ui/src/context_picker/completion_provider.rs +++ b/crates/agent_ui/src/context_picker/completion_provider.rs @@ -686,6 +686,7 @@ impl ContextPickerCompletionProvider { let mut label = CodeLabel::plain(symbol.name.clone(), None); label.push_str(" ", None); label.push_str(&file_name, comment_id); + label.push_str(&format!(" L{}", symbol.range.start.0.row + 1), comment_id); let new_text = format!("{} ", MentionLink::for_symbol(&symbol.name, &full_path)); let new_text_len = new_text.len(); From c99e42a3d6d57b9f6155a17e99ef9ca5aa5e694d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=B0=8F=E7=99=BD?= <364772080@qq.com> Date: Mon, 7 Jul 2025 20:21:48 +0800 Subject: [PATCH 08/21] windows: Properly handle surrogates (#34006) Closes #33791 Surrogate pairs are now handled correctly, so input from tools like `WinCompose` is properly received. Release Notes: - N/A --- crates/gpui/src/platform/windows/events.rs | 41 ++++++++++++++++++---- crates/gpui/src/platform/windows/window.rs | 3 ++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/platform/windows/events.rs b/crates/gpui/src/platform/windows/events.rs index a43fdc097f24a5c30544817f60c992829dbc831a..8b8964b2dfa827784ad5bfc2281835d64f4d7fbc 100644 --- a/crates/gpui/src/platform/windows/events.rs +++ b/crates/gpui/src/platform/windows/events.rs @@ -466,12 +466,7 @@ fn handle_keyup_msg( } fn handle_char_msg(wparam: WPARAM, state_ptr: Rc) -> Option { - let Some(input) = char::from_u32(wparam.0 as u32) - .filter(|c| !c.is_control()) - .map(String::from) - else { - return Some(1); - }; + let input = parse_char_message(wparam, &state_ptr)?; with_input_handler(&state_ptr, |input_handler| { input_handler.replace_text_in_range(None, &input); }); @@ -1228,6 +1223,36 @@ fn handle_input_language_changed( Some(0) } +#[inline] +fn parse_char_message(wparam: WPARAM, state_ptr: &Rc) -> Option { + let code_point = wparam.loword(); + let mut lock = state_ptr.state.borrow_mut(); + // https://www.unicode.org/versions/Unicode16.0.0/core-spec/chapter-3/#G2630 + match code_point { + 0xD800..=0xDBFF => { + // High surrogate, wait for low surrogate + lock.pending_surrogate = Some(code_point); + None + } + 0xDC00..=0xDFFF => { + if let Some(high_surrogate) = lock.pending_surrogate.take() { + // Low surrogate, combine with pending high surrogate + String::from_utf16(&[high_surrogate, code_point]).ok() + } else { + // Invalid low surrogate without a preceding high surrogate + log::warn!( + "Received low surrogate without a preceding high surrogate: {code_point:x}" + ); + None + } + } + _ => { + lock.pending_surrogate = None; + String::from_utf16(&[code_point]).ok() + } + } +} + #[inline] fn translate_message(handle: HWND, wparam: WPARAM, lparam: LPARAM) { let msg = MSG { @@ -1270,6 +1295,10 @@ where capslock: current_capslock(), })) } + VK_PACKET => { + translate_message(handle, wparam, lparam); + None + } VK_CAPITAL => { let capslock = current_capslock(); if state diff --git a/crates/gpui/src/platform/windows/window.rs b/crates/gpui/src/platform/windows/window.rs index 5c7dd07c4857c8d99dd8dcde294942cd33bf0573..5703a82815eb0679ca3668a13c08f3e9affa3696 100644 --- a/crates/gpui/src/platform/windows/window.rs +++ b/crates/gpui/src/platform/windows/window.rs @@ -43,6 +43,7 @@ pub struct WindowsWindowState { pub callbacks: Callbacks, pub input_handler: Option, + pub pending_surrogate: Option, pub last_reported_modifiers: Option, pub last_reported_capslock: Option, pub system_key_handled: bool, @@ -105,6 +106,7 @@ impl WindowsWindowState { let renderer = windows_renderer::init(gpu_context, hwnd, transparent)?; let callbacks = Callbacks::default(); let input_handler = None; + let pending_surrogate = None; let last_reported_modifiers = None; let last_reported_capslock = None; let system_key_handled = false; @@ -126,6 +128,7 @@ impl WindowsWindowState { min_size, callbacks, input_handler, + pending_surrogate, last_reported_modifiers, last_reported_capslock, system_key_handled, From 955580dae6f8e9faf8a64d145f287d3710c08c11 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 7 Jul 2025 09:51:30 -0400 Subject: [PATCH 09/21] Adjust Go outline query for method definition to avoid pesky whitespace (#33971) Closes #33951 There's an adjustment that kicks in to extend `name_ranges` when we capture more than one `@name` for an outline `@item`. That was happening here because we captured both the parameter name for the method receiver and the name of the method as `@name`. It seems like only the second one should have that annotation. Release Notes: - Fixed extraneous leading space in `$ZED_SYMBOL` when used with Go methods. --- crates/languages/src/go/outline.scm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/languages/src/go/outline.scm b/crates/languages/src/go/outline.scm index 0e4d6a52f3b5d48538c30d658f98ec3692b966c6..e37ae7e5723027af3227f947898b301c0305a4f5 100644 --- a/crates/languages/src/go/outline.scm +++ b/crates/languages/src/go/outline.scm @@ -25,7 +25,7 @@ receiver: (parameter_list "(" @context (parameter_declaration - name: (_) @name + name: (_) @context type: (_) @context) ")" @context) name: (field_identifier) @name From 82aee6bcf70f4d1039f748e6922545c79a534a18 Mon Sep 17 00:00:00 2001 From: Kirill Bulatov Date: Mon, 7 Jul 2025 17:28:18 +0300 Subject: [PATCH 10/21] Another lsp tool UI migration (#34009) https://github.com/user-attachments/assets/54182f0d-43e9-4482-89b9-94db5ddaabf8 Release Notes: - N/A --- Cargo.lock | 1 - crates/agent_ui/src/context_picker.rs | 2 + .../src/inline_completion_button.rs | 4 - crates/language_tools/Cargo.toml | 1 - crates/language_tools/src/lsp_tool.rs | 967 ++++++++---------- crates/ui/src/components/context_menu.rs | 25 +- crates/ui/src/components/popover_menu.rs | 18 + 7 files changed, 484 insertions(+), 534 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index baed77a49fd7c35af5ede122c854746308fde162..921eea00f8147f4715934ed0c0ab2015945cd4ab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9023,7 +9023,6 @@ dependencies = [ "itertools 0.14.0", "language", "lsp", - "picker", "project", "release_channel", "serde_json", diff --git a/crates/agent_ui/src/context_picker.rs b/crates/agent_ui/src/context_picker.rs index f303f34a52856a068f1d2da33cf1f0a4fb5813a5..73fc0b36ce33853abd7b8689ef251855e5aca6ac 100644 --- a/crates/agent_ui/src/context_picker.rs +++ b/crates/agent_ui/src/context_picker.rs @@ -426,6 +426,7 @@ impl ContextPicker { this.add_recent_file(project_path.clone(), window, cx); }) }, + None, ) } RecentEntry::Thread(thread) => { @@ -443,6 +444,7 @@ impl ContextPicker { .detach_and_log_err(cx); }) }, + None, ) } } diff --git a/crates/inline_completion_button/src/inline_completion_button.rs b/crates/inline_completion_button/src/inline_completion_button.rs index f8123d676a001427b8b0350d53cdd7ab8b1041ab..7e6b77b93deafbb971980d8b2d19f33f2fa348b4 100644 --- a/crates/inline_completion_button/src/inline_completion_button.rs +++ b/crates/inline_completion_button/src/inline_completion_button.rs @@ -835,10 +835,6 @@ impl InlineCompletionButton { cx.notify(); } - - pub fn toggle_menu(&mut self, window: &mut Window, cx: &mut Context) { - self.popover_menu_handle.toggle(window, cx); - } } impl StatusItemView for InlineCompletionButton { diff --git a/crates/language_tools/Cargo.toml b/crates/language_tools/Cargo.toml index ffdc939809145b319d5421adf5b8a923604e74fe..45af7518d589166e26788203c919d2267b544756 100644 --- a/crates/language_tools/Cargo.toml +++ b/crates/language_tools/Cargo.toml @@ -24,7 +24,6 @@ gpui.workspace = true itertools.workspace = true language.workspace = true lsp.workspace = true -picker.workspace = true project.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/language_tools/src/lsp_tool.rs b/crates/language_tools/src/lsp_tool.rs index 24a53ae2529b23b45e2478227109506839c12a89..6cd2f83184d946fdbf133f5d7d17d188d294ee13 100644 --- a/crates/language_tools/src/lsp_tool.rs +++ b/crates/language_tools/src/lsp_tool.rs @@ -1,19 +1,18 @@ -use std::{collections::hash_map, path::PathBuf, sync::Arc, time::Duration}; +use std::{collections::hash_map, path::PathBuf, rc::Rc, time::Duration}; use client::proto; use collections::{HashMap, HashSet}; use editor::{Editor, EditorEvent}; use feature_flags::FeatureFlagAppExt as _; -use gpui::{ - Corner, DismissEvent, Entity, Focusable as _, MouseButton, Subscription, Task, WeakEntity, - actions, -}; +use gpui::{Corner, Entity, Subscription, Task, WeakEntity, actions}; use language::{BinaryStatus, BufferId, LocalFile, ServerHealth}; use lsp::{LanguageServerId, LanguageServerName, LanguageServerSelector}; -use picker::{Picker, PickerDelegate, popover_menu::PickerPopoverMenu}; use project::{LspStore, LspStoreEvent, project_settings::ProjectSettings}; use settings::{Settings as _, SettingsStore}; -use ui::{Context, Indicator, PopoverMenuHandle, Tooltip, Window, prelude::*}; +use ui::{ + Context, ContextMenu, ContextMenuEntry, ContextMenuItem, DocumentationAside, DocumentationSide, + Indicator, PopoverMenu, PopoverMenuHandle, Tooltip, Window, prelude::*, +}; use workspace::{StatusItemView, Workspace}; @@ -28,33 +27,38 @@ actions!( ); pub struct LspTool { - state: Entity, - popover_menu_handle: PopoverMenuHandle>, - lsp_picker: Option>>, + server_state: Entity, + popover_menu_handle: PopoverMenuHandle, + lsp_menu: Option>, + lsp_menu_refresh: Task<()>, _subscriptions: Vec, } -struct PickerState { +#[derive(Debug)] +struct LanguageServerState { + items: Vec, + other_servers_start_index: Option, workspace: WeakEntity, lsp_store: WeakEntity, active_editor: Option, language_servers: LanguageServers, } -#[derive(Debug)] -pub struct LspPickerDelegate { - state: Entity, - selected_index: usize, - items: Vec, - other_servers_start_index: Option, -} - struct ActiveEditor { editor: WeakEntity, _editor_subscription: Subscription, editor_buffers: HashSet, } +impl std::fmt::Debug for ActiveEditor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ActiveEditor") + .field("editor", &self.editor) + .field("editor_buffers", &self.editor_buffers) + .finish_non_exhaustive() + } +} + #[derive(Debug, Default, Clone)] struct LanguageServers { health_statuses: HashMap, @@ -104,192 +108,154 @@ impl LanguageServerHealthStatus { } } -impl LspPickerDelegate { - fn regenerate_items(&mut self, cx: &mut Context>) { - self.state.update(cx, |state, cx| { - let editor_buffers = state - .active_editor - .as_ref() - .map(|active_editor| active_editor.editor_buffers.clone()) - .unwrap_or_default(); - let editor_buffer_paths = editor_buffers - .iter() - .filter_map(|buffer_id| { - let buffer_path = state - .lsp_store - .update(cx, |lsp_store, cx| { - Some( - project::File::from_dyn( - lsp_store - .buffer_store() +impl LanguageServerState { + fn fill_menu(&self, mut menu: ContextMenu, cx: &mut Context) -> ContextMenu { + let lsp_logs = cx + .try_global::() + .and_then(|lsp_logs| lsp_logs.0.upgrade()); + let lsp_store = self.lsp_store.upgrade(); + let Some((lsp_logs, lsp_store)) = lsp_logs.zip(lsp_store) else { + return menu; + }; + + for (i, item) in self.items.iter().enumerate() { + if let LspItem::ToggleServersButton { restart } = item { + let label = if *restart { + "Restart All Servers" + } else { + "Stop All Servers" + }; + let restart = *restart; + let button = ContextMenuEntry::new(label).handler({ + let state = cx.entity(); + move |_, cx| { + let lsp_store = state.read(cx).lsp_store.clone(); + lsp_store + .update(cx, |lsp_store, cx| { + if restart { + let Some(workspace) = state.read(cx).workspace.upgrade() else { + return; + }; + let project = workspace.read(cx).project().clone(); + let buffer_store = project.read(cx).buffer_store().clone(); + let worktree_store = project.read(cx).worktree_store(); + + let buffers = state .read(cx) - .get(*buffer_id)? + .language_servers + .servers_per_buffer_abs_path + .keys() + .filter_map(|abs_path| { + worktree_store.read(cx).find_worktree(abs_path, cx) + }) + .filter_map(|(worktree, relative_path)| { + let entry = + worktree.read(cx).entry_for_path(&relative_path)?; + project.read(cx).path_for_entry(entry.id, cx) + }) + .filter_map(|project_path| { + buffer_store.read(cx).get_by_path(&project_path) + }) + .collect(); + let selectors = state .read(cx) - .file(), - )? - .abs_path(cx), - ) - }) - .ok()??; - Some(buffer_path) - }) - .collect::>(); - - let mut servers_with_health_checks = HashSet::default(); - let mut server_ids_with_health_checks = HashSet::default(); - let mut buffer_servers = - Vec::with_capacity(state.language_servers.health_statuses.len()); - let mut other_servers = - Vec::with_capacity(state.language_servers.health_statuses.len()); - let buffer_server_ids = editor_buffer_paths - .iter() - .filter_map(|buffer_path| { - state - .language_servers - .servers_per_buffer_abs_path - .get(buffer_path) - }) - .flatten() - .fold(HashMap::default(), |mut acc, (server_id, name)| { - match acc.entry(*server_id) { - hash_map::Entry::Occupied(mut o) => { - let old_name: &mut Option<&LanguageServerName> = o.get_mut(); - if old_name.is_none() { - *old_name = name.as_ref(); - } - } - hash_map::Entry::Vacant(v) => { - v.insert(name.as_ref()); - } + .items + .iter() + // Do not try to use IDs as we have stopped all servers already, when allowing to restart them all + .flat_map(|item| match item { + LspItem::ToggleServersButton { .. } => None, + LspItem::WithHealthCheck(_, status, ..) => Some( + LanguageServerSelector::Name(status.name.clone()), + ), + LspItem::WithBinaryStatus(_, server_name, ..) => Some( + LanguageServerSelector::Name(server_name.clone()), + ), + }) + .collect(); + lsp_store.restart_language_servers_for_buffers( + buffers, selectors, cx, + ); + } else { + lsp_store.stop_all_language_servers(cx); + } + }) + .ok(); } - acc }); - for (server_id, server_state) in &state.language_servers.health_statuses { - let binary_status = state - .language_servers - .binary_statuses - .get(&server_state.name); - servers_with_health_checks.insert(&server_state.name); - server_ids_with_health_checks.insert(*server_id); - if buffer_server_ids.contains_key(server_id) { - buffer_servers.push(ServerData::WithHealthCheck( - *server_id, - server_state, - binary_status, - )); - } else { - other_servers.push(ServerData::WithHealthCheck( - *server_id, - server_state, - binary_status, - )); - } - } - - let mut can_stop_all = false; - let mut can_restart_all = true; + menu = menu.separator().item(button); + continue; + }; + let Some(server_info) = item.server_info() else { + continue; + }; + let workspace = self.workspace.clone(); + let server_selector = server_info.server_selector(); + // TODO currently, Zed remote does not work well with the LSP logs + // https://github.com/zed-industries/zed/issues/28557 + let has_logs = lsp_store.read(cx).as_local().is_some() + && lsp_logs.read(cx).has_server_logs(&server_selector); + let status_color = server_info + .binary_status + .and_then(|binary_status| match binary_status.status { + BinaryStatus::None => None, + BinaryStatus::CheckingForUpdate + | BinaryStatus::Downloading + | BinaryStatus::Starting => Some(Color::Modified), + BinaryStatus::Stopping => Some(Color::Disabled), + BinaryStatus::Stopped => Some(Color::Disabled), + BinaryStatus::Failed { .. } => Some(Color::Error), + }) + .or_else(|| { + Some(match server_info.health? { + ServerHealth::Ok => Color::Success, + ServerHealth::Warning => Color::Warning, + ServerHealth::Error => Color::Error, + }) + }) + .unwrap_or(Color::Success); - for (server_name, status) in state - .language_servers - .binary_statuses - .iter() - .filter(|(name, _)| !servers_with_health_checks.contains(name)) + if self + .other_servers_start_index + .is_some_and(|index| index == i) { - match status.status { - BinaryStatus::None => { - can_restart_all = false; - can_stop_all = true; - } - BinaryStatus::CheckingForUpdate => { - can_restart_all = false; - } - BinaryStatus::Downloading => { - can_restart_all = false; - } - BinaryStatus::Starting => { - can_restart_all = false; - } - BinaryStatus::Stopping => { - can_restart_all = false; - } - BinaryStatus::Stopped => {} - BinaryStatus::Failed { .. } => {} - } - - let matching_server_id = state - .language_servers - .servers_per_buffer_abs_path - .iter() - .filter(|(path, _)| editor_buffer_paths.contains(path)) - .flat_map(|(_, server_associations)| server_associations.iter()) - .find_map(|(id, name)| { - if name.as_ref() == Some(server_name) { - Some(*id) - } else { - None - } - }); - if let Some(server_id) = matching_server_id { - buffer_servers.push(ServerData::WithBinaryStatus( - Some(server_id), - server_name, - status, - )); - } else { - other_servers.push(ServerData::WithBinaryStatus(None, server_name, status)); - } + menu = menu.separator(); } - - buffer_servers.sort_by_key(|data| data.name().clone()); - other_servers.sort_by_key(|data| data.name().clone()); - - let mut other_servers_start_index = None; - let mut new_lsp_items = - Vec::with_capacity(buffer_servers.len() + other_servers.len() + 1); - new_lsp_items.extend(buffer_servers.into_iter().map(ServerData::into_lsp_item)); - if !new_lsp_items.is_empty() { - other_servers_start_index = Some(new_lsp_items.len()); - } - new_lsp_items.extend(other_servers.into_iter().map(ServerData::into_lsp_item)); - if !new_lsp_items.is_empty() { - if can_stop_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: false }); - } else if can_restart_all { - new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); - } - } - - self.items = new_lsp_items; - self.other_servers_start_index = other_servers_start_index; - }); - } - - fn server_info(&self, ix: usize) -> Option { - match self.items.get(ix)? { - LspItem::ToggleServersButton { .. } => None, - LspItem::WithHealthCheck( - language_server_id, - language_server_health_status, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_health_status.name.clone(), - id: Some(*language_server_id), - health: language_server_health_status.health(), - binary_status: language_server_binary_status.clone(), - message: language_server_health_status.message(), - }), - LspItem::WithBinaryStatus( - server_id, - language_server_name, - language_server_binary_status, - ) => Some(ServerInfo { - name: language_server_name.clone(), - id: *server_id, - health: None, - binary_status: Some(language_server_binary_status.clone()), - message: language_server_binary_status.message.clone(), - }), + menu = menu.item(ContextMenuItem::custom_entry( + move |_, _| { + h_flex() + .gap_1() + .w_full() + .child(Indicator::dot().color(status_color)) + .child(Label::new(server_info.name.0.clone())) + .when(!has_logs, |div| div.cursor_default()) + .into_any_element() + }, + { + let lsp_logs = lsp_logs.clone(); + move |window, cx| { + if !has_logs { + cx.propagate(); + return; + } + lsp_logs.update(cx, |lsp_logs, cx| { + lsp_logs.open_server_trace( + workspace.clone(), + server_selector.clone(), + window, + cx, + ); + }); + } + }, + server_info.message.map(|server_message| { + DocumentationAside::new( + DocumentationSide::Right, + Rc::new(move |_| Label::new(server_message.clone()).into_any_element()), + ) + }), + )); } + menu } } @@ -375,6 +341,36 @@ enum LspItem { }, } +impl LspItem { + fn server_info(&self) -> Option { + match self { + LspItem::ToggleServersButton { .. } => None, + LspItem::WithHealthCheck( + language_server_id, + language_server_health_status, + language_server_binary_status, + ) => Some(ServerInfo { + name: language_server_health_status.name.clone(), + id: Some(*language_server_id), + health: language_server_health_status.health(), + binary_status: language_server_binary_status.clone(), + message: language_server_health_status.message(), + }), + LspItem::WithBinaryStatus( + server_id, + language_server_name, + language_server_binary_status, + ) => Some(ServerInfo { + name: language_server_name.clone(), + id: *server_id, + health: None, + binary_status: Some(language_server_binary_status.clone()), + message: language_server_binary_status.message.clone(), + }), + } + } +} + impl ServerData<'_> { fn name(&self) -> &LanguageServerName { match self { @@ -395,267 +391,21 @@ impl ServerData<'_> { } } -impl PickerDelegate for LspPickerDelegate { - type ListItem = AnyElement; - - fn match_count(&self) -> usize { - self.items.len() - } - - fn selected_index(&self) -> usize { - self.selected_index - } - - fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { - self.selected_index = ix; - cx.notify(); - } - - fn update_matches( - &mut self, - _: String, - _: &mut Window, - cx: &mut Context>, - ) -> Task<()> { - cx.spawn(async move |lsp_picker, cx| { - cx.background_executor() - .timer(Duration::from_millis(30)) - .await; - lsp_picker - .update(cx, |lsp_picker, cx| { - lsp_picker.delegate.regenerate_items(cx); - }) - .ok(); - }) - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::default() - } - - fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context>) { - if let Some(LspItem::ToggleServersButton { restart }) = self.items.get(self.selected_index) - { - let lsp_store = self.state.read(cx).lsp_store.clone(); - lsp_store - .update(cx, |lsp_store, cx| { - if *restart { - let Some(workspace) = self.state.read(cx).workspace.upgrade() else { - return; - }; - let project = workspace.read(cx).project().clone(); - let buffer_store = project.read(cx).buffer_store().clone(); - let worktree_store = project.read(cx).worktree_store(); - - let buffers = self - .state - .read(cx) - .language_servers - .servers_per_buffer_abs_path - .keys() - .filter_map(|abs_path| { - worktree_store.read(cx).find_worktree(abs_path, cx) - }) - .filter_map(|(worktree, relative_path)| { - let entry = worktree.read(cx).entry_for_path(&relative_path)?; - project.read(cx).path_for_entry(entry.id, cx) - }) - .filter_map(|project_path| { - buffer_store.read(cx).get_by_path(&project_path) - }) - .collect(); - let selectors = self - .items - .iter() - // Do not try to use IDs as we have stopped all servers already, when allowing to restart them all - .flat_map(|item| match item { - LspItem::ToggleServersButton { .. } => None, - LspItem::WithHealthCheck(_, status, ..) => { - Some(LanguageServerSelector::Name(status.name.clone())) - } - LspItem::WithBinaryStatus(_, server_name, ..) => { - Some(LanguageServerSelector::Name(server_name.clone())) - } - }) - .collect(); - lsp_store.restart_language_servers_for_buffers(buffers, selectors, cx); - } else { - lsp_store.stop_all_language_servers(cx); - } - }) - .ok(); - } - - let Some(server_selector) = self - .server_info(self.selected_index) - .map(|info| info.server_selector()) - else { - return; - }; - let lsp_logs = cx.global::().0.clone(); - let lsp_store = self.state.read(cx).lsp_store.clone(); - let workspace = self.state.read(cx).workspace.clone(); - lsp_logs - .update(cx, |lsp_logs, cx| { - let has_logs = lsp_store - .update(cx, |lsp_store, _| { - lsp_store.as_local().is_some() && lsp_logs.has_server_logs(&server_selector) - }) - .unwrap_or(false); - if has_logs { - lsp_logs.open_server_trace(workspace, server_selector, window, cx); - } - }) - .ok(); - } - - fn dismissed(&mut self, _: &mut Window, cx: &mut Context>) { - cx.emit(DismissEvent); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - cx: &mut Context>, - ) -> Option { - let rendered_match = h_flex().px_1().gap_1(); - let rendered_match_contents = h_flex() - .id(("lsp-item", ix)) - .w_full() - .px_2() - .gap_2() - .when(selected, |server_entry| { - server_entry.bg(cx.theme().colors().element_hover) - }) - .hover(|s| s.bg(cx.theme().colors().element_hover)); - - if let Some(LspItem::ToggleServersButton { restart }) = self.items.get(ix) { - let label = Label::new(if *restart { - "Restart All Servers" - } else { - "Stop All Servers" - }); - return Some( - rendered_match - .child(rendered_match_contents.child(label)) - .into_any_element(), - ); - } - - let server_info = self.server_info(ix)?; - let workspace = self.state.read(cx).workspace.clone(); - let lsp_logs = cx.global::().0.upgrade()?; - let lsp_store = self.state.read(cx).lsp_store.upgrade()?; - let server_selector = server_info.server_selector(); - - // TODO currently, Zed remote does not work well with the LSP logs - // https://github.com/zed-industries/zed/issues/28557 - let has_logs = lsp_store.read(cx).as_local().is_some() - && lsp_logs.read(cx).has_server_logs(&server_selector); - - let status_color = server_info - .binary_status - .and_then(|binary_status| match binary_status.status { - BinaryStatus::None => None, - BinaryStatus::CheckingForUpdate - | BinaryStatus::Downloading - | BinaryStatus::Starting => Some(Color::Modified), - BinaryStatus::Stopping => Some(Color::Disabled), - BinaryStatus::Stopped => Some(Color::Disabled), - BinaryStatus::Failed { .. } => Some(Color::Error), - }) - .or_else(|| { - Some(match server_info.health? { - ServerHealth::Ok => Color::Success, - ServerHealth::Warning => Color::Warning, - ServerHealth::Error => Color::Error, - }) - }) - .unwrap_or(Color::Success); - - Some( - rendered_match - .child( - rendered_match_contents - .child(Indicator::dot().color(status_color)) - .child(Label::new(server_info.name.0.clone())) - .when_some( - server_info.message.clone(), - |server_entry, server_message| { - server_entry.tooltip(Tooltip::text(server_message.clone())) - }, - ), - ) - .when_else( - has_logs, - |server_entry| { - server_entry.on_mouse_down(MouseButton::Left, { - let workspace = workspace.clone(); - let lsp_logs = lsp_logs.downgrade(); - let server_selector = server_selector.clone(); - move |_, window, cx| { - lsp_logs - .update(cx, |lsp_logs, cx| { - lsp_logs.open_server_trace( - workspace.clone(), - server_selector.clone(), - window, - cx, - ); - }) - .ok(); - } - }) - }, - |div| div.cursor_default(), - ) - .into_any_element(), - ) - } - - fn render_editor( - &self, - editor: &Entity, - _: &mut Window, - cx: &mut Context>, - ) -> Div { - div().child(div().track_focus(&editor.focus_handle(cx))) - } - - fn separators_after_indices(&self) -> Vec { - if self.items.is_empty() { - return Vec::new(); - } - let mut indices = vec![self.items.len().saturating_sub(2)]; - if let Some(other_servers_start_index) = self.other_servers_start_index { - if other_servers_start_index > 0 { - indices.insert(0, other_servers_start_index - 1); - indices.dedup(); - } - } - indices - } -} - impl LspTool { pub fn new( workspace: &Workspace, - popover_menu_handle: PopoverMenuHandle>, + popover_menu_handle: PopoverMenuHandle, window: &mut Window, cx: &mut Context, ) -> Self { let settings_subscription = cx.observe_global_in::(window, move |lsp_tool, window, cx| { if ProjectSettings::get_global(cx).global_lsp_settings.button { - if lsp_tool.lsp_picker.is_none() { - lsp_tool.lsp_picker = - Some(Self::new_lsp_picker(lsp_tool.state.clone(), window, cx)); - cx.notify(); + if lsp_tool.lsp_menu.is_none() { + lsp_tool.refresh_lsp_menu(true, window, cx); return; } - } else if lsp_tool.lsp_picker.take().is_some() { + } else if lsp_tool.lsp_menu.take().is_some() { cx.notify(); } }); @@ -666,17 +416,20 @@ impl LspTool { lsp_tool.on_lsp_store_event(e, window, cx) }); - let state = cx.new(|_| PickerState { + let state = cx.new(|_| LanguageServerState { workspace: workspace.weak_handle(), + items: Vec::new(), + other_servers_start_index: None, lsp_store: lsp_store.downgrade(), active_editor: None, language_servers: LanguageServers::default(), }); Self { - state, + server_state: state, popover_menu_handle, - lsp_picker: None, + lsp_menu: None, + lsp_menu_refresh: Task::ready(()), _subscriptions: vec![settings_subscription, lsp_store_subscription], } } @@ -687,7 +440,7 @@ impl LspTool { window: &mut Window, cx: &mut Context, ) { - let Some(lsp_picker) = self.lsp_picker.clone() else { + if self.lsp_menu.is_none() { return; }; let mut updated = false; @@ -720,7 +473,7 @@ impl LspTool { BinaryStatus::Failed { error } } }; - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state.language_servers.update_binary_status( binary_status, status_update.message.as_deref(), @@ -737,7 +490,7 @@ impl LspTool { proto::ServerHealth::Warning => ServerHealth::Warning, proto::ServerHealth::Error => ServerHealth::Error, }; - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state.language_servers.update_server_health( *language_server_id, health, @@ -756,7 +509,7 @@ impl LspTool { message: proto::update_language_server::Variant::RegisteredForBuffer(update), .. } => { - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state .language_servers .servers_per_buffer_abs_path @@ -770,27 +523,203 @@ impl LspTool { }; if updated { - lsp_picker.update(cx, |lsp_picker, cx| { - lsp_picker.refresh(window, cx); - }); + self.refresh_lsp_menu(false, window, cx); } } - fn new_lsp_picker( - state: Entity, + fn regenerate_items(&mut self, cx: &mut App) { + self.server_state.update(cx, |state, cx| { + let editor_buffers = state + .active_editor + .as_ref() + .map(|active_editor| active_editor.editor_buffers.clone()) + .unwrap_or_default(); + let editor_buffer_paths = editor_buffers + .iter() + .filter_map(|buffer_id| { + let buffer_path = state + .lsp_store + .update(cx, |lsp_store, cx| { + Some( + project::File::from_dyn( + lsp_store + .buffer_store() + .read(cx) + .get(*buffer_id)? + .read(cx) + .file(), + )? + .abs_path(cx), + ) + }) + .ok()??; + Some(buffer_path) + }) + .collect::>(); + + let mut servers_with_health_checks = HashSet::default(); + let mut server_ids_with_health_checks = HashSet::default(); + let mut buffer_servers = + Vec::with_capacity(state.language_servers.health_statuses.len()); + let mut other_servers = + Vec::with_capacity(state.language_servers.health_statuses.len()); + let buffer_server_ids = editor_buffer_paths + .iter() + .filter_map(|buffer_path| { + state + .language_servers + .servers_per_buffer_abs_path + .get(buffer_path) + }) + .flatten() + .fold(HashMap::default(), |mut acc, (server_id, name)| { + match acc.entry(*server_id) { + hash_map::Entry::Occupied(mut o) => { + let old_name: &mut Option<&LanguageServerName> = o.get_mut(); + if old_name.is_none() { + *old_name = name.as_ref(); + } + } + hash_map::Entry::Vacant(v) => { + v.insert(name.as_ref()); + } + } + acc + }); + for (server_id, server_state) in &state.language_servers.health_statuses { + let binary_status = state + .language_servers + .binary_statuses + .get(&server_state.name); + servers_with_health_checks.insert(&server_state.name); + server_ids_with_health_checks.insert(*server_id); + if buffer_server_ids.contains_key(server_id) { + buffer_servers.push(ServerData::WithHealthCheck( + *server_id, + server_state, + binary_status, + )); + } else { + other_servers.push(ServerData::WithHealthCheck( + *server_id, + server_state, + binary_status, + )); + } + } + + let mut can_stop_all = !state.language_servers.health_statuses.is_empty(); + let mut can_restart_all = state.language_servers.health_statuses.is_empty(); + for (server_name, status) in state + .language_servers + .binary_statuses + .iter() + .filter(|(name, _)| !servers_with_health_checks.contains(name)) + { + match status.status { + BinaryStatus::None => { + can_restart_all = false; + can_stop_all |= true; + } + BinaryStatus::CheckingForUpdate => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Downloading => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Starting => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Stopping => { + can_restart_all = false; + can_stop_all = false; + } + BinaryStatus::Stopped => {} + BinaryStatus::Failed { .. } => {} + } + + let matching_server_id = state + .language_servers + .servers_per_buffer_abs_path + .iter() + .filter(|(path, _)| editor_buffer_paths.contains(path)) + .flat_map(|(_, server_associations)| server_associations.iter()) + .find_map(|(id, name)| { + if name.as_ref() == Some(server_name) { + Some(*id) + } else { + None + } + }); + if let Some(server_id) = matching_server_id { + buffer_servers.push(ServerData::WithBinaryStatus( + Some(server_id), + server_name, + status, + )); + } else { + other_servers.push(ServerData::WithBinaryStatus(None, server_name, status)); + } + } + + buffer_servers.sort_by_key(|data| data.name().clone()); + other_servers.sort_by_key(|data| data.name().clone()); + + let mut other_servers_start_index = None; + let mut new_lsp_items = + Vec::with_capacity(buffer_servers.len() + other_servers.len() + 1); + new_lsp_items.extend(buffer_servers.into_iter().map(ServerData::into_lsp_item)); + if !new_lsp_items.is_empty() { + other_servers_start_index = Some(new_lsp_items.len()); + } + new_lsp_items.extend(other_servers.into_iter().map(ServerData::into_lsp_item)); + if !new_lsp_items.is_empty() { + if can_stop_all { + new_lsp_items.push(LspItem::ToggleServersButton { restart: false }); + } else if can_restart_all { + new_lsp_items.push(LspItem::ToggleServersButton { restart: true }); + } + } + + state.items = new_lsp_items; + state.other_servers_start_index = other_servers_start_index; + }); + } + + fn refresh_lsp_menu( + &mut self, + create_if_empty: bool, window: &mut Window, cx: &mut Context, - ) -> Entity> { - cx.new(|cx| { - let mut delegate = LspPickerDelegate { - selected_index: 0, - other_servers_start_index: None, - items: Vec::new(), - state, - }; - delegate.regenerate_items(cx); - Picker::list(delegate, window, cx) - }) + ) { + if create_if_empty || self.lsp_menu.is_some() { + let state = self.server_state.clone(); + self.lsp_menu_refresh = cx.spawn_in(window, async move |lsp_tool, cx| { + cx.background_executor() + .timer(Duration::from_millis(30)) + .await; + lsp_tool + .update_in(cx, |lsp_tool, window, cx| { + lsp_tool.regenerate_items(cx); + let menu = ContextMenu::build(window, cx, |menu, _, cx| { + state.update(cx, |state, cx| state.fill_menu(menu, cx)) + }); + lsp_tool.lsp_menu = Some(menu.clone()); + // TODO kb will this work? + // what about the selections? + lsp_tool.popover_menu_handle.refresh_menu( + window, + cx, + Rc::new(move |_, _| Some(menu.clone())), + ); + cx.notify(); + }) + .ok(); + }); + } } } @@ -805,7 +734,7 @@ impl StatusItemView for LspTool { if let Some(editor) = active_pane_item.and_then(|item| item.downcast::()) { if Some(&editor) != self - .state + .server_state .read(cx) .active_editor .as_ref() @@ -819,25 +748,24 @@ impl StatusItemView for LspTool { window, |lsp_tool, _, e: &EditorEvent, window, cx| match e { EditorEvent::ExcerptsAdded { buffer, .. } => { - lsp_tool.state.update(cx, |state, cx| { + let updated = lsp_tool.server_state.update(cx, |state, cx| { if let Some(active_editor) = state.active_editor.as_mut() { let buffer_id = buffer.read(cx).remote_id(); - if active_editor.editor_buffers.insert(buffer_id) { - if let Some(picker) = &lsp_tool.lsp_picker { - picker.update(cx, |picker, cx| { - picker.refresh(window, cx) - }); - } - } + active_editor.editor_buffers.insert(buffer_id) + } else { + false } }); + if updated { + lsp_tool.refresh_lsp_menu(false, window, cx); + } } EditorEvent::ExcerptsRemoved { removed_buffer_ids, .. } => { - lsp_tool.state.update(cx, |state, cx| { + let removed = lsp_tool.server_state.update(cx, |state, _| { + let mut removed = false; if let Some(active_editor) = state.active_editor.as_mut() { - let mut removed = false; for id in removed_buffer_ids { active_editor.editor_buffers.retain(|buffer_id| { let retain = buffer_id != id; @@ -845,68 +773,53 @@ impl StatusItemView for LspTool { retain }); } - if removed { - if let Some(picker) = &lsp_tool.lsp_picker { - picker.update(cx, |picker, cx| { - picker.refresh(window, cx) - }); - } - } } + removed }); + if removed { + lsp_tool.refresh_lsp_menu(false, window, cx); + } } _ => {} }, ); - self.state.update(cx, |state, _| { + self.server_state.update(cx, |state, _| { state.active_editor = Some(ActiveEditor { editor: editor.downgrade(), _editor_subscription, editor_buffers, }); }); - - let lsp_picker = Self::new_lsp_picker(self.state.clone(), window, cx); - self.lsp_picker = Some(lsp_picker.clone()); - lsp_picker.update(cx, |lsp_picker, cx| lsp_picker.refresh(window, cx)); + self.refresh_lsp_menu(true, window, cx); } - } else if self.state.read(cx).active_editor.is_some() { - self.state.update(cx, |state, _| { + } else if self.server_state.read(cx).active_editor.is_some() { + self.server_state.update(cx, |state, _| { state.active_editor = None; }); - if let Some(lsp_picker) = self.lsp_picker.as_ref() { - lsp_picker.update(cx, |lsp_picker, cx| { - lsp_picker.refresh(window, cx); - }); - }; + self.refresh_lsp_menu(false, window, cx); } - } else if self.state.read(cx).active_editor.is_some() { - self.state.update(cx, |state, _| { + } else if self.server_state.read(cx).active_editor.is_some() { + self.server_state.update(cx, |state, _| { state.active_editor = None; }); - if let Some(lsp_picker) = self.lsp_picker.as_ref() { - lsp_picker.update(cx, |lsp_picker, cx| { - lsp_picker.refresh(window, cx); - }); - } + self.refresh_lsp_menu(false, window, cx); } } } impl Render for LspTool { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { - if !cx.is_staff() || self.state.read(cx).language_servers.is_empty() { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl ui::IntoElement { + if !cx.is_staff() + || self.server_state.read(cx).language_servers.is_empty() + || self.lsp_menu.is_none() + { return div(); } - let Some(lsp_picker) = self.lsp_picker.clone() else { - return div(); - }; - let mut has_errors = false; let mut has_warnings = false; let mut has_other_notifications = false; - let state = self.state.read(cx); + let state = self.server_state.read(cx); for server in state.language_servers.health_statuses.values() { if let Some(binary_status) = &state.language_servers.binary_statuses.get(&server.name) { has_errors |= matches!(binary_status.status, BinaryStatus::Failed { .. }); @@ -933,19 +846,21 @@ impl Render for LspTool { None }; + let lsp_tool = cx.entity().clone(); div().child( - PickerPopoverMenu::new( - lsp_picker.clone(), - IconButton::new("zed-lsp-tool-button", IconName::BoltFilledAlt) - .when_some(indicator, IconButton::indicator) - .icon_size(IconSize::Small) - .indicator_border_color(Some(cx.theme().colors().status_bar_background)), - move |window, cx| Tooltip::for_action("Language Servers", &ToggleMenu, window, cx), - Corner::BottomLeft, - cx, - ) - .with_handle(self.popover_menu_handle.clone()) - .render(window, cx), + PopoverMenu::new("lsp-tool") + .menu(move |_, cx| lsp_tool.read(cx).lsp_menu.clone()) + .anchor(Corner::BottomLeft) + .with_handle(self.popover_menu_handle.clone()) + .trigger_with_tooltip( + IconButton::new("zed-lsp-tool-button", IconName::BoltFilledAlt) + .when_some(indicator, IconButton::indicator) + .icon_size(IconSize::Small) + .indicator_border_color(Some(cx.theme().colors().status_bar_background)), + move |window, cx| { + Tooltip::for_action("Language Servers", &ToggleMenu, window, cx) + }, + ), ) } } diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index d7080f21f4f374777fab03104dad339d250f2d2a..075cf7a7d7a881fc308b0d2a7dcee3c9bdabcd57 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -24,6 +24,7 @@ pub enum ContextMenuItem { entry_render: Box AnyElement>, handler: Rc, &mut Window, &mut App)>, selectable: bool, + documentation_aside: Option, }, } @@ -31,11 +32,13 @@ impl ContextMenuItem { pub fn custom_entry( entry_render: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, handler: impl Fn(&mut Window, &mut App) + 'static, + documentation_aside: Option, ) -> Self { Self::CustomEntry { entry_render: Box::new(entry_render), handler: Rc::new(move |_, window, cx| handler(window, cx)), selectable: true, + documentation_aside, } } } @@ -170,6 +173,12 @@ pub struct DocumentationAside { render: Rc AnyElement>, } +impl DocumentationAside { + pub fn new(side: DocumentationSide, render: Rc AnyElement>) -> Self { + Self { side, render } + } +} + impl Focusable for ContextMenu { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() @@ -456,6 +465,7 @@ impl ContextMenu { entry_render: Box::new(entry_render), handler: Rc::new(|_, _, _| {}), selectable: false, + documentation_aside: None, }); self } @@ -469,6 +479,7 @@ impl ContextMenu { entry_render: Box::new(entry_render), handler: Rc::new(move |_, window, cx| handler(window, cx)), selectable: true, + documentation_aside: None, }); self } @@ -705,10 +716,19 @@ impl ContextMenu { let item = self.items.get(ix)?; if item.is_selectable() { self.selected_index = Some(ix); - if let ContextMenuItem::Entry(entry) = item { - if let Some(callback) = &entry.documentation_aside { + match item { + ContextMenuItem::Entry(entry) => { + if let Some(callback) = &entry.documentation_aside { + self.documentation_aside = Some((ix, callback.clone())); + } + } + ContextMenuItem::CustomEntry { + documentation_aside: Some(callback), + .. + } => { self.documentation_aside = Some((ix, callback.clone())); } + _ => (), } } Some(ix) @@ -806,6 +826,7 @@ impl ContextMenu { entry_render, handler, selectable, + .. } => { let handler = handler.clone(); let menu = cx.entity().downgrade(); diff --git a/crates/ui/src/components/popover_menu.rs b/crates/ui/src/components/popover_menu.rs index 077c18f69e5476d8d47a85f5dd9664b3284c6681..55ce0218c75d4450067a5c09c2ea523f7d86ca3c 100644 --- a/crates/ui/src/components/popover_menu.rs +++ b/crates/ui/src/components/popover_menu.rs @@ -105,6 +105,24 @@ impl PopoverMenuHandle { .map_or(false, |model| model.focus_handle(cx).is_focused(window)) }) } + + pub fn refresh_menu( + &self, + window: &mut Window, + cx: &mut App, + new_menu_builder: Rc Option>>, + ) { + let show_menu = if let Some(state) = self.0.borrow_mut().as_mut() { + state.menu_builder = new_menu_builder; + state.menu.borrow().is_some() + } else { + false + }; + + if show_menu { + self.show(window, cx); + } + } } pub struct PopoverMenu { From f785853239ab2344cf1677b89cdf039ec3b6877c Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 7 Jul 2025 10:55:37 -0400 Subject: [PATCH 11/21] ssh: Fix incorrect handling of ssh paths that exist locally (#33743) - Closes: https://github.com/zed-industries/zed/issues/33733 I also tested that remote canonicalization of symlink directories still works. (e.g. `zed ssh://hostname/~/foo` where `foo -> foobar` will open `~/foobar` on the remote). I believe this has been broken since 2024-10-11 from https://github.com/zed-industries/zed/pull/19057. CC: @SomeoneToIgnore. I guess I'm the only person silly enough to run `zed ssh://hostname/tmp`. Release Notes: - ssh: Fixed an issue where Zed incorrectly canonicalized paths locally prior to connecting to the ssh remote. --- crates/zed/src/main.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 89d9c2edf127ff4b75f3100be3b8600dbaa89c06..e04e9c38c15b7ed1bc95bffc0d702b013150b3a5 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -727,11 +727,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc, cx: &mut if let Some(connection_options) = request.ssh_connection { cx.spawn(async move |mut cx| { - let paths_with_position = - derive_paths_with_position(app_state.fs.as_ref(), request.open_paths).await; + let paths: Vec = request.open_paths.into_iter().map(PathBuf::from).collect(); open_ssh_project( connection_options, - paths_with_position.into_iter().map(|p| p.path).collect(), + paths, app_state, workspace::OpenOptions::default(), &mut cx, From 861ca05fb91f6bfbdd0e09fa832d0d129ce9b407 Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 7 Jul 2025 10:56:38 -0400 Subject: [PATCH 12/21] Support loading environment from plan9 `rc` shell (#33599) Closes: https://github.com/zed-industries/zed/issues/33511 Add support for loading environment from Plan9 shell Document esoteric shell behavior. Remove two useless tests. Follow-up to: - https://github.com/zed-industries/zed/pull/32702 - https://github.com/zed-industries/zed/pull/32637 Release Notes: - Add support for loading environment variables from Plan9 `rc` shell. --- crates/util/src/shell_env.rs | 12 ++++++---- crates/util/src/util.rs | 46 ------------------------------------ 2 files changed, 7 insertions(+), 51 deletions(-) diff --git a/crates/util/src/shell_env.rs b/crates/util/src/shell_env.rs index 9e42ebe500ba372806731b799b15afd4b44429b4..21f6096f19fa0c89bf4516b122878be04361ddcd 100644 --- a/crates/util/src/shell_env.rs +++ b/crates/util/src/shell_env.rs @@ -16,8 +16,13 @@ pub fn capture(directory: &std::path::Path) -> Result format!(">[1={}]", ENV_OUTPUT_FD), // `[1=0]` + _ => format!(">&{}", ENV_OUTPUT_FD), // `>&0` + }; command.stdin(Stdio::null()); command.stdout(Stdio::piped()); command.stderr(Stdio::piped()); @@ -38,10 +43,7 @@ pub fn capture(directory: &std::path::Path) -> Result&{}\";", - zed_path, ENV_OUTPUT_FD - )); + command_string.push_str(&format!("{} --printenv {}", zed_path, redir)); command.args(["-i", "-c", &command_string]); super::set_pre_exec_to_start_new_session(&mut command); diff --git a/crates/util/src/util.rs b/crates/util/src/util.rs index 86bee7ffd14c1782f806ebfe4dbe0537b675a5bc..932b519b18d4c555a2ee6189eef5744b0f85829e 100644 --- a/crates/util/src/util.rs +++ b/crates/util/src/util.rs @@ -1097,52 +1097,6 @@ mod tests { assert_eq!(vec, &[1000, 101, 21, 19, 17, 13, 9, 8]); } - #[test] - fn test_get_shell_safe_zed_path_with_spaces() { - // Test that shlex::try_quote handles paths with spaces correctly - let path_with_spaces = "/Applications/Zed Nightly.app/Contents/MacOS/zed"; - let quoted = shlex::try_quote(path_with_spaces).unwrap(); - - // The quoted path should be properly escaped for shell use - assert!(quoted.contains(path_with_spaces)); - - // When used in a shell command, it should not be split at spaces - let command = format!("sh -c '{} --printenv'", quoted); - println!("Command would be: {}", command); - - // Test that shlex can parse it back correctly - let parsed = shlex::split(&format!("{} --printenv", quoted)).unwrap(); - assert_eq!(parsed.len(), 2); - assert_eq!(parsed[0], path_with_spaces); - assert_eq!(parsed[1], "--printenv"); - } - - #[test] - fn test_shell_command_construction_with_quoted_path() { - // Test the specific pattern used in shell_env.rs to ensure proper quoting - let path_with_spaces = "/Applications/Zed Nightly.app/Contents/MacOS/zed"; - let quoted_path = shlex::try_quote(path_with_spaces).unwrap(); - - // This should be: '/Applications/Zed Nightly.app/Contents/MacOS/zed' - assert_eq!( - quoted_path, - "'/Applications/Zed Nightly.app/Contents/MacOS/zed'" - ); - - // Test the command construction pattern from shell_env.rs - // The fixed version should use double quotes around the entire sh -c argument - let env_fd = 0; - let command = format!("sh -c \"{} --printenv >&{}\";", quoted_path, env_fd); - - // This should produce: sh -c "'/Applications/Zed Nightly.app/Contents/MacOS/zed' --printenv >&0"; - let expected = - "sh -c \"'/Applications/Zed Nightly.app/Contents/MacOS/zed' --printenv >&0\";"; - assert_eq!(command, expected); - - // The command should not contain the problematic double single-quote pattern - assert!(!command.contains("''")); - } - #[test] fn test_truncate_to_bottom_n_sorted_by() { let mut vec: Vec = vec![5, 2, 3, 4, 1]; From 966e75b610970af43643f4a5d309aa18c2434064 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 7 Jul 2025 18:34:14 +0300 Subject: [PATCH 13/21] tools: Ensure `properties` always exists in JSON Schema (#34015) OpenAI API requires `properties` to be present, even if it's empty. Release Notes: - N/A --- crates/assistant_tool/src/tool_schema.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/assistant_tool/src/tool_schema.rs b/crates/assistant_tool/src/tool_schema.rs index 001b16ac87f02d3783d606ec3bc8d69a0cefd5a0..7b48f93ba6d23bcc1a6e2cf051737efaf69fa595 100644 --- a/crates/assistant_tool/src/tool_schema.rs +++ b/crates/assistant_tool/src/tool_schema.rs @@ -25,10 +25,15 @@ fn preprocess_json_schema(json: &mut Value) -> Result<()> { // `additionalProperties` defaults to `false` unless explicitly specified. // This prevents models from hallucinating tool parameters. if let Value::Object(obj) = json { - if let Some(Value::String(type_str)) = obj.get("type") { - if type_str == "object" && !obj.contains_key("additionalProperties") { + if matches!(obj.get("type"), Some(Value::String(s)) if s == "object") { + if !obj.contains_key("additionalProperties") { obj.insert("additionalProperties".to_string(), Value::Bool(false)); } + + // OpenAI API requires non-missing `properties` + if !obj.contains_key("properties") { + obj.insert("properties".to_string(), Value::Object(Default::default())); + } } } Ok(()) From 6cb382c49f91bab5cb6e5467f8b35655bf4c386b Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 7 Jul 2025 17:40:14 +0200 Subject: [PATCH 14/21] debugger: Make exception breakpoints persistent (#34014) Closes #33053 Release Notes: - Exception breakpoint state is now persisted across debugging sessions. --- .../src/session/running/breakpoint_list.rs | 117 ++++++++++++++---- crates/project/src/debugger/dap_store.rs | 117 ++++++++---------- crates/project/src/debugger/session.rs | 60 +++++---- 3 files changed, 178 insertions(+), 116 deletions(-) diff --git a/crates/debugger_ui/src/session/running/breakpoint_list.rs b/crates/debugger_ui/src/session/running/breakpoint_list.rs index 2ec20c9877ab38642fa12aa6cc2a61256dd6ab26..78c87db2e6f2a1f9d54368b875d1e86b3ac5789f 100644 --- a/crates/debugger_ui/src/session/running/breakpoint_list.rs +++ b/crates/debugger_ui/src/session/running/breakpoint_list.rs @@ -5,7 +5,8 @@ use std::{ time::Duration, }; -use dap::{Capabilities, ExceptionBreakpointsFilter}; +use dap::{Capabilities, ExceptionBreakpointsFilter, adapters::DebugAdapterName}; +use db::kvp::KEY_VALUE_STORE; use editor::Editor; use gpui::{ Action, AppContext, ClickEvent, Entity, FocusHandle, Focusable, MouseButton, ScrollStrategy, @@ -16,6 +17,7 @@ use project::{ Project, debugger::{ breakpoint_store::{BreakpointEditAction, BreakpointStore, SourceBreakpoint}, + dap_store::{DapStore, PersistedAdapterOptions}, session::Session, }, worktree_store::WorktreeStore, @@ -48,6 +50,7 @@ pub(crate) enum SelectedBreakpointKind { pub(crate) struct BreakpointList { workspace: WeakEntity, breakpoint_store: Entity, + dap_store: Entity, worktree_store: Entity, scrollbar_state: ScrollbarState, breakpoints: Vec, @@ -59,6 +62,7 @@ pub(crate) struct BreakpointList { selected_ix: Option, input: Entity, strip_mode: Option, + serialize_exception_breakpoints_task: Option>>, } impl Focusable for BreakpointList { @@ -85,24 +89,34 @@ impl BreakpointList { let project = project.read(cx); let breakpoint_store = project.breakpoint_store(); let worktree_store = project.worktree_store(); + let dap_store = project.dap_store(); let focus_handle = cx.focus_handle(); let scroll_handle = UniformListScrollHandle::new(); let scrollbar_state = ScrollbarState::new(scroll_handle.clone()); - cx.new(|cx| Self { - breakpoint_store, - worktree_store, - scrollbar_state, - breakpoints: Default::default(), - hide_scrollbar_task: None, - show_scrollbar: false, - workspace, - session, - focus_handle, - scroll_handle, - selected_ix: None, - input: cx.new(|cx| Editor::single_line(window, cx)), - strip_mode: None, + let adapter_name = session.as_ref().map(|session| session.read(cx).adapter()); + cx.new(|cx| { + let this = Self { + breakpoint_store, + dap_store, + worktree_store, + scrollbar_state, + breakpoints: Default::default(), + hide_scrollbar_task: None, + show_scrollbar: false, + workspace, + session, + focus_handle, + scroll_handle, + selected_ix: None, + input: cx.new(|cx| Editor::single_line(window, cx)), + strip_mode: None, + serialize_exception_breakpoints_task: None, + }; + if let Some(name) = adapter_name { + _ = this.deserialize_exception_breakpoints(name, cx); + } + this }) } @@ -404,12 +418,8 @@ impl BreakpointList { self.edit_line_breakpoint(path, row, BreakpointEditAction::InvertState, cx); } BreakpointEntryKind::ExceptionBreakpoint(exception_breakpoint) => { - if let Some(session) = &self.session { - let id = exception_breakpoint.id.clone(); - session.update(cx, |session, cx| { - session.toggle_exception_breakpoint(&id, cx); - }); - } + let id = exception_breakpoint.id.clone(); + self.toggle_exception_breakpoint(&id, cx); } } cx.notify(); @@ -480,6 +490,64 @@ impl BreakpointList { cx.notify(); } + fn toggle_exception_breakpoint(&mut self, id: &str, cx: &mut Context) { + if let Some(session) = &self.session { + session.update(cx, |this, cx| { + this.toggle_exception_breakpoint(&id, cx); + }); + cx.notify(); + const EXCEPTION_SERIALIZATION_INTERVAL: Duration = Duration::from_secs(1); + self.serialize_exception_breakpoints_task = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(EXCEPTION_SERIALIZATION_INTERVAL) + .await; + this.update(cx, |this, cx| this.serialize_exception_breakpoints(cx))? + .await?; + Ok(()) + })); + } + } + + fn kvp_key(adapter_name: &str) -> String { + format!("debug_adapter_`{adapter_name}`_persistence") + } + fn serialize_exception_breakpoints( + &mut self, + cx: &mut Context, + ) -> Task> { + if let Some(session) = self.session.as_ref() { + let key = { + let session = session.read(cx); + let name = session.adapter().0; + Self::kvp_key(&name) + }; + let settings = self.dap_store.update(cx, |this, cx| { + this.sync_adapter_options(session, cx); + }); + let value = serde_json::to_string(&settings); + + cx.background_executor() + .spawn(async move { KEY_VALUE_STORE.write_kvp(key, value?).await }) + } else { + return Task::ready(Result::Ok(())); + } + } + + fn deserialize_exception_breakpoints( + &self, + adapter_name: DebugAdapterName, + cx: &mut Context, + ) -> anyhow::Result<()> { + let Some(val) = KEY_VALUE_STORE.read_kvp(&Self::kvp_key(&adapter_name))? else { + return Ok(()); + }; + let value: PersistedAdapterOptions = serde_json::from_str(&val)?; + self.dap_store + .update(cx, |this, _| this.set_adapter_options(adapter_name, value)); + + Ok(()) + } + fn hide_scrollbar(&mut self, window: &mut Window, cx: &mut Context) { const SCROLLBAR_SHOW_INTERVAL: Duration = Duration::from_secs(1); self.hide_scrollbar_task = Some(cx.spawn_in(window, async move |panel, cx| { @@ -988,12 +1056,7 @@ impl ExceptionBreakpoint { let list = list.clone(); move |_, _, cx| { list.update(cx, |this, cx| { - if let Some(session) = &this.session { - session.update(cx, |this, cx| { - this.toggle_exception_breakpoint(&id, cx); - }); - cx.notify(); - } + this.toggle_exception_breakpoint(&id, cx); }) .ok(); } diff --git a/crates/project/src/debugger/dap_store.rs b/crates/project/src/debugger/dap_store.rs index be4964bbee2688c0025900c552eec3fbbc9af492..29555d0179a41448131aecad8ebea610f2321c1d 100644 --- a/crates/project/src/debugger/dap_store.rs +++ b/crates/project/src/debugger/dap_store.rs @@ -14,15 +14,13 @@ use anyhow::{Context as _, Result, anyhow}; use async_trait::async_trait; use collections::HashMap; use dap::{ - Capabilities, CompletionItem, CompletionsArguments, DapRegistry, DebugRequest, - EvaluateArguments, EvaluateArgumentsContext, EvaluateResponse, Source, StackFrameId, + Capabilities, DapRegistry, DebugRequest, EvaluateArgumentsContext, StackFrameId, adapters::{ DapDelegate, DebugAdapterBinary, DebugAdapterName, DebugTaskDefinition, TcpArguments, }, client::SessionId, inline_value::VariableLookupKind, messages::Message, - requests::{Completions, Evaluate}, }; use fs::Fs; use futures::{ @@ -40,6 +38,7 @@ use rpc::{ AnyProtoClient, TypedEnvelope, proto::{self}, }; +use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsLocation, WorktreeId}; use std::{ borrow::Borrow, @@ -93,10 +92,23 @@ pub struct DapStore { worktree_store: Entity, sessions: BTreeMap>, next_session_id: u32, + adapter_options: BTreeMap>, } impl EventEmitter for DapStore {} +#[derive(Clone, Serialize, Deserialize)] +pub struct PersistedExceptionBreakpoint { + pub enabled: bool, +} + +/// Represents best-effort serialization of adapter state during last session (e.g. watches) +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct PersistedAdapterOptions { + /// Which exception breakpoints were enabled during the last session with this adapter? + pub exception_breakpoints: BTreeMap, +} + impl DapStore { pub fn init(client: &AnyProtoClient, cx: &mut App) { static ADD_LOCATORS: Once = Once::new(); @@ -173,6 +185,7 @@ impl DapStore { breakpoint_store, worktree_store, sessions: Default::default(), + adapter_options: Default::default(), } } @@ -520,65 +533,6 @@ impl DapStore { )) } - pub fn evaluate( - &self, - session_id: &SessionId, - stack_frame_id: u64, - expression: String, - context: EvaluateArgumentsContext, - source: Option, - cx: &mut Context, - ) -> Task> { - let Some(client) = self - .session_by_id(session_id) - .and_then(|client| client.read(cx).adapter_client()) - else { - return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); - }; - - cx.background_executor().spawn(async move { - client - .request::(EvaluateArguments { - expression: expression.clone(), - frame_id: Some(stack_frame_id), - context: Some(context), - format: None, - line: None, - column: None, - source, - }) - .await - }) - } - - pub fn completions( - &self, - session_id: &SessionId, - stack_frame_id: u64, - text: String, - completion_column: u64, - cx: &mut Context, - ) -> Task>> { - let Some(client) = self - .session_by_id(session_id) - .and_then(|client| client.read(cx).adapter_client()) - else { - return Task::ready(Err(anyhow!("Could not find client: {:?}", session_id))); - }; - - cx.background_executor().spawn(async move { - Ok(client - .request::(CompletionsArguments { - frame_id: Some(stack_frame_id), - line: None, - text, - column: completion_column, - }) - .await? - .targets) - }) - } - pub fn resolve_inline_value_locations( &self, session: Entity, @@ -853,6 +807,45 @@ impl DapStore { }) }) } + + pub fn sync_adapter_options( + &mut self, + session: &Entity, + cx: &App, + ) -> Arc { + let session = session.read(cx); + let adapter = session.adapter(); + let exceptions = session.exception_breakpoints(); + let exception_breakpoints = exceptions + .map(|(exception, enabled)| { + ( + exception.filter.clone(), + PersistedExceptionBreakpoint { enabled: *enabled }, + ) + }) + .collect(); + let options = Arc::new(PersistedAdapterOptions { + exception_breakpoints, + }); + self.adapter_options.insert(adapter, options.clone()); + options + } + + pub fn set_adapter_options( + &mut self, + adapter: DebugAdapterName, + options: PersistedAdapterOptions, + ) { + self.adapter_options.insert(adapter, Arc::new(options)); + } + + pub fn adapter_options(&self, name: &str) -> Option> { + self.adapter_options.get(name).cloned() + } + + pub fn all_adapter_options(&self) -> &BTreeMap> { + &self.adapter_options + } } #[derive(Clone)] diff --git a/crates/project/src/debugger/session.rs b/crates/project/src/debugger/session.rs index bd52c0f6fa6f7c77baaa7fa052cd7499b44f0858..9ab83610f02cdeb0661062d04dc1b3d6fa3013be 100644 --- a/crates/project/src/debugger/session.rs +++ b/crates/project/src/debugger/session.rs @@ -409,17 +409,6 @@ impl RunningMode { }; let configuration_done_supported = ConfigurationDone::is_supported(capabilities); - let exception_filters = capabilities - .exception_breakpoint_filters - .as_ref() - .map(|exception_filters| { - exception_filters - .iter() - .filter(|filter| filter.default == Some(true)) - .cloned() - .collect::>() - }) - .unwrap_or_default(); // From spec (on initialization sequence): // client sends a setExceptionBreakpoints request if one or more exceptionBreakpointFilters have been defined (or if supportsConfigurationDoneRequest is not true) // @@ -434,10 +423,20 @@ impl RunningMode { .unwrap_or_default(); let this = self.clone(); let worktree = self.worktree().clone(); + let mut filters = capabilities + .exception_breakpoint_filters + .clone() + .unwrap_or_default(); let configuration_sequence = cx.spawn({ - async move |_, cx| { - let breakpoint_store = - dap_store.read_with(cx, |dap_store, _| dap_store.breakpoint_store().clone())?; + async move |session, cx| { + let adapter_name = session.read_with(cx, |this, _| this.adapter())?; + let (breakpoint_store, adapter_defaults) = + dap_store.read_with(cx, |dap_store, _| { + ( + dap_store.breakpoint_store().clone(), + dap_store.adapter_options(&adapter_name), + ) + })?; initialized_rx.await?; let errors_by_path = cx .update(|cx| this.send_source_breakpoints(false, &breakpoint_store, cx))? @@ -471,7 +470,25 @@ impl RunningMode { })?; if should_send_exception_breakpoints { - this.send_exception_breakpoints(exception_filters, supports_exception_filters) + _ = session.update(cx, |this, _| { + filters.retain(|filter| { + let is_enabled = if let Some(defaults) = adapter_defaults.as_ref() { + defaults + .exception_breakpoints + .get(&filter.filter) + .map(|options| options.enabled) + .unwrap_or_else(|| filter.default.unwrap_or_default()) + } else { + filter.default.unwrap_or_default() + }; + this.exception_breakpoints + .entry(filter.filter.clone()) + .or_insert_with(|| (filter.clone(), is_enabled)); + is_enabled + }); + }); + + this.send_exception_breakpoints(filters, supports_exception_filters) .await .ok(); } @@ -1233,18 +1250,7 @@ impl Session { Ok(capabilities) => { this.update(cx, |session, cx| { session.capabilities = capabilities; - let filters = session - .capabilities - .exception_breakpoint_filters - .clone() - .unwrap_or_default(); - for filter in filters { - let default = filter.default.unwrap_or_default(); - session - .exception_breakpoints - .entry(filter.filter.clone()) - .or_insert_with(|| (filter, default)); - } + cx.emit(SessionEvent::CapabilitiesLoaded); })?; return Ok(()); From c35af6c2e2225c055d106b394c346eea02de8996 Mon Sep 17 00:00:00 2001 From: Richard Feldman Date: Mon, 7 Jul 2025 11:51:45 -0400 Subject: [PATCH 15/21] Fix panic with Helix mode changing case (#34016) Closes #33750 Release Notes: - Fixed a panic when trying to change case in Helix mode --- crates/vim/src/normal/convert.rs | 41 ++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/crates/vim/src/normal/convert.rs b/crates/vim/src/normal/convert.rs index 25b425e847d67eb5bc3d58b1d0a2201581a1e03f..cf9498bec9d7ee796bd2d4d6830085eda9970ea5 100644 --- a/crates/vim/src/normal/convert.rs +++ b/crates/vim/src/normal/convert.rs @@ -212,7 +212,19 @@ impl Vim { } } - Mode::HelixNormal => {} + Mode::HelixNormal => { + if selection.is_empty() { + // Handle empty selection by operating on the whole word + let (word_range, _) = snapshot.surrounding_word(selection.start, false); + let word_start = snapshot.offset_to_point(word_range.start); + let word_end = snapshot.offset_to_point(word_range.end); + ranges.push(word_start..word_end); + cursor_positions.push(selection.start..selection.start); + } else { + ranges.push(selection.start..selection.end); + cursor_positions.push(selection.start..selection.end); + } + } Mode::Insert | Mode::Normal | Mode::Replace => { let start = selection.start; let mut end = start; @@ -245,12 +257,16 @@ impl Vim { }) }); }); - self.switch_mode(Mode::Normal, true, window, cx) + if self.mode != Mode::HelixNormal { + self.switch_mode(Mode::Normal, true, window, cx) + } } } #[cfg(test)] mod test { + use crate::test::VimTestContext; + use crate::{state::Mode, test::NeovimBackedTestContext}; #[gpui::test] @@ -419,4 +435,25 @@ mod test { .await .assert_eq("ˇnopqrstuvwxyzabcdefghijklmNOPQRSTUVWXYZABCDEFGHIJKLM"); } + + #[gpui::test] + async fn test_change_case_helix_mode(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + // Explicit selection + cx.set_state("«hello worldˇ»", Mode::HelixNormal); + cx.simulate_keystrokes("~"); + cx.assert_state("«HELLO WORLDˇ»", Mode::HelixNormal); + + // Cursor-only (empty) selection + cx.set_state("The ˇquick brown", Mode::HelixNormal); + cx.simulate_keystrokes("~"); + cx.assert_state("The ˇQUICK brown", Mode::HelixNormal); + + // With `e` motion (which extends selection to end of word in Helix) + cx.set_state("The ˇquick brown fox", Mode::HelixNormal); + cx.simulate_keystrokes("e"); + cx.simulate_keystrokes("~"); + cx.assert_state("The «QUICKˇ» brown fox", Mode::HelixNormal); + } } From ddf3d992650d94f4986d8babd2fbf3ef51422e64 Mon Sep 17 00:00:00 2001 From: Hilmar Wiegand Date: Mon, 7 Jul 2025 18:18:55 +0200 Subject: [PATCH 16/21] Add g-w rewrap keybind for vim visual mode (#33853) There are both `g q` and `g w` keybinds for rewrapping in normal mode, but `g w` is missing in visual mode. This PR adds that keybind. Release Notes: - Add `g w` rewrap keybind for vim visual mode --- assets/keymaps/vim.json | 1 + 1 file changed, 1 insertion(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 639f1cefade18ee46771d22e08eda4a24f8696c0..ba3012cc54f7cc0af464357b6fb05c041130262d 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -327,6 +327,7 @@ "g shift-r": ["vim::Paste", { "preserve_clipboard": true }], "g c": "vim::ToggleComments", "g q": "vim::Rewrap", + "g w": "vim::Rewrap", "g ?": "vim::ConvertToRot13", // "g ?": "vim::ConvertToRot47", "\"": "vim::PushRegister", From de9053c7ca276adba5d1244fe9805bf331a3c646 Mon Sep 17 00:00:00 2001 From: Ben Kunkle Date: Mon, 7 Jul 2025 11:44:19 -0500 Subject: [PATCH 17/21] keymap_ui: Add ability to edit context (#34019) Closes #ISSUE Adds a context input to the keybind edit modal. Also fixes some bugs in the keymap update function to handle context changes gracefully. The current keybind update strategy implemented in this PR is * when the context doesn't change, just update the binding in place * when the context changes, but the binding is the only binding in the keymap section, update the binding _and_ context in place * when the context changes, and the binding is _not_ the only binding in the keymap section, remove the existing binding and create a new section with the update context and binding so as to avoid impacting other bindings Release Notes: - N/A *or* Added/Fixed/Improved ... --- crates/gpui/src/platform/keystroke.rs | 2 +- crates/settings/src/keymap_file.rs | 187 +++++++++++++++++++++++--- crates/settings_ui/src/keybindings.rs | 163 ++++++++++++++++------ 3 files changed, 288 insertions(+), 64 deletions(-) diff --git a/crates/gpui/src/platform/keystroke.rs b/crates/gpui/src/platform/keystroke.rs index 18adc1af10073001b82e0a72f8e372fafe4395b0..40387a820230cfc0f73f90643c082619ceaa595a 100644 --- a/crates/gpui/src/platform/keystroke.rs +++ b/crates/gpui/src/platform/keystroke.rs @@ -55,7 +55,7 @@ impl Keystroke { /// /// This method assumes that `self` was typed and `target' is in the keymap, and checks /// both possibilities for self against the target. - pub(crate) fn should_match(&self, target: &Keystroke) -> bool { + pub fn should_match(&self, target: &Keystroke) -> bool { #[cfg(not(target_os = "windows"))] if let Some(key_char) = self .key_char diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 4c4ceee49bcd0a90ac43329e6ecd6211a423ae65..ca54b6a877361af15a634ec7ce3c247ffeaff49f 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -604,7 +604,7 @@ impl KeymapFile { // if trying to replace a keybinding that is not user-defined, treat it as an add operation match operation { KeybindUpdateOperation::Replace { - target_source, + target_keybind_source: target_source, source, .. } if target_source != KeybindSource::User => { @@ -643,7 +643,12 @@ impl KeymapFile { else { continue; }; - if keystrokes != target.keystrokes { + if keystrokes.len() != target.keystrokes.len() + || !keystrokes + .iter() + .zip(target.keystrokes) + .all(|(a, b)| a.should_match(b)) + { continue; } if action.0 != target_action_value { @@ -655,18 +660,75 @@ impl KeymapFile { } if let Some(index) = found_index { - let (replace_range, replace_value) = replace_top_level_array_value_in_json_text( - &keymap_contents, - &["bindings", &target.keystrokes_unparsed()], - Some(&source_action_value), - Some(&source.keystrokes_unparsed()), - index, - tab_size, - ) - .context("Failed to replace keybinding")?; - keymap_contents.replace_range(replace_range, &replace_value); - - return Ok(keymap_contents); + if target.context == source.context { + // if we are only changing the keybinding (common case) + // not the context, etc. Then just update the binding in place + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["bindings", &target.keystrokes_unparsed()], + Some(&source_action_value), + Some(&source.keystrokes_unparsed()), + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + + return Ok(keymap_contents); + } else if keymap.0[index] + .bindings + .as_ref() + .map_or(true, |bindings| bindings.len() == 1) + { + // if we are replacing the only binding in the section, + // just update the section in place, updating the context + // and the binding + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["bindings", &target.keystrokes_unparsed()], + Some(&source_action_value), + Some(&source.keystrokes_unparsed()), + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["context"], + source.context.map(Into::into).as_ref(), + None, + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + return Ok(keymap_contents); + } else { + // if we are replacing one of multiple bindings in a section + // with a context change, remove the existing binding from the + // section, then treat this operation as an add operation of the + // new binding with the updated context. + + let (replace_range, replace_value) = + replace_top_level_array_value_in_json_text( + &keymap_contents, + &["bindings", &target.keystrokes_unparsed()], + None, + None, + index, + tab_size, + ) + .context("Failed to replace keybinding")?; + keymap_contents.replace_range(replace_range, &replace_value); + operation = KeybindUpdateOperation::Add(source); + } } else { log::warn!( "Failed to find keybinding to update `{:?} -> {}` creating new binding for `{:?} -> {}` instead", @@ -712,7 +774,7 @@ pub enum KeybindUpdateOperation<'a> { source: KeybindUpdateTarget<'a>, /// Describes the keybind to remove target: KeybindUpdateTarget<'a>, - target_source: KeybindSource, + target_keybind_source: KeybindSource, }, Add(KeybindUpdateTarget<'a>), } @@ -1001,7 +1063,7 @@ mod tests { use_key_equivalents: false, input: Some(r#"{"foo": "bar"}"#), }, - target_source: KeybindSource::Base, + target_keybind_source: KeybindSource::Base, }, r#"[ { @@ -1027,14 +1089,14 @@ mod tests { r#"[ { "bindings": { - "ctrl-a": "zed::SomeAction" + "a": "zed::SomeAction" } } ]"# .unindent(), KeybindUpdateOperation::Replace { target: KeybindUpdateTarget { - keystrokes: &parse_keystrokes("ctrl-a"), + keystrokes: &parse_keystrokes("a"), action_name: "zed::SomeAction", context: None, use_key_equivalents: false, @@ -1047,7 +1109,7 @@ mod tests { use_key_equivalents: false, input: Some(r#"{"foo": "bar"}"#), }, - target_source: KeybindSource::User, + target_keybind_source: KeybindSource::User, }, r#"[ { @@ -1088,7 +1150,7 @@ mod tests { use_key_equivalents: false, input: None, }, - target_source: KeybindSource::User, + target_keybind_source: KeybindSource::User, }, r#"[ { @@ -1131,7 +1193,7 @@ mod tests { use_key_equivalents: false, input: Some(r#"{"foo": "bar"}"#), }, - target_source: KeybindSource::User, + target_keybind_source: KeybindSource::User, }, r#"[ { @@ -1149,5 +1211,88 @@ mod tests { ]"# .unindent(), ); + + check_keymap_update( + r#"[ + { + "context": "SomeContext", + "bindings": { + "a": "foo::bar", + "b": "baz::qux", + } + } + ]"# + .unindent(), + KeybindUpdateOperation::Replace { + target: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("a"), + action_name: "foo::bar", + context: Some("SomeContext"), + use_key_equivalents: false, + input: None, + }, + source: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("c"), + action_name: "foo::baz", + context: Some("SomeOtherContext"), + use_key_equivalents: false, + input: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#"[ + { + "context": "SomeContext", + "bindings": { + "b": "baz::qux", + } + }, + { + "context": "SomeOtherContext", + "bindings": { + "c": "foo::baz" + } + } + ]"# + .unindent(), + ); + + check_keymap_update( + r#"[ + { + "context": "SomeContext", + "bindings": { + "a": "foo::bar", + } + } + ]"# + .unindent(), + KeybindUpdateOperation::Replace { + target: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("a"), + action_name: "foo::bar", + context: Some("SomeContext"), + use_key_equivalents: false, + input: None, + }, + source: KeybindUpdateTarget { + keystrokes: &parse_keystrokes("c"), + action_name: "foo::baz", + context: Some("SomeOtherContext"), + use_key_equivalents: false, + input: None, + }, + target_keybind_source: KeybindSource::User, + }, + r#"[ + { + "context": "SomeOtherContext", + "bindings": { + "c": "foo::baz", + } + } + ]"# + .unindent(), + ); } } diff --git a/crates/settings_ui/src/keybindings.rs b/crates/settings_ui/src/keybindings.rs index 1f5f4b1b7e18c7720227d6c04d7f8680e469c94b..34d4b8585256d12b62b725d360679225b7360a82 100644 --- a/crates/settings_ui/src/keybindings.rs +++ b/crates/settings_ui/src/keybindings.rs @@ -1,4 +1,7 @@ -use std::{ops::Range, sync::Arc}; +use std::{ + ops::{Not, Range}, + sync::Arc, +}; use anyhow::{Context as _, anyhow}; use collections::HashSet; @@ -824,6 +827,7 @@ impl RenderOnce for SyntaxHighlightedText { struct KeybindingEditorModal { editing_keybind: ProcessedKeybinding, keybind_editor: Entity, + context_editor: Entity, fs: Arc, error: Option, } @@ -842,17 +846,86 @@ impl KeybindingEditorModal { pub fn new( editing_keybind: ProcessedKeybinding, fs: Arc, - _window: &mut Window, + window: &mut Window, cx: &mut App, ) -> Self { let keybind_editor = cx.new(KeystrokeInput::new); + let context_editor = cx.new(|cx| { + let mut editor = Editor::single_line(window, cx); + if let Some(context) = editing_keybind + .context + .as_ref() + .and_then(KeybindContextString::local) + { + editor.set_text(context.clone(), window, cx); + } else { + editor.set_placeholder_text("Keybinding context", cx); + } + + editor + }); Self { editing_keybind, fs, keybind_editor, + context_editor, error: None, } } + + fn save(&mut self, cx: &mut Context) { + let existing_keybind = self.editing_keybind.clone(); + let fs = self.fs.clone(); + let new_keystrokes = self + .keybind_editor + .read_with(cx, |editor, _| editor.keystrokes().to_vec()); + if new_keystrokes.is_empty() { + self.error = Some("Keystrokes cannot be empty".to_string()); + cx.notify(); + return; + } + let tab_size = cx.global::().json_tab_size(); + let new_context = self + .context_editor + .read_with(cx, |editor, cx| editor.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.error = Some(err.to_string()); + cx.notify(); + return; + } + + cx.spawn(async move |this, cx| { + if let Err(err) = save_keybinding_update( + existing_keybind, + &new_keystrokes, + new_context.as_deref(), + &fs, + tab_size, + ) + .await + { + this.update(cx, |this, cx| { + this.error = Some(err.to_string()); + cx.notify(); + }) + .log_err(); + } else { + this.update(cx, |_this, cx| { + cx.emit(DismissEvent); + }) + .ok(); + } + }) + .detach(); + } } impl Render for KeybindingEditorModal { @@ -868,14 +941,35 @@ impl Render for KeybindingEditorModal { .gap_2() .child( v_flex().child(Label::new("Edit Keystroke")).child( - Label::new( - "Input the desired keystroke for the selected action and hit save.", - ) - .color(Color::Muted), + Label::new("Input the desired keystroke for the selected action.") + .color(Color::Muted), ), ) .child(self.keybind_editor.clone()), ) + .child( + v_flex() + .p_3() + .gap_3() + .child( + v_flex().child(Label::new("Edit Keystroke")).child( + Label::new("Input the desired keystroke for the selected action.") + .color(Color::Muted), + ), + ) + .child( + div() + .w_full() + .border_color(cx.theme().colors().border_variant) + .border_1() + .py_2() + .px_3() + .min_h_8() + .rounded_md() + .bg(theme.editor_background) + .child(self.context_editor.clone()), + ), + ) .child( h_flex() .p_2() @@ -888,38 +982,11 @@ impl Render for KeybindingEditorModal { 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| { - let existing_keybind = this.editing_keybind.clone(); - let fs = this.fs.clone(); - let new_keystrokes = this - .keybind_editor - .read_with(cx, |editor, _| editor.keystrokes.clone()); - if new_keystrokes.is_empty() { - this.error = Some("Keystrokes cannot be empty".to_string()); - cx.notify(); - return; - } - let tab_size = cx.global::().json_tab_size(); - cx.spawn(async move |this, cx| { - if let Err(err) = save_keybinding_update( - existing_keybind, - &new_keystrokes, - &fs, - tab_size, - ) - .await - { - this.update(cx, |this, cx| { - this.error = Some(err.to_string()); - cx.notify(); - }) - .log_err(); - } - }) - .detach(); - }, - ))), + .child( + Button::new("save-btn", "Save").on_click( + cx.listener(|this, _event, _window, cx| Self::save(this, cx)), + ), + ), ) .when_some(self.error.clone(), |this, error| { this.child( @@ -937,6 +1004,7 @@ impl Render for KeybindingEditorModal { async fn save_keybinding_update( existing: ProcessedKeybinding, new_keystrokes: &[Keystroke], + new_context: Option<&str>, fs: &Arc, tab_size: usize, ) -> anyhow::Result<()> { @@ -950,7 +1018,7 @@ async fn save_keybinding_update( .map(|keybinding| keybinding.keystrokes.as_slice()) .unwrap_or_default(); - let context = existing + let existing_context = existing .context .as_ref() .and_then(KeybindContextString::local_str); @@ -963,18 +1031,18 @@ async fn save_keybinding_update( let operation = if existing.ui_key_binding.is_some() { settings::KeybindUpdateOperation::Replace { target: settings::KeybindUpdateTarget { - context, + context: existing_context, keystrokes: existing_keystrokes, action_name: &existing.action, use_key_equivalents: false, input, }, - target_source: existing + target_keybind_source: existing .source .map(|(source, _name)| source) .unwrap_or(KeybindSource::User), source: settings::KeybindUpdateTarget { - context, + context: new_context, keystrokes: new_keystrokes, action_name: &existing.action, use_key_equivalents: false, @@ -1071,6 +1139,17 @@ impl KeystrokeInput { cx.stop_propagation(); cx.notify(); } + + fn keystrokes(&self) -> &[Keystroke] { + if self + .keystrokes + .last() + .map_or(false, |last| last.key.is_empty()) + { + return &self.keystrokes[..self.keystrokes.len() - 1]; + } + return &self.keystrokes; + } } impl Focusable for KeystrokeInput { From d87603dd60d0cefe87da782a0a906b75c7fd5ab4 Mon Sep 17 00:00:00 2001 From: Oleksiy Syvokon Date: Mon, 7 Jul 2025 19:48:18 +0300 Subject: [PATCH 18/21] agent: Send stale file notifications using the project_notifications tool (#34005) This commit introduces the `project_notifications` tool, which proactively pushes notifications to the agent. Unlike other tools, `Thread` automatically invokes this tool on every turn, even when the LLM doesn't ask for it. When notifications are available, the tool use and results are inserted into the thread, simulating an LLM tool call. As with other tools, users can disable `project_notifications` in Profiles if they do not want them. Currently, the tool only notifies users about stale files: that is, files that have been edited by the user while the agent is also working on them. In the future, notifications may be expanded to include compiler diagnostics, long-running processes, and more. Release Notes: - Added `project_notifications` tool --- assets/settings/default.json | 2 + .../src/prompts/stale_files_prompt_header.txt | 3 + crates/agent/src/thread.rs | 222 +++++++++++++++++- crates/assistant_tools/src/assistant_tools.rs | 3 + .../src/project_notifications_tool.rs | 193 +++++++++++++++ .../project_notifications_tool/description.md | 3 + .../prompt_header.txt | 3 + .../src/examples/file_change_notification.rs | 2 +- 8 files changed, 427 insertions(+), 4 deletions(-) create mode 100644 crates/agent/src/prompts/stale_files_prompt_header.txt create mode 100644 crates/assistant_tools/src/project_notifications_tool.rs create mode 100644 crates/assistant_tools/src/project_notifications_tool/description.md create mode 100644 crates/assistant_tools/src/project_notifications_tool/prompt_header.txt diff --git a/assets/settings/default.json b/assets/settings/default.json index 985e322cac2a2c4b6b807aeff24caeb68beacf89..48cdd665e1745fdcacb15b6317ade6ec2dd4480b 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -810,6 +810,7 @@ "edit_file": true, "fetch": true, "list_directory": true, + "project_notifications": true, "move_path": true, "now": true, "find_path": true, @@ -829,6 +830,7 @@ "diagnostics": true, "fetch": true, "list_directory": true, + "project_notifications": true, "now": true, "find_path": true, "read_file": true, diff --git a/crates/agent/src/prompts/stale_files_prompt_header.txt b/crates/agent/src/prompts/stale_files_prompt_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..f743e239c883c7456f7bdc6e089185c6b994cb44 --- /dev/null +++ b/crates/agent/src/prompts/stale_files_prompt_header.txt @@ -0,0 +1,3 @@ +[The following is an auto-generated notification; do not reply] + +These files have changed since the last read: diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 815b9e86ea8a7c4c0879e81028c4ee42e3a84ca8..50d2a4d77383e4336bed6ba3fd6fc4a9e3e64ac1 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -25,8 +25,8 @@ use language_model::{ ConfiguredModel, LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId, LanguageModelRegistry, LanguageModelRequest, LanguageModelRequestMessage, LanguageModelRequestTool, LanguageModelToolResult, LanguageModelToolResultContent, - LanguageModelToolUseId, MessageContent, ModelRequestLimitReachedError, PaymentRequiredError, - Role, SelectedModel, StopReason, TokenUsage, + LanguageModelToolUse, LanguageModelToolUseId, MessageContent, ModelRequestLimitReachedError, + PaymentRequiredError, Role, SelectedModel, StopReason, TokenUsage, }; use postage::stream::Stream as _; use project::{ @@ -45,7 +45,7 @@ use std::{ time::{Duration, Instant}, }; use thiserror::Error; -use util::{ResultExt as _, post_inc}; +use util::{ResultExt as _, debug_panic, post_inc}; use uuid::Uuid; use zed_llm_client::{CompletionIntent, CompletionRequestStatus, UsageLimit}; @@ -1248,6 +1248,8 @@ impl Thread { self.remaining_turns -= 1; + self.flush_notifications(model.clone(), intent, cx); + let request = self.to_completion_request(model.clone(), intent, cx); self.stream_completion(request, model, intent, window, cx); @@ -1481,6 +1483,110 @@ impl Thread { request } + /// Insert auto-generated notifications (if any) to the thread + fn flush_notifications( + &mut self, + model: Arc, + intent: CompletionIntent, + cx: &mut Context, + ) { + match intent { + CompletionIntent::UserPrompt | CompletionIntent::ToolResults => { + if let Some(pending_tool_use) = self.attach_tracked_files_state(model, cx) { + cx.emit(ThreadEvent::ToolFinished { + tool_use_id: pending_tool_use.id.clone(), + pending_tool_use: Some(pending_tool_use), + }); + } + } + CompletionIntent::ThreadSummarization + | CompletionIntent::ThreadContextSummarization + | CompletionIntent::CreateFile + | CompletionIntent::EditFile + | CompletionIntent::InlineAssist + | CompletionIntent::TerminalInlineAssist + | CompletionIntent::GenerateGitCommitMessage => {} + }; + } + + fn attach_tracked_files_state( + &mut self, + model: Arc, + cx: &mut App, + ) -> Option { + let action_log = self.action_log.read(cx); + + action_log.stale_buffers(cx).next()?; + + // Represent notification as a simulated `project_notifications` tool call + let tool_name = Arc::from("project_notifications"); + let Some(tool) = self.tools.read(cx).tool(&tool_name, cx) else { + debug_panic!("`project_notifications` tool not found"); + return None; + }; + + if !self.profile.is_tool_enabled(tool.source(), tool.name(), cx) { + return None; + } + + let input = serde_json::json!({}); + let request = Arc::new(LanguageModelRequest::default()); // unused + let window = None; + let tool_result = tool.run( + input, + request, + self.project.clone(), + self.action_log.clone(), + model.clone(), + window, + cx, + ); + + let tool_use_id = + LanguageModelToolUseId::from(format!("project_notifications_{}", self.messages.len())); + + let tool_use = LanguageModelToolUse { + id: tool_use_id.clone(), + name: tool_name.clone(), + raw_input: "{}".to_string(), + input: serde_json::json!({}), + is_input_complete: true, + }; + + let tool_output = cx.background_executor().block(tool_result.output); + + // Attach a project_notification tool call to the latest existing + // Assistant message. We cannot create a new Assistant message + // because thinking models require a `thinking` block that we + // cannot mock. We cannot send a notification as a normal + // (non-tool-use) User message because this distracts Agent + // too much. + let tool_message_id = self + .messages + .iter() + .enumerate() + .rfind(|(_, message)| message.role == Role::Assistant) + .map(|(_, message)| message.id)?; + + let tool_use_metadata = ToolUseMetadata { + model: model.clone(), + thread_id: self.id.clone(), + prompt_id: self.last_prompt_id.clone(), + }; + + self.tool_use + .request_tool_use(tool_message_id, tool_use, tool_use_metadata.clone(), cx); + + let pending_tool_use = self.tool_use.insert_tool_output( + tool_use_id.clone(), + tool_name, + tool_output, + self.configured_model.as_ref(), + ); + + pending_tool_use + } + pub fn stream_completion( &mut self, request: LanguageModelRequest, @@ -3156,10 +3262,13 @@ mod tests { const TEST_RATE_LIMIT_RETRY_SECS: u64 = 30; use agent_settings::{AgentProfileId, AgentSettings, LanguageModelParameters}; use assistant_tool::ToolRegistry; + use assistant_tools; use futures::StreamExt; use futures::future::BoxFuture; 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, @@ -3487,6 +3596,105 @@ fn main() {{ ); } + #[gpui::test] + async fn test_stale_buffer_notification(cx: &mut TestAppContext) { + init_test_settings(cx); + + let project = create_test_project( + cx, + json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), + ) + .await; + + let (_workspace, _thread_store, thread, context_store, model) = + setup_test_environment(cx, project.clone()).await; + + // Add a buffer to the context. This will be a tracked buffer + let buffer = add_file_to_context(&project, &context_store, "test/code.rs", cx) + .await + .unwrap(); + + let context = context_store + .read_with(cx, |store, _| store.context().next().cloned()) + .unwrap(); + let loaded_context = cx + .update(|cx| load_context(vec![context], &project, &None, cx)) + .await; + + // Insert user message and assistant response + thread.update(cx, |thread, cx| { + thread.insert_user_message("Explain this code", loaded_context, None, Vec::new(), cx); + thread.insert_assistant_message( + vec![MessageSegment::Text("This code prints 42.".into())], + cx, + ); + }); + + // We shouldn't have a stale buffer notification yet + let notification = thread.read_with(cx, |thread, _| { + find_tool_use(thread, "project_notifications") + }); + assert!( + notification.is_none(), + "Should not have stale buffer notification before buffer is modified" + ); + + // Modify the buffer + buffer.update(cx, |buffer, cx| { + buffer.edit( + [(1..1, "\n println!(\"Added a new line\");\n")], + None, + cx, + ); + }); + + // Insert another user message + thread.update(cx, |thread, cx| { + thread.insert_user_message( + "What does the code do now?", + ContextLoadResult::default(), + None, + Vec::new(), + cx, + ) + }); + + // Check for the stale buffer warning + thread.update(cx, |thread, cx| { + thread.flush_notifications(model.clone(), CompletionIntent::UserPrompt, cx) + }); + + let Some(notification_result) = thread.read_with(cx, |thread, _cx| { + find_tool_use(thread, "project_notifications") + }) else { + panic!("Should have a `project_notifications` tool use"); + }; + + let Some(notification_content) = notification_result.content.to_str() else { + 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); + } + + fn find_tool_use(thread: &Thread, tool_name: &str) -> Option { + thread + .messages() + .filter_map(|message| { + thread + .tool_results_for_message(message.id) + .into_iter() + .find(|result| result.tool_name == tool_name.into()) + }) + .next() + .cloned() + } + #[gpui::test] async fn test_storing_profile_setting_per_thread(cx: &mut TestAppContext) { init_test_settings(cx); @@ -5052,6 +5260,14 @@ fn main() {{ language_model::init_settings(cx); ThemeSettings::register(cx); ToolRegistry::default_global(cx); + assistant_tool::init(cx); + + let http_client = Arc::new(http_client::HttpClientWithUrl::new( + http_client::FakeHttpClient::with_200_response(), + "http://localhost".to_string(), + None, + )); + assistant_tools::init(http_client, cx); }); } diff --git a/crates/assistant_tools/src/assistant_tools.rs b/crates/assistant_tools/src/assistant_tools.rs index 83312a07b625404085694194b92ee7c732a67998..eef792f526fb684e83752241194d293064a9f4f7 100644 --- a/crates/assistant_tools/src/assistant_tools.rs +++ b/crates/assistant_tools/src/assistant_tools.rs @@ -11,6 +11,7 @@ mod list_directory_tool; mod move_path_tool; mod now_tool; mod open_tool; +mod project_notifications_tool; mod read_file_tool; mod schema; mod templates; @@ -45,6 +46,7 @@ pub use edit_file_tool::{EditFileMode, EditFileToolInput}; pub use find_path_tool::FindPathToolInput; pub use grep_tool::{GrepTool, GrepToolInput}; pub use open_tool::OpenTool; +pub use project_notifications_tool::ProjectNotificationsTool; pub use read_file_tool::{ReadFileTool, ReadFileToolInput}; pub use terminal_tool::TerminalTool; @@ -61,6 +63,7 @@ pub fn init(http_client: Arc, cx: &mut App) { registry.register_tool(ListDirectoryTool); registry.register_tool(NowTool); registry.register_tool(OpenTool); + registry.register_tool(ProjectNotificationsTool); registry.register_tool(FindPathTool); registry.register_tool(ReadFileTool); registry.register_tool(GrepTool); diff --git a/crates/assistant_tools/src/project_notifications_tool.rs b/crates/assistant_tools/src/project_notifications_tool.rs new file mode 100644 index 0000000000000000000000000000000000000000..552ebb3d53e7453dbcaa4f363bd6e6ae5d2709b6 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool.rs @@ -0,0 +1,193 @@ +use crate::schema::json_schema_for; +use anyhow::Result; +use assistant_tool::{ActionLog, Tool, ToolResult}; +use gpui::{AnyWindowHandle, App, Entity, Task}; +use language_model::{LanguageModel, LanguageModelRequest, LanguageModelToolSchemaFormat}; +use project::Project; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; +use std::sync::Arc; +use ui::IconName; + +#[derive(Debug, Serialize, Deserialize, JsonSchema)] +pub struct ProjectUpdatesToolInput {} + +pub struct ProjectNotificationsTool; + +impl Tool for ProjectNotificationsTool { + fn name(&self) -> String { + "project_notifications".to_string() + } + + fn needs_confirmation(&self, _: &serde_json::Value, _: &App) -> bool { + false + } + fn may_perform_edits(&self) -> bool { + false + } + fn description(&self) -> String { + include_str!("./project_notifications_tool/description.md").to_string() + } + + fn icon(&self) -> IconName { + IconName::Envelope + } + + fn input_schema(&self, format: LanguageModelToolSchemaFormat) -> Result { + json_schema_for::(format) + } + + fn ui_text(&self, _input: &serde_json::Value) -> String { + "Check project notifications".into() + } + + fn run( + self: Arc, + _input: serde_json::Value, + _request: Arc, + _project: Entity, + action_log: Entity, + _model: Arc, + _window: Option, + cx: &mut App, + ) -> ToolResult { + let mut stale_files = String::new(); + + let action_log = action_log.read(cx); + + for stale_file in action_log.stale_buffers(cx) { + if let Some(file) = stale_file.read(cx).file() { + writeln!(&mut stale_files, "- {}", file.path().display()).ok(); + } + } + + 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") + }; + + Task::ready(Ok(response.into())).into() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use assistant_tool::ToolResultContent; + use gpui::{AppContext, TestAppContext}; + use language_model::{LanguageModelRequest, fake_provider::FakeLanguageModelProvider}; + use project::{FakeFs, Project}; + use serde_json::json; + use settings::SettingsStore; + use std::sync::Arc; + use util::path; + + #[gpui::test] + async fn test_stale_buffer_notification(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + fs.insert_tree( + path!("/test"), + json!({"code.rs": "fn main() {\n println!(\"Hello, world!\");\n}"}), + ) + .await; + + let project = Project::test(fs, [path!("/test").as_ref()], cx).await; + let action_log = cx.new(|_| ActionLog::new(project.clone())); + + let buffer_path = project + .read_with(cx, |project, cx| { + project.find_project_path("test/code.rs", cx) + }) + .unwrap(); + + let buffer = project + .update(cx, |project, cx| { + project.open_buffer(buffer_path.clone(), cx) + }) + .await + .unwrap(); + + // Start tracking the buffer + action_log.update(cx, |log, cx| { + log.buffer_read(buffer.clone(), cx); + }); + + // Run the tool before any changes + let tool = Arc::new(ProjectNotificationsTool); + let provider = Arc::new(FakeLanguageModelProvider); + let model: Arc = Arc::new(provider.test_model()); + let request = Arc::new(LanguageModelRequest::default()); + let tool_input = json!({}); + + let result = cx.update(|cx| { + tool.clone().run( + tool_input.clone(), + request.clone(), + project.clone(), + action_log.clone(), + model.clone(), + None, + cx, + ) + }); + + let response = result.output.await.unwrap(); + let response_text = match &response.content { + ToolResultContent::Text(text) => text.clone(), + _ => panic!("Expected text response"), + }; + assert_eq!( + response_text.as_str(), + "No new notifications", + "Tool should return 'No new notifications' when no stale buffers" + ); + + // Modify the buffer (makes it stale) + buffer.update(cx, |buffer, cx| { + buffer.edit([(1..1, "\nChange!\n")], None, cx); + }); + + // Run the tool again + let result = cx.update(|cx| { + tool.run( + tool_input.clone(), + request.clone(), + project.clone(), + action_log, + model.clone(), + None, + cx, + ) + }); + + // This time the buffer is stale, so the tool should return a notification + let response = result.output.await.unwrap(); + let response_text = match &response.content { + ToolResultContent::Text(text) => text.clone(), + _ => 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, + "Tool should return the stale buffer notification" + ); + } + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings_store = SettingsStore::test(cx); + cx.set_global(settings_store); + language::init(cx); + Project::init_settings(cx); + assistant_tool::init(cx); + }); + } +} diff --git a/crates/assistant_tools/src/project_notifications_tool/description.md b/crates/assistant_tools/src/project_notifications_tool/description.md new file mode 100644 index 0000000000000000000000000000000000000000..24ff678f5e7fd728b94ad4ebce06f2a1dcc6a658 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool/description.md @@ -0,0 +1,3 @@ +This tool reports which files have been modified by the user since the agent last accessed them. + +It serves as a notification mechanism to inform the agent of recent changes. No immediate action is required in response to these updates. diff --git a/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt new file mode 100644 index 0000000000000000000000000000000000000000..f743e239c883c7456f7bdc6e089185c6b994cb44 --- /dev/null +++ b/crates/assistant_tools/src/project_notifications_tool/prompt_header.txt @@ -0,0 +1,3 @@ +[The following is an auto-generated notification; do not reply] + +These files have changed since the last read: diff --git a/crates/eval/src/examples/file_change_notification.rs b/crates/eval/src/examples/file_change_notification.rs index 0e4f770a6757a216061e28efb227a339f1094084..7879ad6f2ebb782bd4a5620f0fdf562c9aad1360 100644 --- a/crates/eval/src/examples/file_change_notification.rs +++ b/crates/eval/src/examples/file_change_notification.rs @@ -14,7 +14,7 @@ impl Example for FileChangeNotificationExample { url: "https://github.com/octocat/hello-world".to_string(), revision: "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d".to_string(), language_server: None, - max_assertions: Some(1), + max_assertions: None, profile_id: AgentProfileId::default(), existing_thread_json: None, max_turns: Some(3), From 8cc3b094d23afaa5cad9326831e2cc461a586296 Mon Sep 17 00:00:00 2001 From: Alex Povel Date: Mon, 7 Jul 2025 19:02:35 +0200 Subject: [PATCH 19/21] editor: Add action to sort lines by length (#33622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This change introduces a new `Action` implementation to sort lines by their `char` length. It reuses the same calculation as used for getting the caret column position, i.e. `TextSummary`. The motivation is to e.g. handle source code where this sort of order matters ([example](https://github.com/alexpovel/srgn/blob/fdf537c3d3e4c18ebf3426bb34759400552a82c3/tests/readme.rs#L529-L535)). Tested manually via `cargo build && ./target/debug/zed .`: the new action shows up in the command palette, and testing it on `.mailmap` entries turns those from ```text Agus Zubiaga Agus Zubiaga Alex Viscreanu Alex Viscreanu Alexander Mankuta Alexander Mankuta amtoaer amtoaer Andrei Zvonimir Crnković Andrei Zvonimir Crnković Angelk90 Angelk90 <20476002+Angelk90@users.noreply.github.com> Antonio Scandurra Antonio Scandurra Ben Kunkle Ben Kunkle Bennet Bo Fenner Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Bennet Bo Fenner Boris Cherny Boris Cherny Brian Tan Chris Hayes Christian Bergschneider Christian Bergschneider Conrad Irwin Conrad Irwin Dairon Medina Danilo Leal Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Edwin Aronsson <75266237+4teapo@users.noreply.github.com> Elvis Pranskevichus Elvis Pranskevichus Evren Sen Evren Sen <146845123+evrensen467@users.noreply.github.com> Evren Sen <146845123+evrsen@users.noreply.github.com> Fernando Tagawa Fernando Tagawa Finn Evers Finn Evers <75036051+MrSubidubi@users.noreply.github.com> Finn Evers Gowtham K <73059450+dovakin0007@users.noreply.github.com> Greg Morenz Greg Morenz Ihnat Aŭtuška Ivan Žužak Ivan Žužak Joseph T. Lyons Joseph T. Lyons Julia Julia <30666851+ForLoveOfCats@users.noreply.github.com> Kaylee Simmons Kaylee Simmons Kaylee Simmons Kaylee Simmons Kirill Bulatov Kirill Bulatov Kyle Caverly Kyle Caverly Lilith Iris Lilith Iris <83819417+Irilith@users.noreply.github.com> LoganDark LoganDark LoganDark Marko Kungla Marko Kungla Marshall Bowers Marshall Bowers Marshall Bowers Matt Fellenz Matt Fellenz Max Brunsfeld Max Brunsfeld Max Linke Max Linke Michael Sloan Michael Sloan Michael Sloan Mikayla Maki Mikayla Maki Mikayla Maki Morgan Krey Muhammad Talal Anwar Muhammad Talal Anwar Nate Butler Nate Butler Nathan Sobo Nathan Sobo Nathan Sobo Nigel Jose Nigel Jose Peter Tripp Peter Tripp Petros Amoiridis Petros Amoiridis Piotr Osiewicz Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Pocæus Pocæus Rashid Almheiri Rashid Almheiri <69181766+huwaireb@users.noreply.github.com> Richard Feldman Richard Feldman Robert Clover Robert Clover Roy Williams Roy Williams Sebastijan Kelnerič Sebastijan Kelnerič Sergey Onufrienko Shish Shish Smit Barmase <0xtimsb@gmail.com> Smit Barmase <0xtimsb@gmail.com> Thomas Thomas Thomas Thomas Heartman Thomas Heartman Thomas Mickley-Doyle Thomas Mickley-Doyle Thorben Kröger Thorben Kröger Thorsten Ball Thorsten Ball Thorsten Ball Tristan Hume Tristan Hume Uladzislau Kaminski Uladzislau Kaminski Vitaly Slobodin Vitaly Slobodin Will Bradley Will Bradley WindSoilder 张小白 <364772080@qq.com> ```` into ```text 张小白 <364772080@qq.com> Ben Kunkle Finn Evers Agus Zubiaga amtoaer Peter Tripp Pocæus Danilo Leal Matt Fellenz Morgan Krey Nathan Sobo Robert Clover Conrad Irwin Ivan Žužak Mikayla Maki Piotr Osiewicz Shish Evren Sen Kirill Bulatov Michael Sloan Angelk90 Max Linke Smit Barmase <0xtimsb@gmail.com> Thorben Kröger Antonio Scandurra Bennet Bo Fenner Brian Tan Julia Nigel Jose Petros Amoiridis Rashid Almheiri Thomas Boris Cherny Nate Butler Thorsten Ball Tristan Hume Richard Feldman Greg Morenz Kaylee Simmons Lilith Iris Marshall Bowers Muhammad Talal Anwar Kyle Caverly Marko Kungla WindSoilder Alexander Mankuta Chris Hayes Max Brunsfeld Dairon Medina Elvis Pranskevichus Agus Zubiaga Alex Viscreanu Ihnat Aŭtuška Joseph T. Lyons Uladzislau Kaminski Will Bradley LoganDark Roy Williams Sergey Onufrienko Andrei Zvonimir Crnković Fernando Tagawa Vitaly Slobodin Nathan Sobo Thomas Mickley-Doyle Ben Kunkle Smit Barmase <0xtimsb@gmail.com> Finn Evers Robert Clover amtoaer Nate Butler Thomas Heartman Kaylee Simmons Peter Tripp Petros Amoiridis Pocæus Antonio Scandurra Bennet Bo Fenner Matt Fellenz Michael Sloan Nathan Sobo Sebastijan Kelnerič Shish Kaylee Simmons Kyle Caverly Max Brunsfeld Michael Sloan Ivan Žužak Richard Feldman Thorsten Ball Conrad Irwin Kirill Bulatov Marshall Bowers Will Bradley Christian Bergschneider Elvis Pranskevichus Greg Morenz Thorsten Ball Edwin Aronsson <75266237+4teapo@users.noreply.github.com> Gowtham K <73059450+dovakin0007@users.noreply.github.com> Marko Kungla Mikayla Maki Mikayla Maki Tristan Hume Thomas Boris Cherny Kaylee Simmons Thomas Muhammad Talal Anwar Roy Williams Marshall Bowers Thorben Kröger Andrei Zvonimir Crnković Thomas Mickley-Doyle Alexander Mankuta LoganDark Max Linke Alex Viscreanu Finn Evers <75036051+MrSubidubi@users.noreply.github.com> Nigel Jose Uladzislau Kaminski LoganDark Thomas Heartman Christian Bergschneider Evren Sen <146845123+evrsen@users.noreply.github.com> Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Vitaly Slobodin Danilo Leal <67129314+danilo-leal@users.noreply.github.com> Angelk90 <20476002+Angelk90@users.noreply.github.com> Bennet Bo Fenner <53836821+bennetbo@users.noreply.github.com> Rashid Almheiri <69181766+huwaireb@users.noreply.github.com> Evren Sen <146845123+evrensen467@users.noreply.github.com> Joseph T. Lyons Lilith Iris <83819417+Irilith@users.noreply.github.com> Fernando Tagawa Julia <30666851+ForLoveOfCats@users.noreply.github.com> Sebastijan Kelnerič ``` which looks good. There's a bit of Unicode in there -- though no grapheme clusters. Column number calculations do not seem to handle grapheme clusters either (?) so I thought this is OK. Open questions are: - should this be added to vim mode as well? - is `TextSummary` the way to go here? Is it perhaps too expensive? (it seems fine -- manually counting `char`s seems more brittle -- this way it will stay in sync with column number calculations) --- Team, I realize you [ask for a discussion to be opened first](https://github.com/zed-industries/zed/blob/86161aa427b9e8b18486272ca436c344224e8ba4/CONTRIBUTING.md#L32), so apologies for not doing that! It turned out hacking on Zed was much easier than expected (it's really nice!), and this change is small, adding a variation to an existing feature. Hope that's fine. Release Notes: - Added feature to sort lines by their length --------- Co-authored-by: Conrad Irwin --- crates/editor/src/actions.rs | 2 ++ crates/editor/src/editor.rs | 11 +++++++++++ crates/editor/src/editor_tests.rs | 23 +++++++++++++++++++++++ crates/editor/src/element.rs | 1 + 4 files changed, 37 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index def2a616a8aa0f29e330b85c75cb8ae2d285542a..70ec8ea00f52dccbab9e2a3ad4856599a8a94acf 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -635,6 +635,8 @@ actions!( SignatureHelpNext, /// Navigates to the previous signature in the signature help popup. SignatureHelpPrevious, + /// Sorts selected lines by length. + SortLinesByLength, /// Sorts selected lines case-insensitively. SortLinesCaseInsensitive, /// Sorts selected lines case-sensitively. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 47223aa59a86eef27494f28dccae144c14a59f85..6d529287a778e1b56994f80084dfb52f33d1e893 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -10204,6 +10204,17 @@ impl Editor { self.manipulate_immutable_lines(window, cx, |lines| lines.sort()) } + pub fn sort_lines_by_length( + &mut self, + _: &SortLinesByLength, + window: &mut Window, + cx: &mut Context, + ) { + self.manipulate_immutable_lines(window, cx, |lines| { + lines.sort_by_key(|&line| line.chars().count()) + }) + } + pub fn sort_lines_case_insensitive( &mut self, _: &SortLinesCaseInsensitive, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index ade9a9322bcdbe38ad33fe9611820c43e2ea5809..05280de02b630eba7c35c4cbacc8d4173cf753a5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -4075,6 +4075,29 @@ async fn test_manipulate_immutable_lines_with_single_selection(cx: &mut TestAppC Zˇ» "}); + // Test sort_lines_by_length() + // + // Demonstrates: + // - ∞ is 3 bytes UTF-8, but sorted by its char count (1) + // - sort is stable + cx.set_state(indoc! {" + «123 + æ + 12 + ∞ + 1 + æˇ» + "}); + cx.update_editor(|e, window, cx| e.sort_lines_by_length(&SortLinesByLength, window, cx)); + cx.assert_editor_state(indoc! {" + «æ + ∞ + 1 + æ + 12 + 123ˇ» + "}); + // Test reverse_lines() cx.set_state(indoc! {" «5 diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 3fa8697c193f80e2f974c57b74947e32a689a506..49f4fc52ac725bcef2707f2bf508a1fae811f434 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -225,6 +225,7 @@ impl EditorElement { register_action(editor, window, Editor::autoindent); register_action(editor, window, Editor::delete_line); register_action(editor, window, Editor::join_lines); + register_action(editor, window, Editor::sort_lines_by_length); register_action(editor, window, Editor::sort_lines_case_sensitive); register_action(editor, window, Editor::sort_lines_case_insensitive); register_action(editor, window, Editor::reverse_lines); From 38febed02d9341d9dc812c0895dc1144733ef94c Mon Sep 17 00:00:00 2001 From: Smit Barmase Date: Mon, 7 Jul 2025 22:41:29 +0530 Subject: [PATCH 20/21] languages: Fix detents case line after typing `:` in Python (#34017) Closes #34002 `decrease_indent_patterns` should only contain mapping which are at same indent level with each other, which is not true for `match` and `case` mapping. Caused in https://github.com/zed-industries/zed/pull/33370 Release Notes: - N/A --- crates/editor/src/editor_tests.rs | 13 +++++++++++++ crates/languages/src/python/config.toml | 1 - crates/languages/src/python/indents.scm | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 05280de02b630eba7c35c4cbacc8d4173cf753a5..aea84de9b022d742080ca9187a6a835092da67af 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -22348,6 +22348,19 @@ async fn test_outdent_after_input_for_python(cx: &mut TestAppContext) { def f() -> list[str]: aˇ "}); + + // test does not outdent on typing : after case keyword + cx.set_state(indoc! {" + match 1: + caseˇ + "}); + cx.update_editor(|editor, window, cx| { + editor.handle_input(":", window, cx); + }); + cx.assert_editor_state(indoc! {" + match 1: + case:ˇ + "}); } #[gpui::test] diff --git a/crates/languages/src/python/config.toml b/crates/languages/src/python/config.toml index 6d83d3f3dec6ba44e87e1d361fb5e61198767874..8728dfeaf138a97a7d9d7e9e2e3ca4b6b6db1820 100644 --- a/crates/languages/src/python/config.toml +++ b/crates/languages/src/python/config.toml @@ -34,5 +34,4 @@ decrease_indent_patterns = [ { pattern = "^\\s*else\\b.*:", valid_after = ["if", "elif", "for", "while", "except"] }, { pattern = "^\\s*except\\b.*:", valid_after = ["try", "except"] }, { pattern = "^\\s*finally\\b.*:", valid_after = ["try", "except", "else"] }, - { pattern = "^\\s*case\\b.*:", valid_after = ["match", "case"] } ] diff --git a/crates/languages/src/python/indents.scm b/crates/languages/src/python/indents.scm index 617aa706d3177c368f334c409989a27d09655b1e..3d4c1cc9c4260d4e925cc373662ae5ca3b82e124 100644 --- a/crates/languages/src/python/indents.scm +++ b/crates/languages/src/python/indents.scm @@ -14,4 +14,4 @@ (else_clause) @start.else (except_clause) @start.except (finally_clause) @start.finally -(case_pattern) @start.case +(case_clause) @start.case From 2a6ef006f4ea35ee0e611aa26640aed8b655022a Mon Sep 17 00:00:00 2001 From: Peter Tripp Date: Mon, 7 Jul 2025 13:56:53 -0400 Subject: [PATCH 21/21] Make `script/generate-license` fail on WARN too (#34008) Current main shows this on `script/generate-licenses`: ``` [WARN] failed to validate all files specified in clarification for crate ring 0.17.14: checksum mismatch, expected '76b39f9b371688eac9d8323f96ee80b3aef5ecbc2217f25377bd4e4a615296a9' ``` Ring fixed it's licenses ambiguity upstream. This warning was identifying that their license text (multiple licenses concatenated) had changed (sha mismatch) and thus our license clarification was invalid. Tested the script to confirm this [now fails](https://github.com/zed-industries/zed/actions/runs/16118890720/job/45479355992?pr=34008) under CI and then removed the ring clarification because it is no longer required and now passes. Release Notes: - N/A --- .github/workflows/ci.yml | 2 +- script/generate-licenses | 11 ++++++++++- script/licenses/zed-licenses.toml | 6 ------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 39036ef5649e699ffda1636f304629fce6184371..84c7a9682819408c95fb8dfaa1f424bd3f847a9a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -65,7 +65,7 @@ jobs: else echo "run_docs=false" >> $GITHUB_OUTPUT fi - if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep '^Cargo.lock') ]]; then + if [[ $(git diff --name-only $COMPARE_REV ${{ github.sha }} | grep -P '^(Cargo.lock|script/.*licenses)') ]]; then echo "run_license=true" >> $GITHUB_OUTPUT else echo "run_license=false" >> $GITHUB_OUTPUT diff --git a/script/generate-licenses b/script/generate-licenses index 9fcb2bd5133e10007f3335abd59fa7c4da2e3176..7ae0f1c3f60b93eb500ffa5e127a96c82d954b72 100755 --- a/script/generate-licenses +++ b/script/generate-licenses @@ -6,6 +6,15 @@ CARGO_ABOUT_VERSION="0.7" OUTPUT_FILE="${1:-$(pwd)/assets/licenses.md}" TEMPLATE_FILE="script/licenses/template.md.hbs" +fail_on_stderr() { + local tmpfile=$(mktemp) + "$@" 2> >(tee "$tmpfile" >&2) + local rc=$? + [ -s "$tmpfile" ] && rc=1 + rm "$tmpfile" + return $rc +} + echo -n "" >"$OUTPUT_FILE" { @@ -28,7 +37,7 @@ fi echo "Generating cargo licenses" if [ -z "${ALLOW_MISSING_LICENSES-}" ]; then FAIL_FLAG=--fail; else FAIL_FLAG=""; fi set -x -cargo about generate \ +fail_on_stderr cargo about generate \ $FAIL_FLAG \ -c script/licenses/zed-licenses.toml \ "$TEMPLATE_FILE" >>"$OUTPUT_FILE" diff --git a/script/licenses/zed-licenses.toml b/script/licenses/zed-licenses.toml index 4df1f5989ab3aa0c70b887d0a9cfc07719f3e7c3..9d13087ece08404e2cae1a44733e382521b8fdd0 100644 --- a/script/licenses/zed-licenses.toml +++ b/script/licenses/zed-licenses.toml @@ -177,9 +177,3 @@ license = "MIT" [[pet-windows-store.clarify.files]] path = '../../LICENSE' checksum = 'c2cfccb812fe482101a8f04597dfc5a9991a6b2748266c47ac91b6a5aae15383' - -[ring.clarify] -license = "ISC AND OpenSSL" -[[ring.clarify.files]] -path = 'LICENSE' -checksum = '76b39f9b371688eac9d8323f96ee80b3aef5ecbc2217f25377bd4e4a615296a9'